diff --git a/icons/joint_build.png b/icons/joint_build.png new file mode 100644 index 0000000..967f3a0 Binary files /dev/null and b/icons/joint_build.png differ diff --git a/package-lock.json b/package-lock.json index 2f2c1f3..69ff5cf 100644 --- a/package-lock.json +++ b/package-lock.json @@ -10,16 +10,20 @@ "license": "MIT", "dependencies": { "@langchain/anthropic": "^0.3.30", + "@langchain/core": "^1.1.19", "@langchain/deepseek": "^0.1.0", "@langchain/google-genai": "^0.2.18", "@langchain/ollama": "^0.2.4", "@langchain/openai": "^0.6.14", + "@types/parse-torrent": "^5.8.8", "@types/webtorrent": "^0.110.1", "electron-squirrel-startup": "^1.0.1", "langchain": "^0.3.35", + "parse-torrent": "^11.0.19", "pdf-lib": "^1.17.1", "react": "^19.1.0", - "react-dom": "^19.1.0" + "react-dom": "^19.1.0", + "semver": "^7.7.3" }, "devDependencies": { "@ant-design/v5-patch-for-react-19": "^1.0.3", @@ -1985,8 +1989,7 @@ "version": "4.1.1", "resolved": "https://registry.npmmirror.com/@cfworker/json-schema/-/json-schema-4.1.1.tgz", "integrity": "sha512-gAmrUZSGtKc3AiBL71iNWxDsyUC5uMaKKGdvzYsBoTW/xi42JQHl7eKV2OYzCUqvc+D2RCcf7EXY2iCyFIk6og==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/@cspotcode/source-map-support": { "version": "0.8.1", @@ -3373,27 +3376,58 @@ } }, "node_modules/@langchain/core": { - "version": "0.3.80", - "resolved": "https://registry.npmmirror.com/@langchain/core/-/core-0.3.80.tgz", - "integrity": "sha512-vcJDV2vk1AlCwSh3aBm/urQ1ZrlXFFBocv11bz/NBUfLWD5/UDNMzwPdaAd2dKvNmTWa9FM2lirLU3+JCf4cRA==", + "version": "1.1.19", + "resolved": "https://registry.npmmirror.com/@langchain/core/-/core-1.1.19.tgz", + "integrity": "sha512-hmNAcgeLLqNLnu8UK+HVTfB8170eCmAfsy4gFLBYeE+kdsnyO0Hd/Kd42pZi8Cgfux4OMX3yxl+g+/a1ktGe0A==", "license": "MIT", - "peer": true, "dependencies": { "@cfworker/json-schema": "^4.0.2", "ansi-styles": "^5.0.0", "camelcase": "6", "decamelize": "1.2.0", "js-tiktoken": "^1.0.12", - "langsmith": "^0.3.67", + "langsmith": ">=0.4.0 <1.0.0", "mustache": "^4.2.0", "p-queue": "^6.6.2", - "p-retry": "4", "uuid": "^10.0.0", - "zod": "^3.25.32", - "zod-to-json-schema": "^3.22.3" + "zod": "^3.25.76 || ^4" }, "engines": { - "node": ">=18" + "node": ">=20" + } + }, + "node_modules/@langchain/core/node_modules/langsmith": { + "version": "0.4.12", + "resolved": "https://registry.npmmirror.com/langsmith/-/langsmith-0.4.12.tgz", + "integrity": "sha512-YWt0jcGvKqjUgIvd78rd4QcdMss0lUkeUaqp0UpVRq7H2yNDx8H5jOUO/laWUmaPtWGgcip0qturykXe1g9Gqw==", + "license": "MIT", + "dependencies": { + "@types/uuid": "^10.0.0", + "chalk": "^4.1.2", + "console-table-printer": "^2.12.1", + "p-queue": "^6.6.2", + "semver": "^7.6.3", + "uuid": "^10.0.0" + }, + "peerDependencies": { + "@opentelemetry/api": "*", + "@opentelemetry/exporter-trace-otlp-proto": "*", + "@opentelemetry/sdk-trace-base": "*", + "openai": "*" + }, + "peerDependenciesMeta": { + "@opentelemetry/api": { + "optional": true + }, + "@opentelemetry/exporter-trace-otlp-proto": { + "optional": true + }, + "@opentelemetry/sdk-trace-base": { + "optional": true + }, + "openai": { + "optional": true + } } }, "node_modules/@langchain/core/node_modules/uuid": { @@ -3405,7 +3439,6 @@ "https://github.com/sponsors/ctavan" ], "license": "MIT", - "peer": true, "bin": { "uuid": "dist/bin/uuid" } @@ -5500,22 +5533,6 @@ } } }, - "node_modules/@svgr/core/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@svgr/hast-util-to-babel-ast": { "version": "8.0.0", "resolved": "https://registry.npmmirror.com/@svgr/hast-util-to-babel-ast/-/hast-util-to-babel-ast-8.0.0.tgz", @@ -5606,22 +5623,6 @@ } } }, - "node_modules/@svgr/plugin-svgo/node_modules/typescript": { - "version": "5.9.3", - "resolved": "https://registry.npmmirror.com/typescript/-/typescript-5.9.3.tgz", - "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", - "dev": true, - "license": "Apache-2.0", - "optional": true, - "peer": true, - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, "node_modules/@svgr/webpack": { "version": "8.1.0", "resolved": "https://registry.npmmirror.com/@svgr/webpack/-/webpack-8.1.0.tgz", @@ -5698,7 +5699,6 @@ "version": "1.0.5", "resolved": "https://registry.npmmirror.com/@thaunknown/thirty-two/-/thirty-two-1.0.5.tgz", "integrity": "sha512-Q53KyCXweV1CS62EfqtPDqfpksn5keQ59PGqzzkK+g8Vif1jB4inoBCcs/BUSdsqddhE3G+2Fn+4RX3S6RqT0A==", - "dev": true, "license": "MIT", "dependencies": { "uint8-util": "^2.2.5" @@ -6685,7 +6685,6 @@ "version": "3.0.0", "resolved": "https://registry.npmmirror.com/abort-controller/-/abort-controller-3.0.0.tgz", "integrity": "sha512-h8lQ8tacZYnR3vNQTgibj+tODHI5/+l06Au2Pcriv/Gmet0eaj4TwWH41sO9wnHDiQsEj19q0drzdWdeAHtweg==", - "dev": true, "license": "MIT", "dependencies": { "event-target-shim": "^5.0.0" @@ -6920,7 +6919,6 @@ "resolved": "https://registry.npmmirror.com/ansi-styles/-/ansi-styles-5.2.0.tgz", "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", "license": "MIT", - "peer": true, "engines": { "node": ">=10" }, @@ -7518,7 +7516,6 @@ "version": "1.0.2", "resolved": "https://registry.npmmirror.com/base64-arraybuffer/-/base64-arraybuffer-1.0.2.tgz", "integrity": "sha512-I3yl4r9QB5ZRY3XuJVEPfc2XhZO6YweFPI+UovAzn+8/hb3oJ6lnysaFcjVpkCPfVWFUDvoZ8kmVDP7WyRtYtQ==", - "dev": true, "license": "MIT", "engines": { "node": ">= 0.6.0" @@ -7575,7 +7572,6 @@ "version": "4.0.0", "resolved": "https://registry.npmmirror.com/bencode/-/bencode-4.0.0.tgz", "integrity": "sha512-AERXw18df0pF3ziGOCyUjqKZBVNH8HV3lBxnx5w0qtgMIk4a1wb9BkcCQbkp9Zstfrn/dzRwl7MmUHHocX3sRQ==", - "dev": true, "license": "MIT", "dependencies": { "uint8-util": "^2.2.2" @@ -7588,7 +7584,6 @@ "version": "2.0.0", "resolved": "https://registry.npmmirror.com/bep53-range/-/bep53-range-2.0.0.tgz", "integrity": "sha512-sMm2sV5PRs0YOVk0LTKtjuIprVzxgTQUsrGX/7Yph2Rm4FO2Fqqtq7hNjsOB5xezM4v4+5rljCgK++UeQJZguA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12.20.0" @@ -9509,7 +9504,6 @@ "version": "1.0.3", "resolved": "https://registry.npmmirror.com/cross-fetch-ponyfill/-/cross-fetch-ponyfill-1.0.3.tgz", "integrity": "sha512-uOBkDhUAGAbx/FEzNKkOfx3w57H8xReBBXoZvUnOKTI0FW0Xvrj3GrYv2iZXUqlffC1LMGfQzhmBM/ke+6eTDA==", - "dev": true, "license": "MIT", "dependencies": { "abort-controller": "^3.0.0", @@ -9520,7 +9514,6 @@ "version": "3.3.2", "resolved": "https://registry.npmmirror.com/node-fetch/-/node-fetch-3.3.2.tgz", "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", - "dev": true, "license": "MIT", "dependencies": { "data-uri-to-buffer": "^4.0.0", @@ -9741,7 +9734,6 @@ "version": "4.0.1", "resolved": "https://registry.npmmirror.com/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", - "dev": true, "license": "MIT", "engines": { "node": ">= 12" @@ -9831,7 +9823,6 @@ "resolved": "https://registry.npmmirror.com/decamelize/-/decamelize-1.2.0.tgz", "integrity": "sha512-z2S+W9X73hAUUki+N+9Za2lBlun89zigOyGrsax+KUQ6wKW4ZoWpEYBkGhQjwAjjDCkWxhY0VKEhk8wzY7F5cA==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -11784,7 +11775,6 @@ "version": "5.0.1", "resolved": "https://registry.npmmirror.com/event-target-shim/-/event-target-shim-5.0.1.tgz", "integrity": "sha512-i/2XbnSz/uxRCU6+NdVJgKWDTM427+MqYbkQzD321DuCQJUqOuJKIA0IM2+W2xtYHdKOmZ4dR6fExsd4SXL+WQ==", - "dev": true, "license": "MIT", "engines": { "node": ">=6" @@ -12010,22 +12000,6 @@ "express": "^4.0.0 || ^5.0.0-alpha.1" } }, - "node_modules/express-ws/node_modules/utf-8-validate": { - "version": "5.0.10", - "resolved": "https://registry.npmmirror.com/utf-8-validate/-/utf-8-validate-5.0.10.tgz", - "integrity": "sha512-Z6czzLq4u8fPOyx7TU6X3dvUZVvoJmxSQ+IcrlmagKhilxlhZgxPK6C5Jqbkw1IDUmFTM+cz9QDnnLTwDz/2gQ==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "peer": true, - "dependencies": { - "node-gyp-build": "^4.3.0" - }, - "engines": { - "node": ">=6.14.2" - } - }, "node_modules/express-ws/node_modules/ws": { "version": "7.5.10", "resolved": "https://registry.npmmirror.com/ws/-/ws-7.5.10.tgz", @@ -12258,7 +12232,6 @@ "version": "3.2.0", "resolved": "https://registry.npmmirror.com/fetch-blob/-/fetch-blob-3.2.0.tgz", "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", - "dev": true, "funding": [ { "type": "github", @@ -12545,7 +12518,6 @@ "version": "4.0.10", "resolved": "https://registry.npmmirror.com/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", - "dev": true, "license": "MIT", "dependencies": { "fetch-blob": "^3.1.2" @@ -12911,7 +12883,6 @@ "version": "9.0.0", "resolved": "https://registry.npmmirror.com/get-stdin/-/get-stdin-9.0.0.tgz", "integrity": "sha512-dVKBjfWisLAicarI2Sf+JuBE/DghV4UzNAVe9yhEJuzeREd3JhOTE9cUaJTeSa77fsbQUK3pcOpJfM59+VKZaA==", - "dev": true, "license": "MIT", "engines": { "node": ">=12" @@ -15500,7 +15471,6 @@ "version": "7.0.7", "resolved": "https://registry.npmmirror.com/magnet-uri/-/magnet-uri-7.0.7.tgz", "integrity": "sha512-z/+dB2NQsXaDuxVBjoPLpZT8ePaacUmoontoFheRBl++nALHYs4qV9MmhTur9e4SaMbkCR/uPX43UMzEOoeyaw==", - "dev": true, "funding": [ { "type": "github", @@ -16006,7 +15976,6 @@ "resolved": "https://registry.npmmirror.com/mustache/-/mustache-4.2.0.tgz", "integrity": "sha512-71ippSywq5Yb7/tVYyGbkBggbU8H3u5Rz56fH60jGFgr8uHwxs+aSKeqmluIVzM0m0kB7xQjKS6qPfd0b2ZoqQ==", "license": "MIT", - "peer": true, "bin": { "mustache": "bin/mustache" } @@ -16201,7 +16170,6 @@ "resolved": "https://registry.npmmirror.com/node-domexception/-/node-domexception-1.0.0.tgz", "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", "deprecated": "Use your platform's native DOMException instead", - "dev": true, "funding": [ { "type": "github", @@ -17092,7 +17060,6 @@ "version": "11.0.19", "resolved": "https://registry.npmmirror.com/parse-torrent/-/parse-torrent-11.0.19.tgz", "integrity": "sha512-T0lEkDdFVQsy0YxHIKjzDHSgt/yl57f3INs5jl7OZqAm77XDF0FgRgrv3LCKgSqsTOrMwYaF0t2761WKdvhgig==", - "dev": true, "funding": [ { "type": "github", @@ -17745,7 +17712,6 @@ "version": "1.2.3", "resolved": "https://registry.npmmirror.com/queue-microtask/-/queue-microtask-1.2.3.tgz", "integrity": "sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==", - "dev": true, "funding": [ { "type": "github", @@ -21682,7 +21648,6 @@ "version": "2.2.5", "resolved": "https://registry.npmmirror.com/uint8-util/-/uint8-util-2.2.5.tgz", "integrity": "sha512-/QxVQD7CttWpVUKVPz9znO+3Dd4BdTSnFQ7pv/4drVhC9m4BaL2LFHTkJn6EsYoxT79VDq/2Gg8L0H22PrzyMw==", - "dev": true, "license": "MIT", "dependencies": { "base64-arraybuffer": "^1.0.2" @@ -22127,7 +22092,6 @@ "version": "3.3.3", "resolved": "https://registry.npmmirror.com/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", - "dev": true, "license": "MIT", "engines": { "node": ">= 8" @@ -22820,7 +22784,7 @@ "version": "8.18.3", "resolved": "https://registry.npmmirror.com/ws/-/ws-8.18.3.tgz", "integrity": "sha512-PEIGCY5tSlUt50cqyMXfCzX+oOPqN0vuGqWzbcJ2xvnkzkq46oOpz7dQaTDBdfICb4N14+GARUDw2XV2N4tvzg==", - "devOptional": true, + "dev": true, "license": "MIT", "engines": { "node": ">=10.0.0" @@ -23062,16 +23026,6 @@ "funding": { "url": "https://github.com/sponsors/colinhacks" } - }, - "node_modules/zod-to-json-schema": { - "version": "3.25.1", - "resolved": "https://registry.npmmirror.com/zod-to-json-schema/-/zod-to-json-schema-3.25.1.tgz", - "integrity": "sha512-pM/SU9d3YAggzi6MtR4h7ruuQlqKtad8e9S0fmxcMi+ueAK5Korys/aWcV9LIIHTVbj01NdzxcnXSN+O74ZIVA==", - "license": "ISC", - "peer": true, - "peerDependencies": { - "zod": "^3.25 || ^4" - } } } } diff --git a/package.json b/package.json index b325b13..9d83c53 100644 --- a/package.json +++ b/package.json @@ -83,13 +83,16 @@ }, "dependencies": { "@langchain/anthropic": "^0.3.30", + "@langchain/core": "^1.1.19", "@langchain/deepseek": "^0.1.0", "@langchain/google-genai": "^0.2.18", "@langchain/ollama": "^0.2.4", "@langchain/openai": "^0.6.14", + "@types/parse-torrent": "^5.8.8", "@types/webtorrent": "^0.110.1", "electron-squirrel-startup": "^1.0.1", "langchain": "^0.3.35", + "parse-torrent": "^11.0.19", "pdf-lib": "^1.17.1", "react": "^19.1.0", "react-dom": "^19.1.0", diff --git a/src/main/dlc/index.ts b/src/main/dlc/index.ts index 869cebe..281d7d5 100644 --- a/src/main/dlc/index.ts +++ b/src/main/dlc/index.ts @@ -5,18 +5,23 @@ import { queryWebtorrentHandle, pauseWebtorrentHandle, removeWebtorrentHandle, + setUploadLimitHandle, + getUploadLimitHandle, DLCIndex, DLCId, OneDLCInfo, } from './type-info'; import { ipcHandle } from '../ipc-util'; import WebTorrent, * as allExports from 'webtorrent'; +import crypto from 'crypto'; import path from 'path'; import { appPath } from '../exec'; import fs, { existsSync, mkdirSync, readFileSync, unlinkSync } from 'fs'; import { compare } from 'semver'; let client: WebTorrent.Instance | null = null; +// 上传速度限制 (bytes/s),0 表示不限速 +let uploadSpeedLimit = 0; const getWebTorrent = async () => { const WebTorrentClass: WebTorrent.WebTorrent = // @ts-ignore @@ -60,9 +65,14 @@ const getWebTorrent = async () => { export default async function init(ipcMain: IpcMain) { client = await getWebTorrent(); - ipcHandle(ipcMain, startWebtorrentHandle, async (_event, magnet: string) => - startWebtorrent(magnet), - ); + ipcHandle(ipcMain, startWebtorrentHandle, async (_event, magnet: string) => { + const torrent = await startWebtorrent(magnet); + // 返回可序列化的对象而不是 torrent 实例 + if (torrent) { + return { infoHash: torrent.infoHash, name: torrent.name }; + } + return null; + }); ipcHandle(ipcMain, queryWebtorrentHandle, async (_event) => queryWebtorrent(), ); @@ -75,10 +85,74 @@ export default async function init(ipcMain: IpcMain) { ipcHandle(ipcMain, logsWebtorrentHandle, async (_event, magnet: string) => logsWebtorrent(magnet), ); + ipcHandle(ipcMain, setUploadLimitHandle, async (_event, limit: number) => + setUploadLimit(limit), + ); + ipcHandle(ipcMain, getUploadLimitHandle, async (_event) => + getUploadLimit(), + ); // 检查种子文件和对应的实际文件,如果有文件,就添加到webtorrent中 await restoreTorrentsFromFiles(); } +/** + * 从torrent文件中提取infoHash + * 简单实现:查找"4:info"标记,提取info字典并计算SHA1 + */ +function extractInfoHashFromTorrent(buffer: Buffer): string | undefined { + // 查找 "4:info" 的位置 + const infoKeyPattern = Buffer.from('4:infod'); + let infoStart = buffer.indexOf(infoKeyPattern); + if (infoStart === -1) { + return undefined; + } + // info字典从 "4:info" 之后的 'd' 开始 + infoStart = infoStart + 6; // 跳过 "4:info" + + // 从infoStart开始,找到对应的字典结束位置 + // bencode 字典以 'd' 开始,以 'e' 结束 + let depth = 0; + let infoEnd = infoStart; + + for (let i = infoStart; i < buffer.length; i++) { + const byte = buffer[i]; + + if (byte === 0x64 || byte === 0x6c) { // 'd' or 'l' (dict or list start) + depth++; + } else if (byte === 0x65) { // 'e' (end) + depth--; + if (depth === 0) { + infoEnd = i + 1; + break; + } + } else if (byte >= 0x30 && byte <= 0x39) { // '0'-'9' (string length) + // 解析字符串长度 + let lengthStr = ''; + let j = i; + while (j < buffer.length && buffer[j] >= 0x30 && buffer[j] <= 0x39) { + lengthStr += String.fromCharCode(buffer[j]); + j++; + } + if (buffer[j] === 0x3a) { // ':' + const strLength = parseInt(lengthStr, 10); + i = j + strLength; // 跳过字符串内容 + } + } else if (byte === 0x69) { // 'i' (integer start) + // 跳过整数直到 'e' + while (i < buffer.length && buffer[i] !== 0x65) { + i++; + } + } + } + + if (infoEnd <= infoStart) { + return undefined; + } + + const infoBuffer = buffer.subarray(infoStart, infoEnd); + return crypto.createHash('sha1').update(infoBuffer).digest('hex'); +} + export async function startWebtorrent(magnet: string) { const torrent = await client.get(magnet); const dLCInfo = getIndexVersionByMegnet(magnet); @@ -117,6 +191,7 @@ export async function startWebtorrent(magnet: string) { } } else { console.warn('没找到链接对应的索引信息', magnet); + return null; } } @@ -173,6 +248,32 @@ export async function logsWebtorrent(magnet: string) { return null; } +/** + * 设置上传速度限制 + * @param limit 限制速度 (bytes/s),0 表示不限速 + */ +export function setUploadLimit(limit: number) { + uploadSpeedLimit = limit; + if (client) { + // WebTorrent 支持 throttleUpload 方法来动态设置上传限速 + // @ts-ignore - throttleUpload 方法存在但类型定义中没有 + if (typeof client.throttleUpload === 'function') { + // @ts-ignore + // -1 表示禁用限速,正数表示限速值 (bytes/s) + client.throttleUpload(limit > 0 ? limit : -1); + } + } + console.debug(`上传速度限制设置为: ${limit > 0 ? (limit / 1024).toFixed(1) + ' KB/s' : '不限速'}`); + return { success: true, limit }; +} + +/** + * 获取当前上传速度限制 + */ +export function getUploadLimit(): number { + return uploadSpeedLimit; +} + const dlcIndexPath = path.join( appPath, 'external-resources', @@ -305,9 +406,8 @@ function getProgress(magnet?: string) { const params = new URL(magnet).searchParams; const xt = params.get('xt'); const torrent = client.torrents.filter((torrent) => { - const hash = xt.split(':')?.pop(); - // console.debug('hash', hash, 'infohash', torrent.infoHash); - return torrent.infoHash === hash; + const hash = xt.split(':')?.pop()?.toLowerCase(); + return torrent.infoHash.toLowerCase() === hash; })[0]; if (torrent) { @@ -484,14 +584,28 @@ async function restoreTorrentsFromFiles() { for (const torrentFile of torrentFiles) { const torrentPath = path.join(versionPath, torrentFile); - const infoHash = torrentFile.replace('.torrent', ''); + + // 读取并解析torrent文件以获取真实的infoHash + let torrentBuffer: Buffer; + let infoHash: string | undefined; + try { + torrentBuffer = fs.readFileSync(torrentPath); + infoHash = extractInfoHashFromTorrent(torrentBuffer); + } catch (error) { + console.error(`解析种子文件失败 ${torrentPath}:`, error); + continue; + } + + if (!infoHash) { + console.warn(`种子文件 ${torrentPath} 没有有效的infoHash,跳过`); + continue; + } // 检查是否已经添加了这个种子 const existingTorrent = client.torrents.find( - (t) => t.infoHash === infoHash, + (t) => t.infoHash.toLowerCase() === infoHash.toLowerCase(), ); if (existingTorrent) { - console.debug(`种子 ${infoHash} 已经存在,跳过`); continue; } @@ -509,31 +623,48 @@ async function restoreTorrentsFromFiles() { (file) => file !== torrentFile && !file.endsWith('.torrent'), ); if (otherFiles.length === 0) { - console.debug( - `版本 ${dlcId}/${version} 没有实际文件,跳过种子 ${infoHash}`, - ); continue; } try { - // 读取.torrent文件 - const torrentBuffer = fs.readFileSync(torrentPath); - - // 添加种子到WebTorrent + // 添加种子到WebTorrent(使用之前已读取的torrentBuffer) client.add(torrentBuffer, { path: versionPath }, (torrent) => { console.debug( `恢复种子成功: ${torrent.name} (${torrent.infoHash})`, ); - console.debug(`存储路径: ${torrent.path}`); - - // 根据用户要求,恢复种子后让下载处于暂停状态 - console.debug(`恢复种子后暂停下载: ${torrent.name}`); - torrent.pause(); - // 如果文件已经完整,切换到做种模式 - if (torrent.progress === 1) { - console.debug(`文件已完整,切换到做种模式: ${torrent.name}`); - torrent.deselect(0, torrent.pieces.length - 1, 0); + // 监听做种相关事件 + torrent.on('wire', (wire) => { + console.debug(`[${torrent.name}] 新连接: ${wire.remoteAddress}`); + }); + + // 定时打印状态(包含上传信息) + const statusInterval = setInterval(() => { + // @ts-ignore - destroyed 属性存在但类型定义中没有 + if (torrent.destroyed) { + clearInterval(statusInterval); + return; + } + console.debug( + `[${torrent.name}] 状态: peers=${torrent.numPeers}, 上传=${torrent.uploaded}, 上传速度=${torrent.uploadSpeed} B/s, ratio=${torrent.ratio.toFixed(2)}` + ); + }, 30000); // 每30秒打印一次 + + // 等待验证完成后开始做种 + const handleReady = () => { + // 验证完成后恢复做种(不暂停) + console.debug(`[${torrent.name}] 验证完成,开始做种`); + torrent.resume(); + }; + + // 如果已经验证完成则直接处理 + if (torrent.done) { + handleReady(); + } else { + // 等待 ready 事件(验证完成) + torrent.once('ready', handleReady); + // 如果部分文件已下载,也监听 done 事件 + torrent.once('done', handleReady); } }); diff --git a/src/main/dlc/type-info.ts b/src/main/dlc/type-info.ts index 37d0a5d..d8afc53 100644 --- a/src/main/dlc/type-info.ts +++ b/src/main/dlc/type-info.ts @@ -13,6 +13,10 @@ export const queryWebtorrentHandle = `${channel}query`; export const logsWebtorrentHandle = `${channel}logs`; +export const setUploadLimitHandle = `${channel}setUploadLimit`; + +export const getUploadLimitHandle = `${channel}getUploadLimit`; + export const dLCIds = [ 'PDF_TAR', 'VOICE_TAR', diff --git a/src/main/index.ts b/src/main/index.ts index 3b8f105..3d63836 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -13,6 +13,7 @@ import initPdfConvert from './pdf-convert'; import initTrainingService from './training-service'; import initLogService from './backup'; import initExternalUrl from './external-url'; +import { setupJointBuildHandlers, setupWindowCloseHandler, isTrayEnabled } from './joint-build'; import initDLC from './dlc'; import path from 'node:path'; import { appPath, autoAdaptEncodingForWindows } from './exec'; @@ -80,6 +81,7 @@ initExampleMain(ipcMain); initPdfConvert(ipcMain); initTrainingService(ipcMain); initExternalUrl(ipcMain); +setupJointBuildHandlers(ipcMain); initDLC(ipcMain); updateTemplate(); @@ -95,6 +97,9 @@ const createWindow = async () => { autoHideMenuBar: true, }); + // 设置窗口关闭行为(托盘最小化) + setupWindowCloseHandler(mainWindow); + // 最大化窗口 mainWindow.maximize(); @@ -121,6 +126,10 @@ app.on('ready', createWindow); // for applications and their menu bar to stay active until the user quits // explicitly with Cmd + Q. app.on('window-all-closed', () => { + // 如果托盘启用,不退出应用 + if (isTrayEnabled()) { + return; + } if (process.platform !== 'darwin') { app.quit(); } diff --git a/src/main/joint-build/index.ts b/src/main/joint-build/index.ts new file mode 100644 index 0000000..cf606e0 --- /dev/null +++ b/src/main/joint-build/index.ts @@ -0,0 +1,232 @@ +import { dialog, IpcMain, Tray, Menu, BrowserWindow, app, nativeImage } from 'electron'; +import { selectFolderHandle, getDiskInfoHandle, DiskInfo, setTrayEnabledHandle } from './type-info'; +import { exec } from 'child_process'; +import { promisify } from 'util'; +import path from 'path'; +import { ipcHandle } from '../ipc-util'; + +const execPromise = promisify(exec); + +// 托盘实例 +let tray: Tray | null = null; +// 是否启用托盘(共建开关状态) +let trayEnabled = false; +// 是否正在强制退出 +let forceQuit = false; + +// 获取Windows磁盘信息 +async function getWindowsDiskInfo(diskPath: string): Promise { + try { + // 获取盘符,例如 "C:" 或 "D:" + const driveLetter = path.parse(diskPath).root.replace('\\', ''); + + // 使用 wmic 命令获取磁盘信息 + const { stdout } = await execPromise( + `wmic logicaldisk where "DeviceID='${driveLetter}'" get Size,FreeSpace /format:csv`, + { encoding: 'utf8' } + ); + + const lines = stdout.trim().split('\n').filter(line => line.trim()); + if (lines.length < 2) { + throw new Error('无法获取磁盘信息'); + } + + // CSV格式: Node,FreeSpace,Size + const values = lines[1].split(','); + const freeSpace = parseInt(values[1], 10) || 0; + const totalSize = parseInt(values[2], 10) || 0; + + return { + total: totalSize, + free: freeSpace, + used: totalSize - freeSpace, + }; + } catch (error) { + console.error('获取磁盘信息失败:', error); + // 返回默认值 + return { + total: 500 * 1024 * 1024 * 1024, // 500GB + free: 200 * 1024 * 1024 * 1024, // 200GB + used: 300 * 1024 * 1024 * 1024, // 300GB + }; + } +} + +export function setupJointBuildHandlers(ipcMain: IpcMain): void { + // 处理选择文件夹 + ipcHandle(ipcMain, selectFolderHandle, async () => { + const result = await dialog.showOpenDialog({ + properties: ['openDirectory'], + defaultPath: 'C:\\', // Windows默认从C盘开始 + title: '选择共建计划存储路径', + }); + + if (result.canceled || !result.filePaths || result.filePaths.length === 0) { + return null; + } + + return result.filePaths[0]; + }); + + // 处理获取磁盘信息 + ipcHandle(ipcMain, getDiskInfoHandle, async (_event, diskPath: string) => { + return getWindowsDiskInfo(diskPath); + }); + + // 处理设置托盘启用状态(只更新状态,不销毁托盘) + ipcHandle(ipcMain, setTrayEnabledHandle, async (_event, enabled: boolean) => { + trayEnabled = enabled; + // 确保托盘始终存在 + createTray(); + // 更新托盘提示信息 + updateTrayTooltip(); + return true; + }); +} + +// 获取图标路径 +function getIconPath(): string { + // 开发环境和生产环境的路径不同 + if (app.isPackaged) { + return path.join(process.resourcesPath, 'icons', 'icon.png'); + } else { + return path.join(__dirname, '..', '..', 'icons', 'icon.png'); + } +} + +// 创建托盘 +function createTray(): void { + if (tray) return; + + try { + const iconPath = getIconPath(); + const icon = nativeImage.createFromPath(iconPath); + tray = new Tray(icon.resize({ width: 16, height: 16 })); + + updateTrayMenu(); + updateTrayTooltip(); + + // 双击托盘图标显示窗口 + tray.on('double-click', () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + mainWindow.show(); + mainWindow.focus(); + } + }); + } catch (error) { + console.error('创建托盘失败:', error); + } +} + +// 更新托盘菜单 +function updateTrayMenu(): void { + if (!tray) return; + + const contextMenu = Menu.buildFromTemplate([ + { + label: '打开AI学习助手', + click: () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + mainWindow.show(); + mainWindow.focus(); + // 发送路由事件到渲染进程,导航到主界面 + mainWindow.webContents.send('navigate-to', '/hello'); + } + }, + }, + { + label: '打开共建设置', + click: () => { + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + const mainWindow = windows[0]; + mainWindow.show(); + mainWindow.focus(); + // 发送路由事件到渲染进程,导航到共建设置界面 + mainWindow.webContents.send('navigate-to', '/joint-build'); + } + }, + }, + { + label: trayEnabled ? '停止共建计划' : '运行共建计划', + click: () => { + trayEnabled = !trayEnabled; + // 更新菜单显示 + updateTrayMenu(); + updateTrayTooltip(); + // 通知渲染进程更新状态 + const windows = BrowserWindow.getAllWindows(); + if (windows.length > 0) { + windows[0].webContents.send('joint-build-status-changed', trayEnabled); + } + }, + }, + { type: 'separator' }, + { + label: '退出AI学习助手', + click: () => { + forceQuit = true; + app.quit(); + }, + }, + ]); + + tray.setContextMenu(contextMenu); +} + +// 更新托盘提示信息 +function updateTrayTooltip(): void { + if (!tray) return; + + const version = app.getVersion(); + const isJointBuilding = trayEnabled ? '正在共建中(ON)' : '共建已关闭(OFF)'; + if (!trayEnabled) { + tray.setToolTip(`AI学习助手 ${version}\n共建已关闭(OFF)`); + return; + } + // todo:获取动态数据 + const currentUploadSpeed = '0KB/s'; + const linkedTo = 0; + tray.setToolTip(`AI学习助手 ${version}\n${isJointBuilding}\n当前上传速度: ${currentUploadSpeed}\n已连接伙伴${linkedTo}人`); +} + +// 销毁托盘 +function destroyTray(): void { + if (tray) { + tray.destroy(); + tray = null; + } +} + +// 设置窗口关闭行为 +export function setupWindowCloseHandler(mainWindow: BrowserWindow): void { + // 确保托盘已创建 + createTray(); + + mainWindow.on('close', (event) => { + // 始终最小化到托盘,除非是强制退出 + if (!forceQuit) { + event.preventDefault(); + mainWindow.hide(); + } + }); +} + +// 检查是否强制退出 +export function isForceQuit(): boolean { + return forceQuit; +} + +// 设置强制退出标志 +export function setForceQuit(value: boolean): void { + forceQuit = value; +} + +// 获取托盘启用状态(始终启用托盘最小化) +export function isTrayEnabled(): boolean { + return true; +} diff --git a/src/main/joint-build/type-info.ts b/src/main/joint-build/type-info.ts new file mode 100644 index 0000000..ac7f09c --- /dev/null +++ b/src/main/joint-build/type-info.ts @@ -0,0 +1,12 @@ +export type ActionName = 'select-folder' | 'get-disk-info' | 'set-tray-enabled'; +export type ServiceName = 'joint-build'; + +export const selectFolderHandle = 'joint-build:select-folder'; +export const getDiskInfoHandle = 'joint-build:get-disk-info'; +export const setTrayEnabledHandle = 'joint-build:set-tray-enabled'; + +export interface DiskInfo { + total: number; // bytes + free: number; // bytes + used: number; // bytes +} diff --git a/src/main/preload.ts b/src/main/preload.ts index 7dc73b2..23c3547 100644 --- a/src/main/preload.ts +++ b/src/main/preload.ts @@ -21,6 +21,12 @@ import { startTrainingServiceHandle, courseHaveNewVersionTrainingServiceHandle, } from './training-service/type-info'; +import { + selectFolderHandle, + getDiskInfoHandle, + setTrayEnabledHandle, + DiskInfo, +} from './joint-build/type-info'; import { DLCIndex, logsWebtorrentHandle, @@ -28,6 +34,8 @@ import { queryWebtorrentHandle, removeWebtorrentHandle, startWebtorrentHandle, + setUploadLimitHandle, + getUploadLimitHandle, DLCId, } from './dlc/type-info'; @@ -129,6 +137,27 @@ const mainHandle = { logsTrainingServiceHandle, ); }, + // 共建计划相关 + selectJointBuildFolder: async (): Promise => { + return ipcInvoke(selectFolderHandle); + }, + getJointBuildDiskInfo: async (diskPath: string): Promise => { + return ipcInvoke(getDiskInfoHandle, diskPath); + }, + setTrayEnabled: async (enabled: boolean): Promise => { + return ipcInvoke(setTrayEnabledHandle, enabled); + }, + // 托盘菜单事件监听 + onNavigateTo: (callback: (route: string) => void) => { + const handler = (_event: IpcRendererEvent, route: string) => callback(route); + ipcRenderer.on('navigate-to', handler); + return () => ipcRenderer.removeListener('navigate-to', handler); + }, + onJointBuildStatusChanged: (callback: (enabled: boolean) => void) => { + const handler = (_event: IpcRendererEvent, enabled: boolean) => callback(enabled); + ipcRenderer.on('joint-build-status-changed', handler); + return () => ipcRenderer.removeListener('joint-build-status-changed', handler); + }, startWebtorrentHandle: async (url: string) => { return ipcInvoke(startWebtorrentHandle, url); }, @@ -144,6 +173,12 @@ const mainHandle = { logsWebtorrentHandle: async (url: string) => { return ipcInvoke(logsWebtorrentHandle, url); }, + setUploadLimit: async (limit: number) => { + return ipcInvoke<{ success: boolean; limit: number }>(setUploadLimitHandle, limit); + }, + getUploadLimit: async () => { + return ipcInvoke(getUploadLimitHandle); + }, }; export type MainHandle = typeof mainHandle; diff --git a/src/renderer/app.tsx b/src/renderer/app.tsx index 98a572e..78bb014 100644 --- a/src/renderer/app.tsx +++ b/src/renderer/app.tsx @@ -1,4 +1,4 @@ -import { MemoryRouter as Router, Routes, Route } from 'react-router-dom'; +import { MemoryRouter as Router, Routes, Route, useNavigate } from 'react-router-dom'; import '@ant-design/v5-patch-for-react-19'; import Hello from './pages/hello'; import './app.css'; @@ -15,13 +15,46 @@ import PdfConvert from './pages/pdf-convert'; import PdfConfig from './pages/pdf-config'; import LLMConfig from './pages/llm-api-config'; import VoiceRTCConfig from './pages/voice-rtc-config'; +import WelcomeModal from './pages/welcome/WelcomeModal'; +import JointBuild from './pages/joint-build'; +import { useEffect } from 'react'; + +// 托盘菜单导航监听组件 +function TrayNavigationHandler() { + const navigate = useNavigate(); + + useEffect(() => { + // 监听托盘菜单的导航事件 + const unsubscribeNavigate = window.mainHandle.onNavigateTo((route: string) => { + navigate(route); + }); + + // 监听共建计划状态变化事件 + const unsubscribeStatus = window.mainHandle.onJointBuildStatusChanged((enabled: boolean) => { + // 更新 localStorage + localStorage.setItem('joint_build_master_switch', String(enabled)); + // 触发页面刷新或状态更新 + window.dispatchEvent(new CustomEvent('joint-build-status-changed', { detail: enabled })); + }); + + return () => { + unsubscribeNavigate(); + unsubscribeStatus(); + }; + }, [navigate]); + + return null; +} import P2PTest from './pages/p2p-test'; export default function App() { return ( + + + } /> } /> } /> } /> diff --git a/src/renderer/pages/hello/index.scss b/src/renderer/pages/hello/index.scss index e3bc9f6..484dea7 100644 --- a/src/renderer/pages/hello/index.scss +++ b/src/renderer/pages/hello/index.scss @@ -667,6 +667,44 @@ body{ transform: translateY(-2px); } } + + .joint-build-link { + text-decoration: none; + } + + .joint-build-button { + box-sizing: border-box; + display: flex; + flex-direction: row; + justify-content: center; + align-items: center; + padding: 2px 8px; + gap: 4px; + width: auto; + min-width: 90px; + min-height: 22px; + background: #52c41a; + border: 1px solid #52c41a; + border-radius: 6px; + color: #fff; + font-family: 'Inter', sans-serif; + font-style: normal; + font-weight: 500; + font-size: clamp(10px, 2vw, 12px); + line-height: 16px; + cursor: pointer; + + .joint-build-icon { + width: 16px; + height: 16px; + } + + &:hover { + background: #73d13d; + border-color: #73d13d; + transform: translateY(-2px); + } + } .dev-example-link-inline { text-decoration: none; diff --git a/src/renderer/pages/hello/index.tsx b/src/renderer/pages/hello/index.tsx index 1497ae8..6730778 100644 --- a/src/renderer/pages/hello/index.tsx +++ b/src/renderer/pages/hello/index.tsx @@ -12,6 +12,7 @@ import wslLogo from './wslLogo.png'; // 新增导入Frame 3和Frame 8图片 import frame3 from './Frame 3.png'; import frame8 from './Frame 8.png'; +import jointBuildIcon from '../../../../icons/joint_build.png'; import './index.scss'; import { LeftOutlined, RightOutlined } from '@ant-design/icons'; import { useTrainingServiceShortcut } from '../../containers/use-training-service-shortcut'; @@ -49,6 +50,29 @@ export default function Hello() { }; }, [setupBackupListener]); + // 初始化托盘状态(根据共建开关) + useEffect(() => { + const initTrayStatus = async () => { + const masterSwitch = localStorage.getItem('joint_build_master_switch'); + const welcomeChoice = localStorage.getItem('ai_learning_assistant_welcome_shown'); + + let trayEnabled = false; + if (masterSwitch !== null) { + trayEnabled = masterSwitch === 'true'; + } else if (welcomeChoice === 'true') { + trayEnabled = true; + } + + try { + await window.mainHandle.setTrayEnabled(trayEnabled); + } catch (error) { + console.error('初始化托盘状态失败:', error); + } + }; + + initTrayStatus(); + }, []); + const [currentSlide, setCurrentSlide] = useState(0); const slides = [ @@ -623,6 +647,12 @@ export default function Hello() { 版本号:{__NPM_PACKAGE_VERSION__} 源码版本:{__COMMIT_HASH__}
+ + + diff --git a/src/renderer/pages/joint-build/index.scss b/src/renderer/pages/joint-build/index.scss new file mode 100644 index 0000000..aea46c9 --- /dev/null +++ b/src/renderer/pages/joint-build/index.scss @@ -0,0 +1,220 @@ +.joint-build-page { + display: flex; + flex-direction: column; + width: 100%; + height: 100%; + padding: 24px; + box-sizing: border-box; + overflow-y: auto; + background: #fafafa; + + .header-container { + display: flex; + align-items: flex-start; + gap: 12px; + margin-bottom: 32px; + + .back-link { + display: flex; + align-items: center; + justify-content: center; + width: 32px; + height: 32px; + border-radius: 50%; + background: #fff; + border: 1px solid #e5e5e5; + color: #333; + text-decoration: none; + transition: all 0.3s; + + &:hover { + background: #f5f5f5; + } + } + + .header-icon { + width: 28px; + height: 28px; + margin-top: 2px; + } + + .header-text { + flex: 1; + + h1 { + font-size: 20px; + font-weight: 600; + color: #000; + margin: 0 0 8px 0; + } + + p { + font-size: 14px; + color: #666; + margin: 0; + line-height: 1.5; + } + } + } + + .section { + margin-bottom: 24px; + + h2 { + font-size: 16px; + font-weight: 600; + color: #000; + margin: 0 0 12px 0; + } + + .section-desc { + font-size: 13px; + color: #999; + margin: 0 0 12px 0; + } + } + + .switch-card { + display: flex; + justify-content: space-between; + align-items: center; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 8px; + padding: 16px 20px; + + .switch-label { + font-size: 14px; + color: #333; + white-space: nowrap; + } + + &.upload-limit-card { + margin-top: 12px; + + .slider-container { + display: flex; + align-items: center; + gap: 16px; + width: 30%; + margin-left: 24px; + + .ant-slider { + flex: 1; + margin: 0; + } + + .limit-value { + font-size: 13px; + color: #666; + min-width: 80px; + text-align: right; + } + } + } + } + + .disk-info { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 8px; + + .disk-path { + font-size: 14px; + color: #666; + } + } + + .disk-stats { + font-size: 12px; + color: #999; + margin-bottom: 8px; + } + + .modules-list { + display: flex; + flex-direction: column; + gap: 12px; + + .module-card { + display: flex; + justify-content: space-between; + align-items: center; + background: #fff; + border: 1px solid #e5e5e5; + border-radius: 8px; + padding: 16px 20px; + + .module-info { + display: flex; + flex-direction: column; + gap: 6px; + flex: 1; + + .module-header { + display: flex; + align-items: center; + gap: 12px; + + .module-name { + font-size: 14px; + font-weight: 500; + color: #333; + } + + .module-version { + font-size: 12px; + color: #999; + background: #f5f5f5; + padding: 2px 8px; + border-radius: 4px; + } + } + + .module-status { + display: flex; + align-items: center; + gap: 12px; + + .status-badge { + font-size: 13px; + font-weight: 500; + } + + .download-progress { + font-size: 12px; + color: #1890ff; + font-weight: 500; + } + } + + .module-stats { + display: flex; + gap: 16px; + font-size: 12px; + color: #999; + + span { + display: flex; + align-items: center; + gap: 4px; + } + } + } + + .module-actions { + display: flex; + gap: 8px; + flex-shrink: 0; + } + } + + .empty-tip { + text-align: center; + padding: 40px 20px; + color: #999; + font-size: 14px; + } + } +} diff --git a/src/renderer/pages/joint-build/index.tsx b/src/renderer/pages/joint-build/index.tsx new file mode 100644 index 0000000..071c2f3 --- /dev/null +++ b/src/renderer/pages/joint-build/index.tsx @@ -0,0 +1,402 @@ +import { Switch, Button, Progress, message, Slider } from 'antd'; +import { NavLink } from 'react-router-dom'; +import { useState, useEffect, useCallback, useRef } from 'react'; +import { LeftOutlined } from '@ant-design/icons'; +import jointBuildIcon from '../../../../icons/joint_build.png'; +import { DLCIndex, OneDLCInfo } from '../../../main/dlc/type-info'; +import './index.scss'; + +const STORAGE_KEY_MASTER = 'joint_build_master_switch'; +const STORAGE_KEY_DISK_PATH = 'joint_build_disk_path'; +const STORAGE_KEY_WELCOME = 'ai_learning_assistant_welcome_shown'; // 欢迎弹窗用户选择 +const STORAGE_KEY_UPLOAD_LIMIT = 'joint_build_upload_limit'; + +// 字节转GB +const bytesToGB = (bytes: number): number => { + return Math.round((bytes / (1024 * 1024 * 1024)) * 10) / 10; +}; + +// 格式化字节大小 +const formatBytes = (bytes: number): string => { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB', 'TB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +}; + +// 获取做种状态(只要种子添加到 WebTorrent 中就可以显示) +function getSeedingState(version: OneDLCInfo['versions'][string]): '未下载' | '已暂停做种' | '做种中' | '验证中' { + // 只要 progress 存在,就说明种子已添加到 WebTorrent + if (version.progress) { + // 完全完成或进度 >= 99% + if (version.progress.done || version.progress.progress >= 0.99) { + if (version.progress.paused) { + return '已暂停做种'; + } else { + return '做种中'; + } + } + // 有进度但未完成(验证中或部分下载) + if (version.progress.progress > 0) { + if (version.progress.paused) { + return '已暂停做种'; + } else { + return '验证中'; + } + } + } + return '未下载'; +} + +// 获取状态颜色 +function getStateColor(state: string): string { + switch (state) { + case '做种中': + return '#52c41a'; + case '已暂停做种': + return '#faad14'; + case '验证中': + return '#1890ff'; + default: + return '#999'; + } +} + +export default function JointBuild() { + const [masterSwitch, setMasterSwitch] = useState(false); + const [diskPath, setDiskPath] = useState('C:\\'); + const [diskUsed, setDiskUsed] = useState(0); + const [diskTotal, setDiskTotal] = useState(0); + const [diskLoading, setDiskLoading] = useState(false); + const [dLCIndex, setDLCIndex] = useState([]); + const [refreshTrigger, setRefreshTrigger] = useState(1); + const [uploadLimit, setUploadLimit] = useState(0); // 0 表示不限速,单位 KB/s + const uploadLimitTimerRef = useRef | null>(null); + + // 定时刷新DLC数据 + useEffect(() => { + window.mainHandle + .queryWebtorrentHandle() + .then((newDLCIndex) => setDLCIndex(newDLCIndex)) + .catch((err) => { + // 降低日志频率,只在首次或间隔打印 + if (refreshTrigger === 1 || refreshTrigger % 10 === 0) { + console.error('查询WebTorrent状态失败:', err); + } + }); + const timeout = setTimeout( + () => setRefreshTrigger(refreshTrigger + 1), + 3000, // 从1秒改为3秒,降低刷新和日志频率 + ); + return () => { + clearTimeout(timeout); + }; + }, [refreshTrigger]); + + // 获取磁盘信息 + const fetchDiskInfo = useCallback(async (path: string) => { + setDiskLoading(true); + try { + const diskInfo = await window.mainHandle.getJointBuildDiskInfo(path); + setDiskTotal(bytesToGB(diskInfo.total)); + setDiskUsed(bytesToGB(diskInfo.used)); + } catch (error) { + console.error('获取磁盘信息失败:', error); + } finally { + setDiskLoading(false); + } + }, []); + + // 从 localStorage 加载配置 + useEffect(() => { + // 优先读取共建开关的设置,如果没有则读取欢迎弹窗的用户选择 + const savedMasterSwitch = localStorage.getItem(STORAGE_KEY_MASTER); + let initialMasterSwitch = false; + + if (savedMasterSwitch !== null) { + initialMasterSwitch = savedMasterSwitch === 'true'; + } else { + // 如果没有单独设置过共建开关,则读取欢迎弹窗的选择 + const welcomeChoice = localStorage.getItem(STORAGE_KEY_WELCOME); + if (welcomeChoice === 'true') { + initialMasterSwitch = true; + } + } + + setMasterSwitch(initialMasterSwitch); + // 初始化时同步托盘状态到主进程 + window.mainHandle.setTrayEnabled(initialMasterSwitch).catch(console.error); + + const savedDiskPath = localStorage.getItem(STORAGE_KEY_DISK_PATH); + const initialPath = savedDiskPath || 'C:\\'; + setDiskPath(initialPath); + + // 加载上传限速设置 + const savedUploadLimit = localStorage.getItem(STORAGE_KEY_UPLOAD_LIMIT); + const initialLimit = savedUploadLimit ? parseInt(savedUploadLimit, 10) : 0; + setUploadLimit(initialLimit); + // 同步到后端 + window.mainHandle.setUploadLimit(initialLimit * 1024).catch(console.error); // 转换为 bytes/s + + // 初始化时获取磁盘信息 + fetchDiskInfo(initialPath); + }, [fetchDiskInfo]); + + // 监听托盘菜单的状态变化 + useEffect(() => { + const handleStatusChange = (event: CustomEvent) => { + setMasterSwitch(event.detail); + }; + + window.addEventListener('joint-build-status-changed', handleStatusChange as EventListener); + + return () => { + window.removeEventListener('joint-build-status-changed', handleStatusChange as EventListener); + // 清理上传限速防抖定时器 + if (uploadLimitTimerRef.current) { + clearTimeout(uploadLimitTimerRef.current); + } + }; + }, []); + + // 保存配置到 localStorage + const saveConfig = (master: boolean) => { + localStorage.setItem(STORAGE_KEY_MASTER, String(master)); + }; + + const handleMasterSwitchChange = async (checked: boolean) => { + setMasterSwitch(checked); + saveConfig(checked); + + // 同步托盘状态到主进程 + try { + await window.mainHandle.setTrayEnabled(checked); + } catch (error) { + console.error('设置托盘状态失败:', error); + } + + if (checked) { + message.success('共建计划已开启,关闭窗口后将最小化到托盘'); + } else { + message.info('共建计划已关闭'); + } + }; + + // 处理上传限速变化(防抖:UI 实时更新,后端调用延迟) + const handleUploadLimitChange = (value: number) => { + setUploadLimit(value); + + // 清除之前的定时器 + if (uploadLimitTimerRef.current) { + clearTimeout(uploadLimitTimerRef.current); + } + + // 防抖:500ms 后才同步到后端 + uploadLimitTimerRef.current = setTimeout(async () => { + localStorage.setItem(STORAGE_KEY_UPLOAD_LIMIT, String(value)); + try { + await window.mainHandle.setUploadLimit(value * 1024); // 转换为 bytes/s + } catch (error) { + console.error('设置上传限速失败:', error); + } + }, 500); + }; + + // 处理开始做种 + const handleStartSeeding = async (magnet: string) => { + try { + await window.mainHandle.startWebtorrentHandle(magnet); + message.success('开始做种'); + } catch (error) { + console.error('启动做种失败:', error); + message.error('启动做种失败'); + } + }; + + // 处理暂停做种 + const handlePauseSeeding = async (magnet: string) => { + try { + await window.mainHandle.pauseWebtorrentHandle(magnet); + message.info('已暂停做种'); + } catch (error) { + console.error('暂停失败:', error); + message.error('暂停失败'); + } + }; + + const handleChangePath = async () => { + try { + const newPath = await window.mainHandle.selectJointBuildFolder(); + if (newPath) { + setDiskPath(newPath); + localStorage.setItem(STORAGE_KEY_DISK_PATH, newPath); + message.success('路径已更新'); + // 获取新路径的磁盘信息 + fetchDiskInfo(newPath); + } + } catch (error) { + console.error('选择文件夹失败:', error); + message.error('选择文件夹失败'); + } + }; + + // 获取最新版本 + const getLatestVersion = (dlc: OneDLCInfo): { key: string; version: OneDLCInfo['versions'][string] } | null => { + const versionKeys = Object.keys(dlc.versions); + if (versionKeys.length === 0) return null; + // 按版本号排序,取最新的 + const sortedKeys = versionKeys.sort((a, b) => { + const partsA = a.split('.').map(Number); + const partsB = b.split('.').map(Number); + for (let i = 0; i < Math.max(partsA.length, partsB.length); i++) { + const numA = partsA[i] || 0; + const numB = partsB[i] || 0; + if (numA !== numB) return numB - numA; + } + return 0; + }); + return { key: sortedKeys[0], version: dlc.versions[sortedKeys[0]] }; + }; + + const diskUsedPercent = diskTotal > 0 ? Math.round((diskUsed / diskTotal) * 100) : 0; + + return ( +
+
+ + + + 共建计划 +
+

共建计划

+

通过闲时分享少量带宽,不仅能加速您的下载,也能帮助其他学习者更快获取大模型与课程资源。

+
+
+ +
+

服务状态

+
+ 共建总开关 + +
+
+ 上传限速 +
+ value === 0 ? '不限速' : `${value} KB/s` + }} + style={{ width: '50%' }} + /> + + {uploadLimit === 0 ? '不限速' : `${uploadLimit} KB/s`} + +
+
+
+ +
+

可用磁盘空间

+
+ 磁盘路径:{diskPath} + +
+
+ 已用 {diskUsed} GB / 总计 {diskTotal} GB +
+ +
+ +
+

共建参与模块

+

以下是您已下载的资源包,开启做种可以帮助其他学习者更快获取资源

+
+ {dLCIndex.map((dlc) => { + const latestVersionInfo = getLatestVersion(dlc); + if (!latestVersionInfo) return null; + + const { key: versionKey, version } = latestVersionInfo; + const state = getSeedingState(version); + const progress = version.progress; + + // 只显示已下载完成的资源 + if (state === '未下载') return null; + + return ( +
+
+
+ {dlc.name} + v{versionKey} +
+
+ + {state} + +
+ {progress && ( +
+ {progress.uploadSpeed > 0 && ( + ↑ {formatBytes(progress.uploadSpeed)}/s + )} + {progress.numPeers > 0 && ( + 节点: {progress.numPeers} + )} + {state === '验证中' && ( + 进度: {Math.round(progress.progress * 100)}% + )} +
+ )} +
+
+ {(state === '已暂停做种' || state === '验证中') && ( + + )} + {state === '做种中' && ( + + )} +
+
+ ); + })} + {dLCIndex.filter(dlc => { + const latestVersionInfo = getLatestVersion(dlc); + if (!latestVersionInfo) return false; + return getSeedingState(latestVersionInfo.version) !== '未下载'; + }).length === 0 && ( +
暂无已下载的资源包可供做种
+ )} +
+
+
+ ); +} diff --git a/src/renderer/pages/welcome/WelcomeModal.css b/src/renderer/pages/welcome/WelcomeModal.css new file mode 100644 index 0000000..d4f1451 --- /dev/null +++ b/src/renderer/pages/welcome/WelcomeModal.css @@ -0,0 +1,75 @@ +.welcome-modal-content { + padding: 10px 0; + text-align: left; +} + +.welcome-modal-icon { + font-size: 32px; + margin-bottom: 16px; +} + +.welcome-modal-title { + font-size: 16px; + font-weight: 600; + color: #000; + margin: 0 0 12px 0; + line-height: 1.5; + display: flex; + align-items: center; + justify-content: flex-start; + gap: 8px; +} + +.welcome-title-icon { + width: 24px; + height: 24px; + vertical-align: middle; +} + +.welcome-modal-description { + font-size: 14px; + color: #666; + margin: 0 0 8px 0; + line-height: 1.6; +} + +.welcome-modal-note { + font-size: 13px; + color: #999; + margin: 0 0 24px 0; +} + +.welcome-modal-buttons { + display: flex; + gap: 12px; + justify-content: center; +} + +.welcome-modal-buttons button { + flex: 1; + max-width: 180px; + padding: 10px 20px; + border: none; + border-radius: 4px; + font-size: 14px; + cursor: pointer; + transition: all 0.3s; +} + +.btn-reject { + background: #f5f5f5; + color: #666; +} + +.btn-reject:hover { + background: #e8e8e8; +} + +.btn-accept { + background: #000; + color: #fff; +} + +.btn-accept:hover { + background: #333; +} diff --git a/src/renderer/pages/welcome/WelcomeModal.tsx b/src/renderer/pages/welcome/WelcomeModal.tsx new file mode 100644 index 0000000..f0a9f65 --- /dev/null +++ b/src/renderer/pages/welcome/WelcomeModal.tsx @@ -0,0 +1,59 @@ +import { Modal } from 'antd'; +import { useState, useEffect } from 'react'; +import './WelcomeModal.css'; +import jointBuildIcon from '../../../../icons/joint_build.png'; + +const STORAGE_KEY = 'ai_learning_assistant_welcome_shown'; + +export default function WelcomeModal() { + const [visible, setVisible] = useState(false); + + useEffect(() => { + // 检查是否已经显示过欢迎弹窗 + const hasShown = localStorage.getItem(STORAGE_KEY); + if (!hasShown) { + setVisible(true); + } + }, []); + + const handleAccept = () => { + localStorage.setItem(STORAGE_KEY, 'true'); + setVisible(false); + }; + + const handleReject = () => { + localStorage.setItem(STORAGE_KEY, 'false'); + setVisible(false); + }; + + return ( + +
+

+ 共建 加入"AI学习助手-共建计划",为知识共享添把柴 +

+

+ 通过闲时分享少量带宽,不仅能加速您的下载,也能帮助其他学习者更快获取大模型与课程资源。 +

+
+ (推荐开启,后续随时可关闭) +
+
+ + +
+
+
+ ); +}