commit projects

This commit is contained in:
lrhh123
2024-03-09 21:30:23 +08:00
parent 35f5a29399
commit 5d41faf47d
100 changed files with 20462 additions and 2 deletions
+12
View File
@@ -0,0 +1,12 @@
root = true
[*]
indent_style = space
indent_size = 2
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true
[*.md]
trim_trailing_whitespace = false
+7
View File
@@ -0,0 +1,7 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off"
}
}
+63
View File
@@ -0,0 +1,63 @@
/**
* Base webpack config used across other specific configs
*/
import path from 'path';
import webpack from 'webpack';
import dotenv from 'dotenv'; // 导入dotenv-webpack插件
import TsconfigPathsPlugins from 'tsconfig-paths-webpack-plugin';
import webpackPaths from './webpack.paths';
import { dependencies as externals } from '../../release/app/package.json';
const configuration: webpack.Configuration = {
externals: [...Object.keys(externals || {})],
stats: 'errors-only',
module: {
rules: [
{
test: /\.[jt]sx?$/,
exclude: /node_modules/,
use: {
loader: 'ts-loader',
options: {
// Remove this line to enable type checking in webpack builds
transpileOnly: true,
compilerOptions: {
module: 'esnext',
},
},
},
},
],
},
output: {
path: webpackPaths.srcPath,
// https://github.com/webpack/webpack/issues/1114
library: {
type: 'commonjs2',
},
},
/**
* Determine the array of extensions that should be used to resolve modules.
*/
resolve: {
extensions: ['.js', '.jsx', '.json', '.ts', '.tsx'],
modules: [webpackPaths.srcPath, 'node_modules'],
// There is no need to add aliases here, the paths in tsconfig get mirrored
plugins: [new TsconfigPathsPlugins()],
},
plugins: [
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
...dotenv.config({
path: path.join(webpackPaths.rootPath, '.env'),
}).parsed,
}),
],
};
export default configuration;
+3
View File
@@ -0,0 +1,3 @@
/* eslint import/no-unresolved: off, import/no-self-import: off */
module.exports = require('./webpack.config.renderer.dev').default;
+83
View File
@@ -0,0 +1,83 @@
/**
* Webpack config for production electron main process
*/
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import TerserPlugin from 'terser-webpack-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
checkNodeEnv('production');
deleteSourceMaps();
const configuration: webpack.Configuration = {
devtool: 'source-map',
mode: 'production',
target: 'electron-main',
entry: {
main: path.join(webpackPaths.srcMainPath, 'main.ts'),
preload: path.join(webpackPaths.srcMainPath, 'preload.ts'),
},
output: {
path: webpackPaths.distMainPath,
filename: '[name].js',
library: {
type: 'umd',
},
},
optimization: {
minimizer: [
new TerserPlugin({
parallel: true,
}),
],
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8888,
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
START_MINIMIZED: false,
}),
new webpack.DefinePlugin({
'process.type': '"browser"',
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
};
export default merge(baseConfig, configuration);
@@ -0,0 +1,71 @@
import path from 'path';
import webpack from 'webpack';
import { merge } from 'webpack-merge';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: 'electron-preload',
entry: path.join(webpackPaths.srcMainPath, 'preload.ts'),
output: {
path: webpackPaths.dllPath,
filename: 'preload.js',
library: {
type: 'umd',
},
},
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
],
/**
* Disables webpack processing of __dirname and __filename.
* If you run the bundle in node.js it falls back to these values of node.js.
* https://github.com/webpack/webpack/issues/2010
*/
node: {
__dirname: false,
__filename: false,
},
watch: true,
};
export default merge(baseConfig, configuration);
@@ -0,0 +1,77 @@
/**
* Builds the DLL for development electron renderer process
*/
import webpack from 'webpack';
import path from 'path';
import { merge } from 'webpack-merge';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import { dependencies } from '../../package.json';
import checkNodeEnv from '../scripts/check-node-env';
checkNodeEnv('development');
const dist = webpackPaths.dllPath;
const configuration: webpack.Configuration = {
context: webpackPaths.rootPath,
devtool: 'eval',
mode: 'development',
target: 'electron-renderer',
externals: ['fsevents', 'crypto-browserify'],
/**
* Use `module` from `webpack.config.renderer.dev.js`
*/
module: require('./webpack.config.renderer.dev').default.module,
entry: {
renderer: Object.keys(dependencies || {}),
},
output: {
path: dist,
filename: '[name].dev.dll.js',
library: {
name: 'renderer',
type: 'var',
},
},
plugins: [
new webpack.DllPlugin({
path: path.join(dist, '[name].json'),
name: '[name]',
}),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
options: {
context: webpackPaths.srcPath,
output: {
path: webpackPaths.dllPath,
},
},
}),
],
};
export default merge(baseConfig, configuration);
+213
View File
@@ -0,0 +1,213 @@
import 'webpack-dev-server';
import path from 'path';
import fs from 'fs';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import chalk from 'chalk';
import { merge } from 'webpack-merge';
import { execSync, spawn } from 'child_process';
import ReactRefreshWebpackPlugin from '@pmmmwh/react-refresh-webpack-plugin';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
// When an ESLint server is running, we can't set the NODE_ENV so we'll check if it's
// at the dev webpack config is not accidentally run in a production environment
if (process.env.NODE_ENV === 'production') {
checkNodeEnv('development');
}
const port = process.env.PORT || 1212;
const manifest = path.resolve(webpackPaths.dllPath, 'renderer.json');
const skipDLLs =
module.parent?.filename.includes('webpack.config.renderer.dev.dll') ||
module.parent?.filename.includes('webpack.config.eslint');
/**
* Warn if the DLL is not built
*/
if (
!skipDLLs &&
!(fs.existsSync(webpackPaths.dllPath) && fs.existsSync(manifest))
) {
console.log(
chalk.black.bgYellow.bold(
'The DLL files are missing. Sit back while we build them for you with "npm run build-dll"',
),
);
execSync('npm run postinstall');
}
const configuration: webpack.Configuration = {
devtool: 'inline-source-map',
mode: 'development',
target: ['web', 'electron-renderer'],
entry: [
`webpack-dev-server/client?http://localhost:${port}/dist`,
'webpack/hot/only-dev-server',
path.join(webpackPaths.srcRendererPath, 'index.tsx'),
],
output: {
path: webpackPaths.distRendererPath,
publicPath: '/',
filename: 'renderer.dev.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(c|a)ss$/,
use: [
'style-loader',
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?css$/,
use: ['style-loader', 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
'file-loader',
],
},
],
},
plugins: [
...(skipDLLs
? []
: [
new webpack.DllReferencePlugin({
context: webpackPaths.dllPath,
manifest: require(manifest),
sourceType: 'var',
}),
]),
new webpack.NoEmitOnErrorsPlugin(),
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*
* By default, use 'development' as NODE_ENV. This can be overriden with
* 'staging', for example, by changing the ENV variables in the npm scripts
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'development',
}),
new webpack.LoaderOptionsPlugin({
debug: true,
}),
new ReactRefreshWebpackPlugin(),
new HtmlWebpackPlugin({
filename: path.join('index.html'),
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
env: process.env.NODE_ENV,
isDevelopment: process.env.NODE_ENV !== 'production',
nodeModules: webpackPaths.appNodeModulesPath,
}),
],
node: {
__dirname: false,
__filename: false,
},
devServer: {
port,
compress: true,
hot: true,
headers: { 'Access-Control-Allow-Origin': '*' },
static: {
publicPath: '/',
},
historyApiFallback: {
verbose: true,
},
setupMiddlewares(middlewares) {
console.log('Starting preload.js builder...');
const preloadProcess = spawn('npm', ['run', 'start:preload'], {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => process.exit(code!))
.on('error', (spawnError) => console.error(spawnError));
console.log('Starting Main Process...');
let args = ['run', 'start:main'];
if (process.env.MAIN_ARGS) {
args = args.concat(
['--', ...process.env.MAIN_ARGS.matchAll(/"[^"]+"|[^\s"]+/g)].flat(),
);
}
spawn('npm', args, {
shell: true,
stdio: 'inherit',
})
.on('close', (code: number) => {
preloadProcess.kill();
process.exit(code!);
})
.on('error', (spawnError) => console.error(spawnError));
return middlewares;
},
},
};
export default merge(baseConfig, configuration);
@@ -0,0 +1,141 @@
/**
* Build config for electron renderer process
*/
import path from 'path';
import webpack from 'webpack';
import HtmlWebpackPlugin from 'html-webpack-plugin';
import MiniCssExtractPlugin from 'mini-css-extract-plugin';
import { BundleAnalyzerPlugin } from 'webpack-bundle-analyzer';
import CssMinimizerPlugin from 'css-minimizer-webpack-plugin';
import { merge } from 'webpack-merge';
import TerserPlugin from 'terser-webpack-plugin';
import baseConfig from './webpack.config.base';
import webpackPaths from './webpack.paths';
import checkNodeEnv from '../scripts/check-node-env';
import deleteSourceMaps from '../scripts/delete-source-maps';
checkNodeEnv('production');
deleteSourceMaps();
const configuration: webpack.Configuration = {
devtool: 'source-map',
mode: 'production',
target: ['web', 'electron-renderer'],
entry: [path.join(webpackPaths.srcRendererPath, 'index.tsx')],
output: {
path: webpackPaths.distRendererPath,
publicPath: './',
filename: 'renderer.js',
library: {
type: 'umd',
},
},
module: {
rules: [
{
test: /\.s?(a|c)ss$/,
use: [
MiniCssExtractPlugin.loader,
{
loader: 'css-loader',
options: {
modules: true,
sourceMap: true,
importLoaders: 1,
},
},
'sass-loader',
],
include: /\.module\.s?(c|a)ss$/,
},
{
test: /\.s?(a|c)ss$/,
use: [MiniCssExtractPlugin.loader, 'css-loader', 'sass-loader'],
exclude: /\.module\.s?(c|a)ss$/,
},
// Fonts
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
// Images
{
test: /\.(png|jpg|jpeg|gif)$/i,
type: 'asset/resource',
},
// SVG
{
test: /\.svg$/,
use: [
{
loader: '@svgr/webpack',
options: {
prettier: false,
svgo: false,
svgoConfig: {
plugins: [{ removeViewBox: false }],
},
titleProp: true,
ref: true,
},
},
'file-loader',
],
},
],
},
optimization: {
minimize: true,
minimizer: [new TerserPlugin(), new CssMinimizerPlugin()],
},
plugins: [
/**
* Create global constants which can be configured at compile time.
*
* Useful for allowing different behaviour between development builds and
* release builds
*
* NODE_ENV should be production so that modules do not perform certain
* development checks
*/
new webpack.EnvironmentPlugin({
NODE_ENV: 'production',
DEBUG_PROD: false,
}),
new MiniCssExtractPlugin({
filename: 'style.css',
}),
new BundleAnalyzerPlugin({
analyzerMode: process.env.ANALYZE === 'true' ? 'server' : 'disabled',
analyzerPort: 8889,
}),
new HtmlWebpackPlugin({
filename: 'index.html',
template: path.join(webpackPaths.srcRendererPath, 'index.ejs'),
minify: {
collapseWhitespace: true,
removeAttributeQuotes: true,
removeComments: true,
},
isBrowser: false,
isDevelopment: false,
}),
new webpack.DefinePlugin({
'process.type': '"renderer"',
}),
],
};
export default merge(baseConfig, configuration);
+38
View File
@@ -0,0 +1,38 @@
const path = require('path');
const rootPath = path.join(__dirname, '../..');
const dllPath = path.join(__dirname, '../dll');
const srcPath = path.join(rootPath, 'src');
const srcMainPath = path.join(srcPath, 'main');
const srcRendererPath = path.join(srcPath, 'renderer');
const releasePath = path.join(rootPath, 'release');
const appPath = path.join(releasePath, 'app');
const appPackagePath = path.join(appPath, 'package.json');
const appNodeModulesPath = path.join(appPath, 'node_modules');
const srcNodeModulesPath = path.join(srcPath, 'node_modules');
const distPath = path.join(appPath, 'dist');
const distMainPath = path.join(distPath, 'main');
const distRendererPath = path.join(distPath, 'renderer');
const buildPath = path.join(releasePath, 'build');
export default {
rootPath,
dllPath,
srcPath,
srcMainPath,
srcRendererPath,
releasePath,
appPath,
appPackagePath,
appNodeModulesPath,
srcNodeModulesPath,
distPath,
distMainPath,
distRendererPath,
buildPath,
};
File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 24 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 33 KiB

+1
View File
@@ -0,0 +1 @@
export default 'test-file-stub';
+8
View File
@@ -0,0 +1,8 @@
{
"rules": {
"no-console": "off",
"global-require": "off",
"import/no-dynamic-require": "off",
"import/no-extraneous-dependencies": "off"
}
}
+24
View File
@@ -0,0 +1,24 @@
// Check if the renderer and main bundles are built
import path from 'path';
import chalk from 'chalk';
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const mainPath = path.join(webpackPaths.distMainPath, 'main.js');
const rendererPath = path.join(webpackPaths.distRendererPath, 'renderer.js');
if (!fs.existsSync(mainPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The main process is not built yet. Build it by running "npm run build:main"',
),
);
}
if (!fs.existsSync(rendererPath)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
'The renderer process is not built yet. Build it by running "npm run build:renderer"',
),
);
}
+54
View File
@@ -0,0 +1,54 @@
import fs from 'fs';
import chalk from 'chalk';
import { execSync } from 'child_process';
import { dependencies } from '../../package.json';
if (dependencies) {
const dependenciesKeys = Object.keys(dependencies);
const nativeDeps = fs
.readdirSync('node_modules')
.filter((folder) => fs.existsSync(`node_modules/${folder}/binding.gyp`));
if (nativeDeps.length === 0) {
process.exit(0);
}
try {
// Find the reason for why the dependency is installed. If it is installed
// because of a devDependency then that is okay. Warn when it is installed
// because of a dependency
const { dependencies: dependenciesObject } = JSON.parse(
execSync(`npm ls ${nativeDeps.join(' ')} --json`).toString(),
);
const rootDependencies = Object.keys(dependenciesObject);
const filteredRootDependencies = rootDependencies.filter((rootDependency) =>
dependenciesKeys.includes(rootDependency),
);
if (filteredRootDependencies.length > 0) {
const plural = filteredRootDependencies.length > 1;
console.log(`
${chalk.whiteBright.bgYellow.bold(
'Webpack does not work with native dependencies.',
)}
${chalk.bold(filteredRootDependencies.join(', '))} ${
plural ? 'are native dependencies' : 'is a native dependency'
} and should be installed inside of the "./release/app" folder.
First, uninstall the packages from "./package.json":
${chalk.whiteBright.bgGreen.bold('npm uninstall your-package')}
${chalk.bold(
'Then, instead of installing the package to the root "./package.json":',
)}
${chalk.whiteBright.bgRed.bold('npm install your-package')}
${chalk.bold('Install the package to "./release/app/package.json"')}
${chalk.whiteBright.bgGreen.bold(
'cd ./release/app && npm install your-package',
)}
Read more about native dependencies at:
${chalk.bold(
'https://electron-react-boilerplate.js.org/docs/adding-dependencies/#module-structure',
)}
`);
process.exit(1);
}
} catch (e) {
console.log('Native dependencies could not be checked');
}
}
+16
View File
@@ -0,0 +1,16 @@
import chalk from 'chalk';
export default function checkNodeEnv(expectedEnv) {
if (!expectedEnv) {
throw new Error('"expectedEnv" not set');
}
if (process.env.NODE_ENV !== expectedEnv) {
console.log(
chalk.whiteBright.bgRed.bold(
`"process.env.NODE_ENV" must be "${expectedEnv}" to use this webpack config`,
),
);
process.exit(2);
}
}
+16
View File
@@ -0,0 +1,16 @@
import chalk from 'chalk';
import detectPort from 'detect-port';
const port = process.env.PORT || '1212';
detectPort(port, (_err, availablePort) => {
if (port !== String(availablePort)) {
throw new Error(
chalk.whiteBright.bgRed.bold(
`Port "${port}" on "localhost" is already in use. Please use another port. ex: PORT=4343 npm start`,
),
);
} else {
process.exit(0);
}
});
+13
View File
@@ -0,0 +1,13 @@
import { rimrafSync } from 'rimraf';
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const foldersToRemove = [
webpackPaths.distPath,
webpackPaths.buildPath,
webpackPaths.dllPath,
];
foldersToRemove.forEach((folder) => {
if (fs.existsSync(folder)) rimrafSync(folder);
});
+15
View File
@@ -0,0 +1,15 @@
import fs from 'fs';
import path from 'path';
import { rimrafSync } from 'rimraf';
import webpackPaths from '../configs/webpack.paths';
export default function deleteSourceMaps() {
if (fs.existsSync(webpackPaths.distMainPath))
rimrafSync(path.join(webpackPaths.distMainPath, '*.js.map'), {
glob: true,
});
if (fs.existsSync(webpackPaths.distRendererPath))
rimrafSync(path.join(webpackPaths.distRendererPath, '*.js.map'), {
glob: true,
});
}
+20
View File
@@ -0,0 +1,20 @@
import { execSync } from 'child_process';
import fs from 'fs';
import { dependencies } from '../../release/app/package.json';
import webpackPaths from '../configs/webpack.paths';
if (
Object.keys(dependencies || {}).length > 0 &&
fs.existsSync(webpackPaths.appNodeModulesPath)
) {
const electronRebuildCmd =
'../../node_modules/.bin/electron-rebuild --force --types prod,dev,optional --module-dir .';
const cmd =
process.platform === 'win32'
? electronRebuildCmd.replace(/\//g, '\\')
: electronRebuildCmd;
execSync(cmd, {
cwd: webpackPaths.appPath,
stdio: 'inherit',
});
}
+9
View File
@@ -0,0 +1,9 @@
import fs from 'fs';
import webpackPaths from '../configs/webpack.paths';
const { srcNodeModulesPath } = webpackPaths;
const { appNodeModulesPath } = webpackPaths;
if (!fs.existsSync(srcNodeModulesPath) && fs.existsSync(appNodeModulesPath)) {
fs.symlinkSync(appNodeModulesPath, srcNodeModulesPath, 'junction');
}
+32
View File
@@ -0,0 +1,32 @@
const { notarize } = require('@electron/notarize');
const { build } = require('../../package.json');
exports.default = async function notarizeMacos(context) {
const { electronPlatformName, appOutDir } = context;
if (electronPlatformName !== 'darwin') {
return;
}
if (process.env.CI !== 'true') {
console.warn('Skipping notarizing step. Packaging is not running in CI');
return;
}
if (
!('APPLE_ID' in process.env && 'APPLE_APP_SPECIFIC_PASSWORD' in process.env)
) {
console.warn(
'Skipping notarizing step. APPLE_ID and APPLE_APP_SPECIFIC_PASSWORD env variables must be set',
);
return;
}
const appName = context.packager.appInfo.productFilename;
await notarize({
appBundleId: build.appId,
appPath: `${appOutDir}/${appName}.app`,
appleId: process.env.APPLE_ID,
appleIdPassword: process.env.APPLE_APP_SPECIFIC_PASSWORD,
});
};
+33
View File
@@ -0,0 +1,33 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# OSX
.DS_Store
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
# eslint ignores hidden directories by default:
# https://github.com/eslint/eslint/issues/8429
!.erb
+47
View File
@@ -0,0 +1,47 @@
module.exports = {
extends: 'erb',
plugins: ['@typescript-eslint'],
rules: {
// A temporary hack related to IDE not resolving correct package.json
'import/no-extraneous-dependencies': 'off',
'react/react-in-jsx-scope': 'off',
'react/jsx-filename-extension': 'off',
'import/extensions': 'off',
'import/no-unresolved': 'off',
'import/no-import-module-exports': 'off',
'no-shadow': 'off',
'@typescript-eslint/no-shadow': 'error',
'no-unused-vars': 'off',
'react/function-component-definition': 'off',
'react/jsx-curly-brace-presence': 'off',
'react/require-default-props': 'off',
'react/jsx-props-no-spreading': 'off',
'react/destructuring-assignment': 'off',
'import/no-named-as-default': 'off',
'prefer-promise-reject-errors': 'off',
'react/jsx-no-useless-fragment': 'off',
'no-promise-executor-return': 'off',
'import/prefer-default-export': 'off',
'promise/no-promise-in-callback': 'off',
'react/no-array-index-key': 'off',
'class-methods-use-this': 'off',
'@typescript-eslint/no-unused-vars': 'error',
},
parserOptions: {
ecmaVersion: 2022,
sourceType: 'module',
},
settings: {
'import/resolver': {
// See https://github.com/benmosher/eslint-plugin-import/issues/1396#issuecomment-575727774 for line below
node: {},
webpack: {
config: require.resolve('./.erb/configs/webpack.config.eslint.ts'),
},
typescript: {},
},
'import/parsers': {
'@typescript-eslint/parser': ['.ts', '.tsx'],
},
},
};
+12
View File
@@ -0,0 +1,12 @@
* text eol=lf
*.exe binary
*.png binary
*.jpg binary
*.jpeg binary
*.ico binary
*.icns binary
*.eot binary
*.otf binary
*.ttf binary
*.woff binary
*.woff2 binary
+34
View File
@@ -0,0 +1,34 @@
# Logs
logs
*.log
# Runtime data
pids
*.pid
*.seed
# Coverage directory used by tools like istanbul
coverage
.eslintcache
# Dependency directory
# https://www.npmjs.org/doc/misc/npm-faq.html#should-i-check-my-node_modules-folder-into-git
node_modules
# OSX
.DS_Store
release/app/dist
release/build
.erb/dll
.idea
npm-debug.log.*
*.css.d.ts
*.sass.d.ts
*.scss.d.ts
assets/backend/*
src/renderer/services/analytics/index.ts
.env
.vscode
+49
View File
@@ -0,0 +1,49 @@
## 本地调试说明
首先下载 https://wwp.lanzouo.com/ipTev1qulx1i 里面的文件并解压到你的项目目录 assets/backend 目录下
确定本地 Nodejs 环境没有问题后
然后运行以下命令,安装依赖
```bash
npm i -g pnpm
pnpm i
```
就可以运行以下命令,启动项目
下面是我的 vscode 配置文件,可以参考一下
```JSON
{
"version": "0.2.0",
"configurations": [
{
"name": "Electron: Main",
"type": "node",
"request": "launch",
"protocol": "inspector",
"runtimeExecutable": "npm",
"runtimeArgs": ["run", "start"],
"env": {
"MAIN_ARGS": "--inspect=5858 --remote-debugging-port=9223"
}
},
{
"name": "Electron: Renderer",
"type": "chrome",
"request": "attach",
"port": 9223,
"webRoot": "${workspaceFolder}",
"timeout": 15000
}
],
"compounds": [
{
"name": "Electron: All",
"configurations": ["Electron: Main", "Electron: Renderer"]
}
]
}
```
+7
View File
@@ -0,0 +1,7 @@
# 1.0.0
- [x] 多平台支持:当前支持哔哩哔哩、抖音企业号、抖音、抖店、微博聊天、小红书专业号运营、小红书、知乎等平台,未来将不断扩展支持更多社交媒体平台。
- [x] 预设回复内容:允许用户设置自定义回复,以应对常见问题,提高回复效率。
- [x] 接入ChatGPT接口,根据客户的咨询内容智能生成回复,适用于处理复杂或者个性化的客户咨询。
- [x] 发送图片和二进制文件:支持发送图片等二进制文件,满足多样化的客户服务需求。
- [x] 知识库: 通过上传知识库文件自定义专属机器人,可作为数字分身、智能客服、私域助手使用,基于 [懒人百宝箱](https://chat.lazaytools.top/) 实现
- [x] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
+6 -2
View File
@@ -8,9 +8,12 @@
- [x] 接入ChatGPT接口,根据客户的咨询内容智能生成回复,适用于处理复杂或者个性化的客户咨询。
- [x] 发送图片和二进制文件:支持发送图片等二进制文件,满足多样化的客户服务需求。
- [x] 知识库: 通过上传知识库文件自定义专属机器人,可作为数字分身、智能客服、私域助手使用,基于 [懒人百宝箱](https://chat.lazaytools.top/) 实现
- [X] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
- [x] 各个平台独立的插件系统,支持插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
## 演示视频
[观看视频](https://www.bilibili.com/video/BV1qz421Q73S)
## 开源社区
如果有问题需要反馈,或者对项目有什么特性希望支持的,可以添加小助手微信加入开源项目交流群:
@@ -27,7 +30,8 @@
## 下载地址
[点击下载]()
<a href="https://wwp.lanzouo.com/iCntL1qukopc" style="display: inline-block; background-color: #008CBA; color: white; padding: 10px 20px; text-align: center; text-decoration: none; font-weight: bold; border-radius: 5px; margin: 4px 2px; cursor: pointer;">点击下载</a>
## 使用说明
第一次启动时可能会有点慢,因为它需要先下载驱动文件和初始化回复数据库,所以请耐心等待。
+40
View File
@@ -0,0 +1,40 @@
type Styles = Record<string, string>;
declare module '*.svg' {
import React = require('react');
export const ReactComponent: React.FC<React.SVGProps<SVGSVGElement>>;
const content: string;
export default content;
}
declare module '*.png' {
const content: string;
export default content;
}
declare module '*.jpg' {
const content: string;
export default content;
}
declare module '*.scss' {
const content: Styles;
export default content;
}
declare module '*.sass' {
const content: Styles;
export default content;
}
declare module '*.css' {
const content: Styles;
export default content;
}
declare module '*.gif' {
const content: Styles;
export default content;
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

Binary file not shown.
BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 186 KiB

BIN
View File
Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 9.1 KiB

+30
View File
@@ -0,0 +1,30 @@
appId: org.lrhh123.cocs
productName: 懒人客服
asar: true
asarUnpack: "**\\*.{node,dll}"
compression: maximum
files:
- dist
- node_modules
- package.json
afterSign: ".erb/scripts/notarize.js"
win:
target:
- nsis
nsis:
oneClick: false
perMachine: true
allowElevation: true
allowToChangeInstallationDirectory: true
createDesktopShortcut: true
createStartMenuShortcut: true
shortcutName: 懒人客服
uninstallDisplayName: ChatGPT-On-CS
deleteAppDataOnUninstall: true
artifactName: "${productName} ${version}.${ext}"
directories:
app: release/app
buildResources: assets
output: release/build
extraResources:
- "./assets/**"
+205
View File
@@ -0,0 +1,205 @@
{
"name": "chatgpt-on-cs",
"description": "多平台智能客服,允许使用 ChatGPT 作为客服机器人",
"version": "0.9.0",
"keywords": [
"electron",
"boilerplate",
"react",
"typescript",
"ts",
"sass",
"webpack",
"hot",
"reload"
],
"bugs": {
"url": "https://github.com/lrhh123/ChatGPT-On-CS"
},
"repository": {
"type": "git",
"url": "git+https://github.com/lrhh123/ChatGPT-On-CS.git"
},
"license": "AGPL-3.0",
"contributors": [
{
"name": "lrhh123",
"email": "lrhh123@users.noreply.github.com",
"url": "https://github.com/lrhh123"
}
],
"main": "./src/main/main.ts",
"scripts": {
"build": "pnpx concurrently \"pnpm run build:main\" \"pnpm run build:renderer\"",
"build:dll": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.dev.dll.ts",
"build:main": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.main.prod.ts",
"build:renderer": "cross-env NODE_ENV=production TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.renderer.prod.ts",
"postinstall": "ts-node .erb/scripts/check-native-dep.js && electron-builder install-app-deps && pnpm run build:dll",
"lint": "cross-env NODE_ENV=development eslint . --ext .js,.jsx,.ts,.tsx",
"package": "ts-node ./.erb/scripts/clean.js dist && pnpm run build && electron-builder build --publish never && pnpm run build:dll",
"rebuild": "electron-rebuild --parallel --types prod,dev,optional --module-dir release/app",
"start": "ts-node ./.erb/scripts/check-port-in-use.js && pnpm run start:renderer",
"start:main": "cross-env NODE_ENV=development electronmon -r ts-node/register/transpile-only .",
"start:preload": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack --config ./.erb/configs/webpack.config.preload.dev.ts",
"start:renderer": "cross-env NODE_ENV=development TS_NODE_TRANSPILE_ONLY=true webpack serve --config ./.erb/configs/webpack.config.renderer.dev.ts",
"test": "jest"
},
"browserslist": [],
"prettier": {
"singleQuote": true,
"overrides": [
{
"files": [
".prettierrc",
".eslintrc"
],
"options": {
"parser": "json"
}
}
]
},
"jest": {
"moduleDirectories": [
"node_modules",
"release/app/node_modules",
"src"
],
"moduleFileExtensions": [
"js",
"jsx",
"ts",
"tsx",
"json"
],
"moduleNameMapper": {
"\\.(jpg|jpeg|png|gif|eot|otf|webp|svg|ttf|woff|woff2|mp4|webm|wav|mp3|m4a|aac|oga)$": "<rootDir>/.erb/mocks/fileMock.js",
"\\.(css|less|sass|scss)$": "identity-obj-proxy"
},
"setupFiles": [
"./.erb/scripts/check-build-exists.ts"
],
"testEnvironment": "jsdom",
"testEnvironmentOptions": {
"url": "http://localhost/"
},
"testPathIgnorePatterns": [
"release/app/dist",
".erb/dll"
],
"transform": {
"\\.(ts|tsx|js|jsx)$": "ts-jest"
}
},
"dependencies": {
"@agconnect/api": "^1.3.2",
"@agconnect/instance": "^1.3.2",
"@chakra-ui/anatomy": "^2.2.2",
"@chakra-ui/icons": "^2.1.1",
"@chakra-ui/react": "^2.8.2",
"@emotion/react": "^11.11.3",
"@emotion/styled": "^11.11.0",
"@hw-hmscore/analytics-web": "6.9.9-301",
"@tanstack/react-query": "4.36.1",
"axios": "^1.6.7",
"electron-debug": "^3.2.0",
"electron-log": "^4.4.8",
"electron-store": "^8.1.0",
"electron-updater": "^6.1.4",
"framer-motion": "^11.0.3",
"immer": "^10.0.3",
"net": "^1.0.2",
"node-cron": "^3.0.3",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-hook-form": "^7.50.1",
"react-icons": "^5.0.1",
"react-markdown": "^9.0.1",
"react-router-dom": "^6.16.0",
"react-spinners": "^0.13.8",
"react-use-websocket": "^4.8.1",
"remark-gfm": "^4.0.0",
"source-map-support": "^0.5.21",
"zustand": "^4.5.0"
},
"devDependencies": {
"@electron/notarize": "^2.1.0",
"@electron/rebuild": "^3.3.0",
"@pmmmwh/react-refresh-webpack-plugin": "^0.5.11",
"@svgr/webpack": "^8.1.0",
"@teamsupercell/typings-for-css-modules-loader": "^2.5.2",
"@testing-library/jest-dom": "^6.1.3",
"@testing-library/react": "^14.0.0",
"@types/jest": "^29.5.5",
"@types/node": "20.6.2",
"@types/react": "^18.2.21",
"@types/react-dom": "^18.2.7",
"@types/react-test-renderer": "^18.0.1",
"@types/terser-webpack-plugin": "^5.0.4",
"@types/webpack-bundle-analyzer": "^4.6.0",
"@typescript-eslint/eslint-plugin": "^6.7.0",
"@typescript-eslint/parser": "^6.7.0",
"browserslist-config-erb": "^0.0.3",
"chalk": "^4.1.2",
"concurrently": "^8.2.1",
"core-js": "^3.32.2",
"cross-env": "^7.0.3",
"css-loader": "^6.8.1",
"css-minimizer-webpack-plugin": "^5.0.1",
"detect-port": "^1.5.1",
"dotenv": "^16.4.4",
"electron": "^26.2.1",
"electron-builder": "^24.6.4",
"electron-devtools-installer": "^3.2.0",
"electronmon": "^2.0.2",
"eslint": "^8.49.0",
"eslint-config-airbnb-base": "^15.0.0",
"eslint-config-erb": "^4.1.0-0",
"eslint-import-resolver-typescript": "^3.6.0",
"eslint-import-resolver-webpack": "^0.13.7",
"eslint-plugin-compat": "^4.2.0",
"eslint-plugin-import": "^2.28.1",
"eslint-plugin-jest": "^27.4.0",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-plugin-promise": "^6.1.1",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"file-loader": "^6.2.0",
"html-webpack-plugin": "^5.5.3",
"identity-obj-proxy": "^3.0.0",
"jest": "^29.7.0",
"jest-environment-jsdom": "^29.7.0",
"mini-css-extract-plugin": "^2.7.6",
"prettier": "^3.0.3",
"prettier-eslint": "^16.3.0",
"react-refresh": "^0.14.0",
"react-test-renderer": "^18.2.0",
"rimraf": "^5.0.1",
"sass": "^1.67.0",
"sass-loader": "^13.3.2",
"style-loader": "^3.3.3",
"terser-webpack-plugin": "^5.3.9",
"ts-jest": "^29.1.1",
"ts-loader": "^9.4.4",
"ts-node": "^10.9.1",
"tsconfig-paths-webpack-plugin": "^4.1.0",
"typescript": "^5.2.2",
"url-loader": "^4.1.1",
"webpack": "^5.88.2",
"webpack-bundle-analyzer": "^4.10.1",
"webpack-cli": "^5.1.4",
"webpack-dev-server": "^4.15.1",
"webpack-merge": "^5.9.0"
},
"devEngines": {
"node": ">=14.x",
"npm": ">=7.x"
},
"electronmon": {
"patterns": [
"!**/**",
"src/main/**"
],
"logLevel": "quiet"
}
}
+13849
View File
File diff suppressed because it is too large Load Diff
+9
View File
@@ -0,0 +1,9 @@
import '@testing-library/jest-dom';
import { render } from '@testing-library/react';
import App from '../renderer/App';
describe('App', () => {
it('should render', () => {
expect(render(<App />)).toBeTruthy();
});
});
+102
View File
@@ -0,0 +1,102 @@
import {
ipcMain,
dialog,
shell,
BrowserWindow,
app,
Notification,
} from 'electron';
import Store from 'electron-store';
import { setCron } from './system/cron';
import type BackendServiceManager from './system/backend';
import { getBrowserVersionFromOS } from './system/chrome';
const store = new Store();
const setupIpcHandlers = (
mainWindow: BrowserWindow,
bsm: BackendServiceManager,
) => {
ipcMain.on('get-env', async (event, key) => {
event.returnValue = process.env[key];
});
ipcMain.on('get-port', async (event) => {
event.returnValue = bsm.getPort();
});
ipcMain.on('ipc-example', async (event) => {
const msgTemplate = (pingPong: string) => `IPC test: ${pingPong}`;
event.reply('ipc-example', msgTemplate('pong'));
});
ipcMain.on('select-directory', async (event) => {
const result = await dialog.showOpenDialog({
properties: ['openDirectory'],
});
event.reply('selected-directory', result.filePaths);
});
ipcMain.on('select-file', async (event) => {
const result = await dialog.showOpenDialog({
properties: ['openFile'],
});
event.reply('selected-file', result.filePaths);
});
ipcMain.on('open-directory', async (event, args) => {
shell.openPath(args);
});
ipcMain.on('electron-store-get', async (event, val) => {
event.returnValue = store.get(val);
});
ipcMain.on('electron-store-set', async (event, key, val) => {
store.set(key, val);
});
ipcMain.on('electron-store-remove', async (event, key) => {
store.delete(key);
});
ipcMain.on('get-version', async (event) => {
event.returnValue = app.getVersion();
});
ipcMain.on('open-url', async (event, url) => {
shell.openExternal(url);
});
ipcMain.on('get-browser-version', async (event) => {
const version = await getBrowserVersionFromOS();
event.returnValue = version;
});
ipcMain.on('notification', async (event, title, message) => {
const notification = {
title,
body: message,
};
new Notification(notification).show();
});
// 每隔 5 秒执行一次
setCron('*/5 * * * * *', () => {
mainWindow.webContents.send('refresh-config');
});
// 每隔 5 秒执行一次检查后端服务是否健康
setCron('*/5 * * * * *', async () => {
if (!bsm) {
console.error('BackendServiceManager not found');
return;
}
console.log('Checking health...');
const isHealthy = await bsm.check_health();
mainWindow.webContents.send('check-health', isHealthy);
});
};
export default setupIpcHandlers;
+133
View File
@@ -0,0 +1,133 @@
/* eslint global-require: off, no-console: off, promise/always-return: off */
/**
* This module executes inside of electron's main process. You can start
* electron renderer process from here and communicate with the other processes
* through IPC.
*
* When running `npm run build` or `npm run build:main`, this file is compiled to
* `./src/main.js` using webpack. This gives us some performance wins.
*/
import path from 'path';
import { app, BrowserWindow, shell } from 'electron';
import MenuBuilder from './menu';
import { resolveHtmlPath } from './util';
import setupIpcHandlers from './ipcHandlers';
import BackendServiceManager from './system/backend';
let mainWindow: BrowserWindow | null = null;
let backendServiceManager: BackendServiceManager | null = null;
if (process.env.NODE_ENV === 'production') {
const sourceMapSupport = require('source-map-support');
sourceMapSupport.install();
}
const isDebug =
process.env.NODE_ENV === 'development' || process.env.DEBUG_PROD === 'true';
if (isDebug) {
require('electron-debug')();
}
const installExtensions = async () => {
const installer = require('electron-devtools-installer');
const forceDownload = !!process.env.UPGRADE_EXTENSIONS;
const extensions = ['REACT_DEVELOPER_TOOLS'];
return installer
.default(
extensions.map((name) => installer[name]),
forceDownload,
)
.catch(console.log);
};
const createWindow = async () => {
if (isDebug) {
await installExtensions();
}
const RESOURCES_PATH = app.isPackaged
? path.join(process.resourcesPath, 'assets')
: path.join(__dirname, '../../assets');
backendServiceManager = new BackendServiceManager(
path.join(
RESOURCES_PATH,
process.env.BKEXE_PATH || './backend/__main__.exe',
),
);
await backendServiceManager.start();
const getAssetPath = (...paths: string[]): string => {
return path.join(RESOURCES_PATH, ...paths);
};
mainWindow = new BrowserWindow({
show: false,
width: 528,
height: 1024,
resizable: false, // 防止用户调整窗口大小
icon: getAssetPath('icon.png'),
webPreferences: {
preload: app.isPackaged
? path.join(__dirname, 'preload.js')
: path.join(__dirname, '../../.erb/dll/preload.js'),
},
});
setupIpcHandlers(mainWindow, backendServiceManager);
mainWindow.loadURL(resolveHtmlPath('index.html'));
mainWindow.on('ready-to-show', () => {
if (!mainWindow) {
throw new Error('"mainWindow" is not defined');
}
if (process.env.START_MINIMIZED) {
mainWindow.minimize();
} else {
mainWindow.show();
}
});
mainWindow.on('closed', () => {
mainWindow = null;
});
const menuBuilder = new MenuBuilder(mainWindow);
menuBuilder.buildMenu();
// Open urls in the user's browser
mainWindow.webContents.setWindowOpenHandler((edata) => {
shell.openExternal(edata.url);
return { action: 'deny' };
});
};
/**
* Add event listeners...
*/
app.on('window-all-closed', async () => {
// Respect the OSX convention of having the application in memory even
// after all windows have been closed
console.log('window-all-closed');
await backendServiceManager?.stop();
if (process.platform !== 'darwin') {
app.quit();
}
});
app
.whenReady()
.then(() => {
createWindow();
app.on('activate', () => {
// On macOS it's common to re-create a window in the app when the
// dock icon is clicked and there are no other windows open.
if (mainWindow === null) createWindow();
});
})
.catch(console.log);
+289
View File
@@ -0,0 +1,289 @@
import {
app,
Menu,
shell,
BrowserWindow,
MenuItemConstructorOptions,
} from 'electron';
interface DarwinMenuItemConstructorOptions extends MenuItemConstructorOptions {
selector?: string;
submenu?: DarwinMenuItemConstructorOptions[] | Menu;
}
export default class MenuBuilder {
mainWindow: BrowserWindow;
constructor(mainWindow: BrowserWindow) {
this.mainWindow = mainWindow;
}
buildMenu() {
if (
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
) {
this.setupDevelopmentEnvironment();
const template =
process.platform === 'darwin'
? this.buildDarwinTemplate()
: this.buildDefaultTemplate();
const menu = Menu.buildFromTemplate(template);
Menu.setApplicationMenu(menu);
} else {
Menu.setApplicationMenu(null);
this.mainWindow.setMenu(null);
}
}
setupDevelopmentEnvironment(): void {
this.mainWindow.webContents.on('context-menu', (_, props) => {
const { x, y } = props;
Menu.buildFromTemplate([
{
label: 'Inspect element',
click: () => {
this.mainWindow.webContents.inspectElement(x, y);
},
},
]).popup({ window: this.mainWindow });
});
}
buildDarwinTemplate(): MenuItemConstructorOptions[] {
const subMenuAbout: DarwinMenuItemConstructorOptions = {
label: 'Electron',
submenu: [
{
label: 'About ElectronReact',
selector: 'orderFrontStandardAboutPanel:',
},
{ type: 'separator' },
{ label: 'Services', submenu: [] },
{ type: 'separator' },
{
label: 'Hide ElectronReact',
accelerator: 'Command+H',
selector: 'hide:',
},
{
label: 'Hide Others',
accelerator: 'Command+Shift+H',
selector: 'hideOtherApplications:',
},
{ label: 'Show All', selector: 'unhideAllApplications:' },
{ type: 'separator' },
{
label: 'Quit',
accelerator: 'Command+Q',
click: () => {
app.quit();
},
},
],
};
const subMenuEdit: DarwinMenuItemConstructorOptions = {
label: 'Edit',
submenu: [
{ label: 'Undo', accelerator: 'Command+Z', selector: 'undo:' },
{ label: 'Redo', accelerator: 'Shift+Command+Z', selector: 'redo:' },
{ type: 'separator' },
{ label: 'Cut', accelerator: 'Command+X', selector: 'cut:' },
{ label: 'Copy', accelerator: 'Command+C', selector: 'copy:' },
{ label: 'Paste', accelerator: 'Command+V', selector: 'paste:' },
{
label: 'Select All',
accelerator: 'Command+A',
selector: 'selectAll:',
},
],
};
const subMenuViewDev: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Reload',
accelerator: 'Command+R',
click: () => {
this.mainWindow.webContents.reload();
},
},
{
label: 'Toggle Full Screen',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
},
{
label: 'Toggle Developer Tools',
accelerator: 'Alt+Command+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
},
],
};
const subMenuViewProd: MenuItemConstructorOptions = {
label: 'View',
submenu: [
{
label: 'Toggle Full Screen',
accelerator: 'Ctrl+Command+F',
click: () => {
this.mainWindow.setFullScreen(!this.mainWindow.isFullScreen());
},
},
],
};
const subMenuWindow: DarwinMenuItemConstructorOptions = {
label: 'Window',
submenu: [
{
label: 'Minimize',
accelerator: 'Command+M',
selector: 'performMiniaturize:',
},
{ label: 'Close', accelerator: 'Command+W', selector: 'performClose:' },
{ type: 'separator' },
{ label: 'Bring All to Front', selector: 'arrangeInFront:' },
],
};
const subMenuHelp: MenuItemConstructorOptions = {
label: 'Help',
submenu: [
{
label: 'Learn More',
click() {
shell.openExternal('https://electronjs.org');
},
},
{
label: 'Documentation',
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
},
{
label: 'Community Discussions',
click() {
shell.openExternal('https://www.electronjs.org/community');
},
},
{
label: 'Search Issues',
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
},
],
};
const subMenuView =
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
? subMenuViewDev
: subMenuViewProd;
return [subMenuAbout, subMenuEdit, subMenuView, subMenuWindow, subMenuHelp];
}
buildDefaultTemplate() {
const templateDefault = [
{
label: '&File',
submenu: [
{
label: '&Open',
accelerator: 'Ctrl+O',
},
{
label: '&Close',
accelerator: 'Ctrl+W',
click: () => {
this.mainWindow.close();
},
},
],
},
{
label: '&View',
submenu:
process.env.NODE_ENV === 'development' ||
process.env.DEBUG_PROD === 'true'
? [
{
label: '&Reload',
accelerator: 'Ctrl+R',
click: () => {
this.mainWindow.webContents.reload();
},
},
{
label: 'Toggle &Full Screen',
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
},
{
label: 'Toggle &Developer Tools',
accelerator: 'Alt+Ctrl+I',
click: () => {
this.mainWindow.webContents.toggleDevTools();
},
},
]
: [
{
label: 'Toggle &Full Screen',
accelerator: 'F11',
click: () => {
this.mainWindow.setFullScreen(
!this.mainWindow.isFullScreen(),
);
},
},
],
},
{
label: 'Help',
submenu: [
{
label: 'Learn More',
click() {
shell.openExternal('https://electronjs.org');
},
},
{
label: 'Documentation',
click() {
shell.openExternal(
'https://github.com/electron/electron/tree/main/docs#readme',
);
},
},
{
label: 'Community Discussions',
click() {
shell.openExternal('https://www.electronjs.org/community');
},
},
{
label: 'Search Issues',
click() {
shell.openExternal('https://github.com/electron/electron/issues');
},
},
],
},
];
return templateDefault;
}
}
+73
View File
@@ -0,0 +1,73 @@
// Disable no-unused-vars, broken for spread args
/* eslint no-unused-vars: off */
import { contextBridge, ipcRenderer, IpcRendererEvent } from 'electron';
export type Channels =
| 'ipc-example'
| 'get-env'
| 'get-port'
| 'check-health'
| 'electron-store-get'
| 'electron-store-set'
| 'electron-store-remove'
| 'refresh-config'
| 'open-directory'
| 'select-file'
| 'selected-file'
| 'select-directory'
| 'selected-directory'
| 'open-url'
| 'notification'
| 'get-browser-version'
| 'get-version';
const electronHandler = {
ipcRenderer: {
sendMessage(channel: Channels, ...args: unknown[]) {
ipcRenderer.send(channel, ...args);
},
get(channel: Channels) {
return ipcRenderer.sendSync(channel);
},
on(channel: Channels, func: (...args: unknown[]) => void) {
const subscription = (_event: IpcRendererEvent, ...args: unknown[]) =>
func(...args);
ipcRenderer.on(channel, subscription);
return () => {
ipcRenderer.removeListener(channel, subscription);
};
},
// 这个once方法是特别的,因为它确保了事件处理函数只会被调用一次,然后自动移除。
once(channel: Channels, func: (...args: unknown[]) => void) {
ipcRenderer.once(channel, (_event, ...args) => func(...args));
},
remove(channel: Channels) {
ipcRenderer.removeAllListeners(channel);
},
},
store: {
get(key: string) {
return ipcRenderer.sendSync('electron-store-get', key);
},
set(key: string, value: string) {
ipcRenderer.send('electron-store-set', key, value);
},
remove(key: string) {
ipcRenderer.send('electron-store-remove', key);
},
},
getEnv: (key: string) => {
const v = ipcRenderer.sendSync('get-env', key);
return v;
},
getPort: () => {
const v = ipcRenderer.sendSync('get-port');
return v;
},
};
export type ElectronHandler = typeof electronHandler;
// 把功能暴露给渲染进程
contextBridge.exposeInMainWorld('electron', electronHandler);
+130
View File
@@ -0,0 +1,130 @@
import { spawn, exec } from 'child_process';
import { createServer } from 'net';
import axios from 'axios';
import fs from 'fs';
import os from 'os';
import path from 'path';
class BackendServiceManager {
private executablePath: string;
private process: ReturnType<typeof spawn> | null;
private port: number;
private autoRestart: boolean; // 新增自动重启标志
constructor(executablePath: string, autoRestart: boolean = true) {
this.executablePath = executablePath;
this.process = null;
this.port = 0;
this.autoRestart = autoRestart; // 是否自动重启
}
async start() {
if (process.env.NODE_ENV === 'development') {
this.port = 9999;
return;
}
const port = await this.getAvailablePort();
this.port = port;
this.launchProcess(port);
// 监听退出事件并可能重启
this.process?.on('close', (code) => {
console.log(`child process exited with code ${code}`);
if (this.autoRestart) {
console.log('Attempting to restart...');
this.start().catch((err) => console.error('Failed to restart:', err));
}
});
}
private launchProcess(port: number) {
// 获取系统的临时文件夹路径
const tempDir = os.tmpdir();
// 在临时文件夹中创建一个名为 chatgpt-on-cs 的目录用于存放日志
const logDir = path.join(tempDir, 'chatgpt-on-cs');
// 如果目录不存在,则创建它
if (!fs.existsSync(logDir)) {
fs.mkdirSync(logDir);
}
// 定义日志文件的路径
const logFilePath = path.join(logDir, 'process.log');
// 创建一个写入流
const logStream = fs.createWriteStream(logFilePath, { flags: 'a' });
this.process = spawn(this.executablePath, ['--port', port.toString()]);
this.process.stdout?.on('data', (data) => {
console.log(`stdout: ${data}`); // 可选:依然在控制台输出
logStream.write(`stdout: ${data}`); // 写入文件
});
this.process.stderr?.on('data', (data) => {
console.error(`stderr: ${data}`); // 可选:依然在控制台输出
logStream.write(`stderr: ${data}`); // 写入文件
});
// 监听进程关闭事件,关闭写入流
this.process.on('close', () => {
logStream.close();
});
}
async getAvailablePort(): Promise<number> {
return new Promise((resolve, reject) => {
const server = createServer();
server.unref();
server.on('error', reject);
server.listen(0, () => {
const { port } = server.address() as { port: number };
server.close(() => {
resolve(port);
});
});
});
}
async check_health() {
// 发送 HTTP 请求检查服务是否健康
try {
const {
data: { data },
} = await axios.get(`http://127.0.0.1:${this.port}/api/v1/base/health`);
return data.init_db;
} catch (error) {
return false;
}
}
stop() {
// 修改停止方法以标记不自动重启
return new Promise((resolve, reject) => {
this.autoRestart = false; // 停止时禁用自动重启
if (this.process) {
console.log('Killing process...');
const { pid } = this.process;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
exec(`taskkill /pid ${pid} /T /F`, (error, stdout, stderr) => {
this.process = null;
if (error) {
reject(error);
return;
}
resolve({});
});
} else {
resolve({});
}
});
}
getPort() {
return this.port;
}
}
export default BackendServiceManager;
+34
View File
@@ -0,0 +1,34 @@
import { exec } from 'child_process';
import { promisify } from 'util';
const execAsync = promisify(exec);
async function readVersionFromCmd(cmd: string, pattern: RegExp) {
try {
const { stdout } = await execAsync(cmd, { shell: 'powershell.exe' });
const match = stdout.match(pattern);
return match ? match[0] : null;
} catch (error) {
// 如果命令执行失败,这里会捕获到异常,但我们选择忽略它,因为可能是因为浏览器不存在
return null;
}
}
export async function getBrowserVersionFromOS(): Promise<string | null> {
const commands = [
`(Get-Item -Path "$env:PROGRAMFILES\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
`(Get-Item -Path "'$env:PROGRAMFILES (x86)'\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
`(Get-Item -Path "$env:LOCALAPPDATA\\Google\\Chrome\\Application\\chrome.exe").VersionInfo.FileVersion`,
`(Get-ItemProperty -Path Registry::"HKCU\\SOFTWARE\\Google\\Chrome\\BLBeacon").version`,
`(Get-ItemProperty -Path Registry::"HKLM\\SOFTWARE\\Wow6432Node\\Microsoft\\Windows\\CurrentVersion\\Uninstall\\Google Chrome").version`,
];
const pattern = /\d+\.\d+\.\d+/;
const firstHitTemplate = `$tmp = {expression}; if ($tmp) {echo $tmp; Exit;};`;
const script = `$ErrorActionPreference='silentlycontinue'; ${commands
.map((e) => firstHitTemplate.replace('{expression}', e))
.join(' ')}`;
const version = await readVersionFromCmd(`${script}`, pattern);
return version; // 如果所有命令都失败了,返回 null
}
+6
View File
@@ -0,0 +1,6 @@
import nodeCron from 'node-cron';
export const setCron = (time: string, cb: () => void) => {
// second minute hour day month week
return nodeCron.schedule(time, cb);
};
+13
View File
@@ -0,0 +1,13 @@
/* eslint import/prefer-default-export: off */
import { URL } from 'url';
import path from 'path';
export function resolveHtmlPath(htmlFileName: string) {
if (process.env.NODE_ENV === 'development') {
const port = process.env.PORT || 1212;
const url = new URL(`http://localhost:${port}`);
url.pathname = htmlFileName;
return url.href;
}
return `file://${path.resolve(__dirname, '../renderer/', htmlFileName)}`;
}
+54
View File
@@ -0,0 +1,54 @@
/*
* @NOTE: Prepend a `~` to css file paths that are in your node_modules
* See https://github.com/webpack-contrib/sass-loader#imports
*/
html,
body {
height: 100%;
overflow: auto; /* 或者使用 'scroll' 来强制显示滚动条 */
}
body {
height: 100vh;
}
@font-face {
font-family: 'zhFont';
src: url('../../assets/fonts/庞门正道标题体.ttf') format('truetype');
font-weight: normal;
font-style: normal;
}
.font-zh {
font-family: 'zhFont';
}
.table-tiny th,
.table-tiny td {
padding-left: 4px;
padding-right: 4px;
}
.table-tiny th:first-child,
.table-tiny td:first-child {
max-width: 60px;
}
* {
scrollbar-width: thin;
scrollbar-color: var(--chakra-colors-gray-300) transparent;
}
*::-webkit-scrollbar {
width: 5px;
}
*::-webkit-scrollbar-track {
background: transparent;
}
*::-webkit-scrollbar-thumb {
background-color: var(--chakra-colors-gray-300);
border-radius: 10px;
border: none;
}
+86
View File
@@ -0,0 +1,86 @@
import React, { useState, useEffect } from 'react';
import { MemoryRouter as Router, Routes, Route } from 'react-router-dom';
import { ChakraProvider, Box, Flex } from '@chakra-ui/react';
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import Navbar from './components/layout/Navbar';
import Footer from './components/layout/Footer';
import ErrorBoundary from './components/ErrorBoundary';
import HomePage from './pages/Home';
import SettingsPage from './pages/Settings';
import AboutPage from './pages/About';
import MsgList from './pages/MsgList';
import FullScreenLoader from './pages/FullScreenLoader';
import PlatformSettings from './pages/Platforms';
import { SettingsProvider } from './pages/Settings/SettingsContext';
import Updater from './components/Updater';
import SystemCheck from './components/SystemCheck';
import { WebSocketProvider } from './hooks/useWebSocketContext';
import './App.css';
import theme from './ui/styles/theme';
// Create a client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
keepPreviousData: true,
refetchOnWindowFocus: false,
retry: false,
cacheTime: 10,
},
},
});
function App() {
const [isLoaded, setIsLoaded] = useState(false);
useEffect(() => {
window.electron.ipcRenderer.on('check-health', (health) => {
const h = health as boolean;
setIsLoaded(h);
});
return () => {
window.electron.ipcRenderer.remove('check-health');
};
});
return (
<QueryClientProvider client={queryClient}>
<ChakraProvider theme={theme}>
<WebSocketProvider>
<ErrorBoundary>
<Router>
{isLoaded ? (
<Flex direction="column" minH="100vh">
<Navbar />
<Box flex="1" mt={{ base: '4rem', md: '5rem' }}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/msg" element={<MsgList />} />
<Route path="/platforms" element={<PlatformSettings />} />
<Route
path="/settings"
element={
<SettingsProvider>
<SettingsPage />
</SettingsProvider>
}
/>
<Route path="/about" element={<AboutPage />} />
</Routes>
</Box>
<Footer />
</Flex>
) : (
<FullScreenLoader />
)}
<SystemCheck />
<Updater />
</Router>
</ErrorBoundary>
</WebSocketProvider>
</ChakraProvider>
</QueryClientProvider>
);
}
export default App;
@@ -0,0 +1,117 @@
import React from 'react';
import {
Alert,
AlertIcon,
AlertTitle,
AlertDescription,
Button,
Link,
Box,
} from '@chakra-ui/react';
interface ErrorBoundaryProps {
children: React.ReactNode;
}
interface ErrorBoundaryState {
hasError: boolean;
error: Error | null;
}
class ErrorBoundary extends React.Component<
ErrorBoundaryProps,
ErrorBoundaryState
> {
constructor(props: ErrorBoundaryProps) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
return { hasError: true, error };
}
componentDidMount() {
window.addEventListener('unhandledrejection', this.handlePromiseRejection);
}
componentDidCatch(error: Error, errorInfo: React.ErrorInfo) {
console.error('Caught an error:', error, errorInfo);
}
componentWillUnmount() {
window.removeEventListener(
'unhandledrejection',
this.handlePromiseRejection,
);
}
handlePromiseRejection = (event: PromiseRejectionEvent) => {
this.setState({ hasError: true, error: event.reason });
};
render() {
const { hasError, error } = this.state;
if (hasError) {
return (
<Box
position="fixed"
top="0"
left="0"
width="100vw"
height="100vh"
backgroundColor="gray.200"
zIndex="modal"
display="flex"
flexDirection="column"
alignItems="center"
justifyContent="center"
textAlign="center"
padding="40px"
>
<Alert
status="error"
flexDirection="column"
justifyContent="center"
textAlign="center"
height="auto"
borderRadius="md"
boxShadow="lg"
backgroundColor="white"
>
<AlertIcon boxSize="50px" mr={0} />
<AlertTitle mt={4} mb={1} fontSize="xl">
</AlertTitle>
<AlertDescription maxWidth="sm" mb={4}>
{error?.toString() || '未知错误,请尝试刷新页面或稍后再试。'}
</AlertDescription>
<Button
colorScheme="red"
variant="solid"
onClick={() => window.location.reload()}
>
</Button>
<AlertDescription maxWidth="sm" mt={4}>
</AlertDescription>
<Link
href="mailto:author@example.com"
isExternal
mt={2}
color="teal.500"
>
author@example.com
</Link>
</Alert>
</Box>
);
}
return this.props.children;
}
}
export default ErrorBoundary;
+13
View File
@@ -0,0 +1,13 @@
import React from 'react';
import { DotLoader } from 'react-spinners';
import './loader.css';
const FullScreenLoader = () => {
return (
<div className="full-screen-loader">
<DotLoader color="#36d7b7" />
</div>
);
};
export default React.memo(FullScreenLoader);
+11
View File
@@ -0,0 +1,11 @@
.full-screen-loader {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
display: flex;
justify-content: center;
align-items: center;
background-color: rgba(255, 255, 255, 0.9);
}
@@ -0,0 +1,421 @@
.waitingAnimation > :last-child::after {
display: inline-block;
content: '';
width: 3px;
height: 14px;
transform: translate(4px, 2px) scaleY(1.3);
animation: blink 0.6s infinite;
}
.animation {
height: 20px;
&::after {
display: inline-block;
content: '';
width: 3px;
height: 14px;
transform: translate(4px, 2px) scaleY(1.3);
animation: blink 0.6s infinite;
}
}
@keyframes blink {
from,
to {
opacity: 0;
}
50% {
opacity: 1;
}
}
.markdown > *:first-child {
margin-top: 0 !important;
}
.markdown > *:last-child {
margin-bottom: 0 !important;
}
.markdown a.absent {
color: #cc0000;
}
.markdown a.anchor {
bottom: 0;
cursor: pointer;
display: block;
left: 0;
margin-left: -30px;
padding-left: 30px;
position: absolute;
top: 0;
}
.markdown h1,
.markdown h2,
.markdown h3,
.markdown h4,
.markdown h5,
.markdown h6 {
cursor: text;
font-weight: bold;
margin: 10px 0;
padding: 0;
position: relative;
}
.markdown h1 .mini-icon-link,
.markdown h2 .mini-icon-link,
.markdown h3 .mini-icon-link,
.markdown h4 .mini-icon-link,
.markdown h5 .mini-icon-link,
.markdown h6 .mini-icon-link {
display: none;
}
.markdown h1:hover a.anchor,
.markdown h2:hover a.anchor,
.markdown h3:hover a.anchor,
.markdown h4:hover a.anchor,
.markdown h5:hover a.anchor,
.markdown h6:hover a.anchor {
line-height: 1;
margin-left: -22px;
padding-left: 0;
text-decoration: none;
top: 15%;
}
.markdown h1:hover a.anchor .mini-icon-link,
.markdown h2:hover a.anchor .mini-icon-link,
.markdown h3:hover a.anchor .mini-icon-link,
.markdown h4:hover a.anchor .mini-icon-link,
.markdown h5:hover a.anchor .mini-icon-link,
.markdown h6:hover a.anchor .mini-icon-link {
display: inline-block;
}
.markdown h1 tt,
.markdown h1 code,
.markdown h2 tt,
.markdown h2 code,
.markdown h3 tt,
.markdown h3 code,
.markdown h4 tt,
.markdown h4 code,
.markdown h5 tt,
.markdown h5 code,
.markdown h6 tt,
.markdown h6 code {
font-size: inherit;
}
.markdown h1 {
font-size: 28px;
}
.markdown h2 {
font-size: 24px;
}
.markdown h3 {
font-size: 18px;
}
.markdown h4 {
font-size: 16px;
}
.markdown h5 {
font-size: 14px;
}
.markdown h6 {
font-size: 12px;
}
.markdown p,
.markdown blockquote,
.markdown ul,
.markdown ol,
.markdown dl,
.markdown table,
.markdown pre {
margin: 14px 0;
}
.markdown > h2:first-child,
.markdown > h1:first-child,
.markdown > h1:first-child + h2,
.markdown > h3:first-child,
.markdown > h4:first-child,
.markdown > h5:first-child,
.markdown > h6:first-child {
margin-top: 0;
padding-top: 0;
}
.markdown a:first-child h1,
.markdown a:first-child h2,
.markdown a:first-child h3,
.markdown a:first-child h4,
.markdown a:first-child h5,
.markdown a:first-child h6 {
margin-top: 0;
padding-top: 0;
}
.markdown h1 + p,
.markdown h2 + p,
.markdown h3 + p,
.markdown h4 + p,
.markdown h5 + p,
.markdown h6 + p {
margin-top: 0;
}
.markdown li p.first {
display: inline-block;
}
.markdown ul,
.markdown ol {
padding-left: 2em;
}
.markdown ul.no-list,
.markdown ol.no-list {
list-style-type: none;
padding: 0;
}
.markdown ul li > *:first-child,
.markdown ol li > *:first-child {
margin-top: 0;
}
.markdown ul,
.markdown ol {
// padding-left: 14px;
}
.markdown dl {
padding: 0;
}
.markdown dl dt {
font-size: 14px;
font-style: italic;
font-weight: bold;
margin: 15px 0 5px;
padding: 0;
}
.markdown dl dt:first-child {
padding: 0;
}
.markdown dl dt > *:first-child {
margin-top: 0;
}
.markdown dl dt > *:last-child {
margin-bottom: 0;
}
.markdown dl dd {
margin: 0 0 15px;
padding: 0 15px;
}
.markdown dl dd > *:first-child {
margin-top: 0;
}
.markdown dl dd > *:last-child {
margin-bottom: 0;
}
.markdown blockquote {
border-left: 4px solid #dddddd;
color: #777777;
padding: 0 15px;
}
.markdown blockquote > *:first-child {
margin-top: 0;
}
.markdown blockquote > *:last-child {
margin-bottom: 0;
}
.markdown table {
width: 100%;
}
.markdown table th {
font-weight: bold;
}
.markdown table th,
.markdown table td {
padding: 6px 13px;
}
.markdown table tr {
// background-color: #ffffff;
}
.markdown table tr:nth-child(2n) {
// background-color: #f0f0f0;
}
.markdown img {
max-width: 100%;
}
.markdown span.frame {
display: block;
overflow: hidden;
}
.markdown span.frame > span {
// border: 1px solid #dddddd;
display: block;
float: left;
margin: 13px 0 0;
overflow: hidden;
padding: 7px;
width: auto;
}
.markdown span.frame span img {
display: block;
float: left;
}
.markdown span.frame span span {
clear: both;
color: #333333;
display: block;
padding: 5px 0 0;
}
.markdown span.align-center {
clear: both;
display: block;
overflow: hidden;
}
.markdown span.align-center > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: center;
}
.markdown span.align-center span img {
margin: 0 auto;
text-align: center;
}
.markdown span.align-right {
clear: both;
display: block;
overflow: hidden;
}
.markdown span.align-right > span {
display: block;
margin: 13px 0 0;
overflow: hidden;
text-align: right;
}
.markdown span.align-right span img {
margin: 0;
text-align: right;
}
.markdown span.float-left {
display: block;
float: left;
margin-right: 13px;
overflow: hidden;
}
.markdown span.float-left span {
margin: 13px 0 0;
}
.markdown span.float-right {
display: block;
float: right;
margin-left: 13px;
overflow: hidden;
}
.markdown span.float-right > span {
display: block;
margin: 13px auto 0;
overflow: hidden;
text-align: right;
}
.markdown code,
.markdown tt {
// border: 1px solid #dee0e2;
// background-color: #f4f6f8;
border-radius: 3px;
margin: 0 2px;
padding: 0 5px;
}
.markdown pre > code {
background: none repeat scroll 0 0 transparent;
border: medium none;
margin: 0;
padding: 0;
}
.markdown .highlight pre,
.markdown pre {
// border: 1px solid #cccccc;
border-radius: 3px 3px 3px 3px;
font-size: max(0.9em, 14px);
line-height: 19px;
overflow: auto;
padding: 6px 10px;
}
.markdown pre code,
.markdown pre tt {
background-color: transparent;
border: medium none;
}
.markdown hr {
margin: 10px 0;
}
.markdown {
tab-size: 4;
word-spacing: normal;
width: 100%;
* {
word-break: break-word;
}
pre {
display: block;
width: 100%;
padding: 15px;
margin: 0;
border: none;
border-radius: 0;
background-color: #292b33 !important;
overflow-x: auto;
color: #fff;
}
pre code {
background-color: #292b33 !important;
width: 100%;
}
a {
text-decoration: underline;
}
table {
border-collapse: separate;
border-spacing: 0px;
color: var(--chakra-colors-gray-700);
thead tr:first-child th {
border-bottom-width: 1px;
border-left-width: 1px;
border-top-width: 1px;
// border-color: #ccc;
background-color: rgba(236, 236, 241, 0.2);
overflow: hidden;
&:first-child {
border-top-left-radius: 0.375rem;
}
&:last-child {
border-right-width: 1px;
border-top-right-radius: 0.375rem;
}
}
td {
border-bottom-width: 1px;
border-left-width: 1px;
// border-color: #ccc;
&:last-of-type {
border-right-width: 1px;
}
}
tbody tr:last-child {
overflow: hidden;
td {
&:first-child {
border-bottom-left-radius: 0.375rem;
}
&:last-child {
border-bottom-right-radius: 0.375rem;
}
}
}
}
}
.mermaid {
overflow-x: auto;
}
@@ -0,0 +1,32 @@
import React from 'react';
import ReactMarkdown, { Components } from 'react-markdown';
import remarkGfm from 'remark-gfm';
import styles from './index.module.scss';
interface MarkdownProps {
content: string;
}
const Markdown: React.FC<MarkdownProps> = ({ content }) => {
// 使用正确的类型来确保与预期的ReactMarkdown组件兼容
const components: Components = {
// @ts-ignore
a: ({ node, ...props }) => ( // eslint-disable-line
<a {...props} target="_blank" rel="noopener noreferrer">
{props.children}
</a>
),
};
return (
<ReactMarkdown
className={styles.markdown}
remarkPlugins={[remarkGfm]}
components={components}
>
{content}
</ReactMarkdown>
);
};
export default React.memo(Markdown);
+92
View File
@@ -0,0 +1,92 @@
import React from 'react';
import {
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalCloseButton,
ModalContentProps,
Box,
Image,
} from '@chakra-ui/react';
export interface MyModalProps extends ModalContentProps {
iconSrc?: string;
title?: any;
isCentered?: boolean;
isOpen: boolean;
onClose?: () => void;
}
const MyModal = ({
isOpen,
onClose,
iconSrc,
title,
children,
isCentered,
w = 'auto',
maxW = ['90vw', '600px'],
...props
}: MyModalProps) => {
return (
<Modal
isOpen={isOpen}
onClose={() => onClose && onClose()}
autoFocus={false}
isCentered={isCentered}
>
<ModalOverlay />
<ModalContent
w={w}
minW={['90vw', '400px']}
maxW={maxW}
position={'relative'}
maxH={'85vh'}
{...props}
>
{!title && onClose && <ModalCloseButton zIndex={1} />}
{!!title && (
<ModalHeader
display={'flex'}
alignItems={'center'}
fontWeight={500}
background={'myBackground.100'}
color={'myText.500'}
py={'10px'}
>
{iconSrc && (
<>
<Image
mr={3}
objectFit={'contain'}
alt=""
src={iconSrc}
w={'20px'}
/>
</>
)}
{title}
<Box flex={1} />
{onClose && (
<ModalCloseButton position={'relative'} top={0} right={0} />
)}
</ModalHeader>
)}
<Box
overflow={props.overflow || 'overlay'}
h={'100%'}
display={'flex'}
flexDirection={'column'}
bg={'myBackground.100'}
color={'myText.500'}
>
{children}
</Box>
</ModalContent>
</Modal>
);
};
export default MyModal;
@@ -0,0 +1,77 @@
import React, { useRef, useState, useEffect } from 'react';
import { Box, Textarea, TextareaProps, Text } from '@chakra-ui/react';
type Props = TextareaProps & {
title?: string;
};
const Editor = React.memo(function Editor({
textareaRef,
maxLength,
onChange,
value,
...props
}: Props & {
textareaRef: React.RefObject<HTMLTextAreaElement>;
onOpenModal?: () => void;
}) {
// @ts-ignore
const [currentLength, setCurrentLength] = useState(value?.length || 0);
// 处理文本变化的函数
const handleChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setCurrentLength(e.target.value.length); // 更新当前文本长度
if (onChange) {
onChange(e); // 如果有外部传入的onChange事件处理器,也调用它
}
};
// 处理初始值的情况
useEffect(() => {
if (value) {
// @ts-ignore
setCurrentLength(value.length);
}
}, [value]);
return (
<Box h={'100%'} w={'100%'} position={'relative'}>
<Textarea
ref={textareaRef}
maxW={'100%'}
maxLength={maxLength}
onChange={handleChange} // 使用自定义的handleChange来更新文本长度
value={value} // 确保Textarea显示的是传入的value
{...props}
/>
{maxLength && ( // 当设置了maxLength时,展示当前字符数和最大允许字符数
<Text fontSize="sm" position="absolute" right="4" bottom="4">
{`${currentLength}/${maxLength}`}
</Text>
)}
</Box>
);
});
const MyTextarea = React.forwardRef<HTMLTextAreaElement, Props>(
function MyTextarea(props, ref) {
const TextareaRef = useRef<HTMLTextAreaElement>(null);
// 将外部传入的ref绑定到Textarea上,确保外部能通过ref控制Textarea
useEffect(() => {
if (typeof ref === 'function') {
ref(TextareaRef.current);
} else if (ref) {
ref.current = TextareaRef.current;
}
}, [ref]);
return (
<>
<Editor textareaRef={TextareaRef} {...props} />
</>
);
},
);
export default React.memo(MyTextarea);
@@ -0,0 +1,35 @@
import React from 'react';
import { Tooltip, TooltipProps } from '@chakra-ui/react';
interface Props extends TooltipProps {
forceShow?: boolean;
}
const MyTooltip = ({
children,
shouldWrapChildren = true,
...props
}: Props) => {
return (
<Tooltip
className="tooltip"
bg={'myBackground.100'}
arrowShadowColor={' rgba(0,0,0,0.05)'}
hasArrow
arrowSize={12}
offset={[-15, 15]}
color={'myText.500'}
px={4}
py={2}
borderRadius={'8px'}
whiteSpace={'pre-wrap'}
boxShadow={'1px 1px 10px rgba(0,0,0,0.2)'}
shouldWrapChildren={shouldWrapChildren}
{...props}
>
{children}
</Tooltip>
);
};
export default MyTooltip;
@@ -0,0 +1,52 @@
import React, { ReactNode } from 'react';
import { Box, Flex, Spinner, BoxProps } from '@chakra-ui/react';
interface PageContainerProps extends BoxProps {
children?: ReactNode;
isLoading?: boolean;
text?: string;
insertProps?: BoxProps;
}
const PageContainer: React.FC<PageContainerProps> = ({
children,
isLoading = false,
text = '',
insertProps = {},
...props
}) => {
return (
<Box h={'100%'} p={[0, 5]} px={[0, 6]} position={'relative'} {...props}>
<Box h={'100%'} overflow={'overlay'} {...insertProps}>
{children}
</Box>
{isLoading && (
<Flex
position={'absolute'}
zIndex={1000}
top={0}
left={0}
right={0}
bottom={0}
alignItems={'center'}
justifyContent={'center'}
flexDirection={'column'}
>
<Spinner
thickness="4px"
speed="0.65s"
color="myPrimary.200"
size="xl"
/>
{text && (
<Box mt={2} fontWeight={'bold'}>
{text}
</Box>
)}
</Flex>
)}
</Box>
);
};
export default PageContainer;
@@ -0,0 +1,140 @@
import React, { useEffect, useState } from 'react';
import {
Button,
VStack,
Text,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
AlertDialog,
AlertDialogBody,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogContent,
AlertDialogOverlay,
useDisclosure,
} from '@chakra-ui/react';
import { useWebSocketContext } from '../../hooks/useWebSocketContext';
import { useSystemStore } from '../../stores/useSystemStore';
const SystemCheck = () => {
const [humanTaskMsg, setHumanTaskMsg] = useState<string>('');
const { isOpen, onOpen, onClose } = useDisclosure();
const [isModalOpen, setIsModalOpen] = useState(false);
const cancelRef = React.useRef<any>();
const { registerEventHandler, acknowledgeMessage } = useWebSocketContext();
const { setDriverSettings, driverSettings } = useSystemStore();
useEffect(() => {
const unregister = registerEventHandler((message) => {
if (message.message === 'chrome_download') {
acknowledgeMessage('chrome_download', message.event_id);
setIsModalOpen(true);
} else if (message.message === 'human_task') {
if (!message.data) {
return;
}
window.electron.ipcRenderer.sendMessage(
'notification',
'警告',
'有需要人工处理的消息,请手动处理,注意处理完成后请取消暂停勾选。',
);
setDriverSettings({
...driverSettings,
isPaused: true,
});
const data = message.data as {
message: string;
value: string;
type: string;
};
acknowledgeMessage('human_task', message.event_id);
if (data.type === 'strategy') {
setHumanTaskMsg(data.message);
onOpen();
} else if (data.type === 'system') {
setHumanTaskMsg(data.message);
onOpen();
}
}
});
// 组件卸载时注销事件处理器
return () => unregister();
}, [registerEventHandler, acknowledgeMessage]); // eslint-disable-line
const confirmDownload = () => {
window.electron.ipcRenderer.sendMessage(
'open-url',
'https://www.google.cn/chrome/',
);
};
useEffect(() => {
const version = window.electron.ipcRenderer.get('get-browser-version');
if (!version) {
setIsModalOpen(true);
}
}, []);
return (
<>
<Modal isOpen={isModalOpen} onClose={() => setIsModalOpen(false)}>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody>
Chrome Chrome
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={confirmDownload}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
<AlertDialog
isOpen={isOpen}
leastDestructiveRef={cancelRef}
onClose={onClose}
>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
</AlertDialogHeader>
<AlertDialogBody>
<VStack>
<Text></Text>
<Text>{humanTaskMsg}</Text>
<Text>
</Text>
</VStack>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={onClose}>
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
</>
);
};
export default SystemCheck;
+83
View File
@@ -0,0 +1,83 @@
import React, { useEffect, useState } from 'react';
import {
Box,
Button,
Text,
Modal,
ModalOverlay,
ModalContent,
ModalHeader,
ModalFooter,
ModalBody,
ModalCloseButton,
} from '@chakra-ui/react';
import { getVersionInfo } from '../../services/system/controller';
import Markdown from '../Markdown';
const Updater = () => {
const [isUpdateModalOpen, setIsUpdateModalOpen] = useState(false);
const [updateInfo, setUpdateInfo] = useState<{
version: string;
url: string;
description: string;
} | null>(null);
const [currentVersion, setCurrentVersion] = useState('');
useEffect(() => {
(async () => {
const cv = window.electron.ipcRenderer.get('get-version');
const info = await getVersionInfo(cv);
if (info) {
setUpdateInfo(info);
setCurrentVersion(cv);
setIsUpdateModalOpen(true);
}
})();
}, []);
// 确认更新则跳转到下载链接
const confirmUpdate = () => {
if (updateInfo) {
window.electron.ipcRenderer.sendMessage('open-url', updateInfo.url);
}
};
return (
<>
<Modal
isOpen={isUpdateModalOpen}
onClose={() => setIsUpdateModalOpen(false)}
>
<ModalOverlay />
<ModalContent>
<ModalHeader></ModalHeader>
<ModalCloseButton />
<ModalBody>
{updateInfo && (
<>
<Text>
{currentVersion} {' '}
{updateInfo?.version}
</Text>
<Box mt="20px">
<Markdown content={updateInfo?.description} />
</Box>
</>
)}
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={confirmUpdate}>
</Button>
<Button variant="ghost" onClick={() => setIsUpdateModalOpen(false)}>
</Button>
</ModalFooter>
</ModalContent>
</Modal>
</>
);
};
export default React.memo(Updater);
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import { Box, Text, Stack, useColorModeValue } from '@chakra-ui/react';
const Footer = () => {
const bg = useColorModeValue('gray.100', 'gray.900');
return (
<Box
as="footer"
role="contentinfo"
maxW="7xl"
py="3"
px={{ base: '4', md: '8' }}
bg={bg}
>
<Stack>
<Text fontSize="sm" alignSelf={{ base: 'center' }}>
&copy; {new Date().getFullYear()} lrhh123. All rights reserved.
</Text>
</Stack>
</Box>
);
};
export default Footer;
+86
View File
@@ -0,0 +1,86 @@
import React from 'react';
import { useNavigate } from 'react-router-dom'; // 导入 Link 组件
import {
Box,
Flex,
Text,
IconButton,
Menu,
MenuButton,
MenuList,
MenuItem,
} from '@chakra-ui/react';
import {
HamburgerIcon,
SettingsIcon,
InfoOutlineIcon,
ChatIcon,
CalendarIcon,
} from '@chakra-ui/icons';
import { FiAirplay } from 'react-icons/fi';
const Navbar = () => {
const navigate = useNavigate();
const handleNavigate = (path: string) => () => {
navigate(path);
};
return (
<Flex
as="nav"
align="center"
justify="space-between"
wrap="wrap"
padding="1rem"
position="fixed"
width="100%"
top="0"
bg={'white'}
zIndex="1000"
height="60px"
>
<Text as="h1" fontSize="2em" letterSpacing="tighter" className="font-zh">
</Text>
<Box display={{ md: 'none' }}>
<Menu>
<MenuButton
as={IconButton}
icon={<HamburgerIcon />}
variant="outline"
/>
<MenuList mb={2}>
{/* 直接在MenuItem上使用onClick进行导航 */}
<MenuItem icon={<ChatIcon />} onClick={handleNavigate('/')}>
</MenuItem>
<MenuItem
icon={<FiAirplay />}
onClick={handleNavigate('/platforms')}
>
</MenuItem>
<MenuItem icon={<CalendarIcon />} onClick={handleNavigate('/msg')}>
</MenuItem>
<MenuItem
icon={<SettingsIcon />}
onClick={handleNavigate('/settings')}
>
</MenuItem>
<MenuItem
icon={<InfoOutlineIcon />}
onClick={handleNavigate('/about')}
>
</MenuItem>
</MenuList>
</Menu>
</Box>
</Flex>
);
};
export default React.memo(Navbar);
+13
View File
@@ -0,0 +1,13 @@
import { useToast as uToast, UseToastOptions } from '@chakra-ui/react';
export const useToast = (props?: UseToastOptions) => {
const toast = uToast({
position: 'top',
duration: 2000,
...(props && props),
});
return {
toast,
};
};
@@ -0,0 +1,94 @@
import React, {
createContext,
useContext,
useEffect,
ReactNode,
useMemo,
useState,
useCallback,
} from 'react';
import useWebSocket from 'react-use-websocket';
// 定义WebSocket消息类型
interface WebSocketMessage {
message: string;
data: any;
event_id: string;
}
// 定义上下文类型
interface WebSocketContextType {
acknowledgeMessage: (event: string, eventId: string) => void;
registerEventHandler: (
handler: (message: WebSocketMessage) => void,
) => () => void;
}
const WebSocketContext = createContext<WebSocketContextType | null>(null);
export const WebSocketProvider = ({ children }: { children: ReactNode }) => {
const { lastMessage, sendMessage } = useWebSocket(
`ws://127.0.0.1:${window.electron.getPort()}/api/v1/event/ws`,
{
shouldReconnect: () => {
return true; // 总是尝试重连
},
reconnectInterval: 3000,
},
);
const [eventHandlers, setEventHandlers] = useState<
((message: WebSocketMessage) => void)[]
>([]);
const acknowledgeMessage = useCallback(
(event: string, eventId: string) => {
sendMessage(
JSON.stringify({
type: 'ack',
event_type: event,
event_id: eventId,
}),
);
},
[sendMessage],
);
// 注册事件处理器
const registerEventHandler = useCallback(
(handler: (message: WebSocketMessage) => void) => {
setEventHandlers((prevHandlers) => [...prevHandlers, handler]);
// 返回注销该处理器的函数
return () => {
setEventHandlers((prevHandlers) =>
prevHandlers.filter((h) => h !== handler),
);
};
},
[],
);
useEffect(() => {
if (lastMessage !== null) {
const message: WebSocketMessage = JSON.parse(lastMessage.data);
eventHandlers.forEach((handler) => handler(message));
}
}, [lastMessage, eventHandlers]);
const value = useMemo(
() => ({
acknowledgeMessage,
registerEventHandler,
}),
[acknowledgeMessage, registerEventHandler],
);
return (
<WebSocketContext.Provider value={value}>
{children}
</WebSocketContext.Provider>
);
};
// Hook to use WebSocket context
export const useWebSocketContext = () =>
useContext(WebSocketContext) as WebSocketContextType;
+14
View File
@@ -0,0 +1,14 @@
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta
http-equiv="Content-Security-Policy"
content="script-src 'self' 'unsafe-inline'"
/>
<title>懒人客服</title>
</head>
<body>
<div id="root"></div>
</body>
</html>
+14
View File
@@ -0,0 +1,14 @@
import { createRoot } from 'react-dom/client';
import App from './App';
const container = document.getElementById('root') as HTMLElement;
const root = createRoot(container);
root.render(<App />);
// calling IPC exposed from preload script
window.electron.ipcRenderer.once('ipc-example', (arg) => {
// eslint-disable-next-line no-console
console.log(arg);
});
window.electron.ipcRenderer.sendMessage('ipc-example', ['ping']);
+25
View File
@@ -0,0 +1,25 @@
import React from 'react';
import PageContainer from '../../components/PageContainer';
import Markdown from '../../components/Markdown';
const AboutPage: React.FC = () => {
return (
<PageContainer>
<Markdown
content={`
本项目是基于大模型的智能对话客服工具,支持哔哩哔哩、抖音企业号、抖音、抖店、微博聊天、小红书专业号运营、小红书、知乎等平台接入,可选择 GPT3.5/GPT4.0/ [懒人百宝箱](https://chat.lazaytools.top/) (后续会支持更多平台),能处理文本、语音和图片,通过插件访问操作系统和互联网等外部资源,支持基于自有知识库定制企业 AI 应用。
## 使用说明
项目文档: [懒人百宝箱使用说明](https://gitee.com/alsritter/ChatGPT-On-CS)
## 项目地址
* [GitHub](https://github.com/lrhh123/ChatGPT-On-CS)
* [Gitee](https://gitee.com/alsritter/ChatGPT-On-CS) (国内用户推荐)
`}
/>
</PageContainer>
);
};
export default AboutPage;
@@ -0,0 +1,19 @@
import React from 'react';
import { Center, Text } from '@chakra-ui/react';
import Loader from '../../components/Loader';
const FullScreenLoader = () => {
return (
<Center h="100vh" w="100vw" bg="white" flexDirection="column">
<Text fontSize="lg" mt="4" zIndex={10} pb={40}>
...
</Text>
<Text fontSize="sm" zIndex={10}>
()
</Text>
<Loader />
</Center>
);
};
export default React.memo(FullScreenLoader);
+111
View File
@@ -0,0 +1,111 @@
import React, { useEffect } from 'react';
import {
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Checkbox,
Stack,
HStack,
Tooltip,
} from '@chakra-ui/react';
import { updateRunner } from '../../services/platform/controller';
import { useSystemStore } from '../../stores/useSystemStore';
import { useToast } from '../../hooks/useToast';
import LogBox from './LogBox';
const DriverSettings = () => {
const { toast } = useToast();
const { driverSettings, setDriverSettings } = useSystemStore();
useEffect(() => {
window.electron.ipcRenderer.on('refresh-config', () => {
const { isPaused, isKeywordMatch } = driverSettings;
(async () => {
try {
await updateRunner(isPaused, isKeywordMatch);
} catch (error: any) {
console.error(error);
}
})();
});
return () => {
window.electron.ipcRenderer.remove('refresh-config');
};
}, [driverSettings, toast]);
const handleFormChange = (field: string) => (event: any) => {
const { value, checked, type } = event.target;
const newDriverSettings = {
...driverSettings,
[field]: type === 'checkbox' ? checked : value,
};
setDriverSettings(newDriverSettings);
if (field === 'isPaused') {
if (checked) {
toast({
title: '已经暂停自动回复功能',
status: 'warning',
});
} else {
toast({
title: '已经开启自动回复功能',
status: 'success',
});
}
updateRunner(checked, driverSettings.isKeywordMatch);
} else if (field === 'isKeywordMatch') {
updateRunner(driverSettings.isPaused, checked);
}
};
return (
<Tabs>
<TabList>
<Tab></Tab>
<Tab></Tab>
</TabList>
<TabPanels>
<TabPanel>
<Stack spacing={4}>
<HStack width="full" alignItems="center">
<Checkbox
mr={4}
isChecked={driverSettings.isPaused}
onChange={handleFormChange('isPaused')}
>
<Tooltip label="暂停软件后,将不再自动回复消息">
</Tooltip>
</Checkbox>
<Checkbox
isChecked={driverSettings.isKeywordMatch}
onChange={handleFormChange('isKeywordMatch')}
>
<Tooltip label="将优先匹配关键词,未匹配的才去调用 GPT 接口">
</Tooltip>
</Checkbox>
</HStack>
</Stack>
</TabPanel>
<TabPanel>
<Stack spacing={4}>
<LogBox />
</Stack>
</TabPanel>
</TabPanels>
</Tabs>
);
};
export default DriverSettings;
+398
View File
@@ -0,0 +1,398 @@
import React, { useState, useEffect } from 'react';
import {
Button,
Input,
ModalBody,
ModalFooter,
Stack,
Text,
Icon,
HStack,
Box,
Switch,
Select,
IconButton,
Tooltip,
Flex,
useToast,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { FiHelpCircle } from 'react-icons/fi';
import { AddIcon, DeleteIcon, AttachmentIcon } from '@chakra-ui/icons';
import {
getPlatformList,
addReplyKeyword,
updateReplyKeyword,
} from '../../services/platform/controller';
import MyTextarea from '../../components/MyTextarea';
import Markdown from '../../components/Markdown';
import MyModal from '../../components/MyModal';
import { Reply, Platform } from '../../services/platform/platform';
interface EditKeywordProps {
editKeyword: Reply | null;
isOpen: boolean;
onClose: () => void;
handleEdit: () => void;
}
const EditKeyword = ({
editKeyword,
isOpen,
onClose,
handleEdit,
}: EditKeywordProps) => {
const toast = useToast();
const [keywords, setKeywords] = useState<string[]>(
editKeyword?.keyword.split('|') || [],
);
const [replyList, setReplyList] = useState<string[]>(
editKeyword?.reply.split('[or]') || [],
);
const [isGlobal, setIsGlobal] = useState<boolean>(false);
const [ptf, setPtf] = useState<string>(editKeyword?.platform_id || '');
const [newKeyword, setNewKeyword] = useState<string>('');
const [newReply, setNewReply] = useState<string>('');
const [startKeyword, setStartKeyword] = useState<string>('');
const [endKeyword, setEndKeyword] = useState<string>('');
const [currentPlatform, setCurrentPlatform] = useState<Platform | undefined>(
undefined,
);
useEffect(() => {
if (!editKeyword?.keyword) {
setKeywords([]);
} else {
setKeywords(editKeyword?.keyword.split('|') || []);
}
if (!editKeyword?.reply) {
setReplyList([]);
} else {
setReplyList(editKeyword?.reply.split('[or]') || []);
}
setPtf(editKeyword?.platform_id || '');
if (editKeyword) {
setIsGlobal(editKeyword.platform_id === '');
}
}, [editKeyword]);
const { data: platforms, isLoading: isPlatformsLoading } = useQuery(
['platformList'],
getPlatformList,
);
useEffect(() => {
if (ptf) {
setCurrentPlatform(
platforms?.data.find((platform) => platform.id === ptf),
);
}
}, [platforms, ptf]);
const handleAddKeyword = () => {
if (newKeyword) {
setKeywords([...keywords, newKeyword]);
setNewKeyword('');
}
};
const handleAddFuzzyKeyword = () => {
if (startKeyword && endKeyword) {
const fuzzyKeyword = `${startKeyword}[and]${endKeyword}`;
setKeywords([...keywords, fuzzyKeyword]);
setStartKeyword('');
setEndKeyword('');
}
};
const handleDeleteKeyword = (index: number) => {
setKeywords(keywords.filter((_, i) => i !== index));
};
const handleAddReply = () => {
if (newReply) {
setReplyList([...replyList, newReply]);
setNewReply('');
}
};
const handleDeleteReply = (index: number) => {
setReplyList(replyList.filter((_, i) => i !== index));
};
const handleInsertRandomChar = () => {
setNewReply(`${newReply}[~]`);
};
const handleInsertFile = () => {
window.electron.ipcRenderer.sendMessage('select-file');
window.electron.ipcRenderer.once('selected-file', (path) => {
const selectedPath = path as string[];
if (!selectedPath.length || !selectedPath[0]) return;
setReplyList([...replyList, `[@]${selectedPath[0]}[/@]`]);
});
};
const handleKeywordClick = (keyword: string, index: number) => {
if (keyword.includes('[and]')) {
const parts = keyword.split('[and]');
setStartKeyword(parts[0]);
setEndKeyword(parts[1]);
} else {
setNewKeyword(keyword);
}
handleDeleteKeyword(index);
};
const handleReplyClick = (item: string, index: number) => {
setNewReply(item);
handleDeleteReply(index);
};
const handleSave = async () => {
try {
const updatedReply = {
...editKeyword,
keyword: keywords.join('|'),
reply: replyList.join('[or]'),
};
if (!isGlobal) {
updatedReply.platform_id = ptf;
}
if (updatedReply.keyword === '') {
throw new Error('关键词不能为空');
}
if (updatedReply.reply === '') {
throw new Error('回复内容不能为空');
}
if (updatedReply.id) {
await updateReplyKeyword(updatedReply);
} else {
await addReplyKeyword(updatedReply);
}
handleEdit();
} catch (error: any) {
toast({
title: error.message,
status: 'error',
duration: 3000,
isClosable: true,
});
}
};
return (
<MyModal
isOpen={isOpen}
onClose={onClose}
title={editKeyword?.id ? '编辑关键词' : '新增关键词'}
>
<ModalBody>
<HStack width="full" alignItems="center" my={3}>
<Box width="30%">
<Flex mt={3}>
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
</Text>
<Tooltip label="该关键词是否面向全部平台,否则请选择一个适用的平台">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
</Box>
<Box width="70%">
<Switch
isChecked={isGlobal}
onChange={() => setIsGlobal(!isGlobal)}
/>
</Box>
</HStack>
{!isGlobal && (
<HStack width="full" alignItems="center" mb={5}>
<Box width="30%">
<Text></Text>
</Box>
<Box width="70%">
<Select
value={ptf || ''}
onChange={(e) => setPtf(e.target.value)}
isDisabled={isPlatformsLoading}
>
{platforms?.data.map((platform) => (
<option key={platform.id} value={platform.id}>
{platform.name}
</option>
))}
</Select>
</Box>
</HStack>
)}
<Flex mb="8px" mt="12px">
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
</Text>
<Tooltip label="设置关键词以匹配具体的回复,命中关键词的问题,不会使用 GPT 进行回答">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
<Stack direction="row" mb="4">
<Input
placeholder="新增关键词"
value={newKeyword}
onChange={(e) => setNewKeyword(e.target.value)}
/>
<Tooltip label="新增一条关键词,可以使用 * 字符模糊匹配,如果要输入 * 字符,则使用 \* 代替">
<Button onClick={handleAddKeyword} colorScheme="green">
<AddIcon />
</Button>
</Tooltip>
</Stack>
<Stack direction="row" mb="4">
<Input
placeholder="开始关键词"
value={startKeyword}
onChange={(e) => setStartKeyword(e.target.value)}
/>
<Input
placeholder="结束关键词"
value={endKeyword}
onChange={(e) => setEndKeyword(e.target.value)}
/>
<Tooltip label="新增一个范围关键词,如果匹配上了开始关键词和结束关键词,也能匹配成功">
<Button onClick={handleAddFuzzyKeyword} colorScheme="green">
<AddIcon />
</Button>
</Tooltip>
</Stack>
<Stack direction="row" spacing={4} wrap="wrap">
{keywords.map((keyword, index) => (
<Text
key={index}
p="1"
borderRadius="md"
borderWidth="1px"
cursor="pointer"
maxWidth="220px"
onClick={() => handleKeywordClick(keyword, index)}
style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}
>
{keyword}
<IconButton
ml={3}
aria-label="Delete keyword"
colorScheme="red"
icon={<DeleteIcon />}
size="xs"
onClick={(e) => {
e.stopPropagation();
handleDeleteKeyword(index);
}}
/>
</Text>
))}
</Stack>
<Flex mb="8px" mt="22px">
<Text mr={2} fontSize={'large'} fontWeight={'bold'}>
</Text>
<Tooltip label="添加的多个关键词只要一个匹配上了,将会触发回复,如果有多个回复,将会随机选择一个回复。">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
{currentPlatform && currentPlatform.desc && (
<Box>
<Markdown content={currentPlatform.desc} />
</Box>
)}
<Tooltip label="在拼多多平台等平台,是不允许每次重复一个回答的,所以可以插入一个随机符,以规避这个问题">
<Button
onClick={handleInsertRandomChar}
mt="4"
mr={4}
colorScheme="teal"
>
</Button>
</Tooltip>
<Tooltip label="有些无法发送文件或者图片的平台无法使用该文件">
<Button
leftIcon={<AttachmentIcon />}
mt="4"
onClick={handleInsertFile}
colorScheme="orange"
>
</Button>
</Tooltip>
<Stack direction="row" mt="4">
<MyTextarea
mb="4"
maxLength={200}
placeholder="回复内容"
value={newReply}
onChange={(e) => setNewReply(e.target.value)}
/>
<Button onClick={handleAddReply} colorScheme="green">
<AddIcon />
</Button>
</Stack>
<Stack direction="row" spacing={4} wrap="wrap">
{replyList.map((item, index) => (
<Text
key={index}
p="1"
borderRadius="md"
borderWidth="1px"
cursor="pointer"
maxWidth="220px"
onClick={() => handleReplyClick(item, index)}
style={{ whiteSpace: 'normal', wordWrap: 'break-word' }}
>
{item}
<IconButton
ml={3}
aria-label="Delete reply"
icon={<DeleteIcon />}
colorScheme="red"
size="xs"
onClick={(e) => {
e.stopPropagation();
handleDeleteReply(index);
}}
/>
</Text>
))}
</Stack>
</ModalBody>
<ModalFooter>
<Button colorScheme="blue" mr={3} onClick={handleSave}>
</Button>
<Button variant="ghost" onClick={onClose}>
</Button>
</ModalFooter>
</MyModal>
);
};
export default EditKeyword;
+78
View File
@@ -0,0 +1,78 @@
import React, { useEffect } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
HStack,
TableContainer,
Text,
Button,
Box,
} from '@chakra-ui/react';
import { useWebSocketContext } from '../../hooks/useWebSocketContext';
import useGlobalStore from '../../stores/useGlobalStore';
const LogBox = () => {
const { logs, clearLogs, addLog } = useGlobalStore();
const { registerEventHandler, acknowledgeMessage } = useWebSocketContext();
useEffect(() => {
const unregister = registerEventHandler((message) => {
if (message.message === 'log_show') {
acknowledgeMessage('log_show', message.event_id);
if (message.data) {
const log = message.data as {
time: string;
content: string;
};
if (log) {
addLog(log);
}
}
}
});
// 组件卸载时注销事件处理器
return () => unregister();
}, [registerEventHandler, acknowledgeMessage]); // eslint-disable-line
const clearLog = () => {
clearLogs();
};
return (
<Box>
<TableContainer maxH={'150px'} overflowY="scroll">
<Table size="sm">
<Thead>
<Tr>
<Th></Th>
<Th>
<HStack width="full">
<Text></Text>
<Button size="sm" onClick={clearLog}>
</Button>
</HStack>
</Th>
</Tr>
</Thead>
<Tbody>
{logs.map((log, index) => (
<Tr key={index}>
<Td>{log.time}</Td>
<Td>{log.content}</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
</Box>
);
};
export default React.memo(LogBox);
+119
View File
@@ -0,0 +1,119 @@
import React, { useEffect, useState } from 'react';
import {
Tabs,
TabList,
Tab,
TabPanels,
TabPanel,
Checkbox,
Stack,
Image,
Box,
Skeleton,
CheckboxGroup,
Grid,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import {
getPlatformList,
updatePlatform,
} from '../../services/platform/controller';
import { Platform } from '../../services/platform/platform';
import {
PlatformTypeMap,
PlatformTypeEnum,
} from '../../services/platform/constant';
import { useSystemStore } from '../../stores/useSystemStore';
import defaultPlatformIcon from '../../../../assets/base/default-platform-icon.png';
import analytics from '../../services/analytics/index_template';
const PlatformTabs = () => {
const { data, isLoading } = useQuery(['platformList'], getPlatformList);
const [firstLoad, setFirstLoad] = useState<boolean>(true);
const { selectedPlatforms, setSelectedPlatforms } = useSystemStore();
// 处理数据加载完毕后的平台分组
const groupedPlatforms = data?.data.reduce(
(acc, platform) => {
const type = String(platform.type) || 'other';
if (!acc[type]) acc[type] = [];
acc[type].push(platform);
return acc;
},
{} as Record<string, Platform[]>,
);
// 第一次加载时需要更新一次后端
useEffect(() => {
if (selectedPlatforms && firstLoad) {
updatePlatform(selectedPlatforms);
setFirstLoad(false);
}
}, [firstLoad, setFirstLoad, selectedPlatforms]);
const handleCheckboxChange = async (selectedIds: string[]) => {
const oldSelectedIds = selectedPlatforms;
setSelectedPlatforms(selectedIds);
await updatePlatform(selectedIds);
analytics.onEvent('$ModifySetting', {
$NewValue: selectedIds,
$OldValue: oldSelectedIds,
$Type: 'platforms',
});
};
if (isLoading) {
return (
<Stack>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
const renderTabPanels = () => {
const types = Object.keys(groupedPlatforms || {});
return types.map((type) => (
<TabPanel key={type}>
<CheckboxGroup
colorScheme="green"
value={selectedPlatforms}
onChange={handleCheckboxChange}
>
<Grid templateColumns="repeat(2, 1fr)" gap={4}>
{groupedPlatforms?.[type]?.map((platform) => (
<Box key={platform.id} display="flex" alignItems="center">
{/* 显示平台图标,如果没有则显示默认图标 */}
<Image
src={platform.avatar || defaultPlatformIcon}
fallbackSrc={defaultPlatformIcon}
boxSize="25px"
marginRight="12px"
/>
<Checkbox value={platform.id} isDisabled={!platform.impl}>
{platform.name}
</Checkbox>
</Box>
))}
</Grid>
</CheckboxGroup>
</TabPanel>
));
};
return (
<Tabs>
<TabList>
{Object.keys(groupedPlatforms || {}).map((type) => (
<Tab key={type}>{PlatformTypeMap[type as PlatformTypeEnum]}</Tab>
))}
</TabList>
<TabPanels>{renderTabPanels()}</TabPanels>
</Tabs>
);
};
export default PlatformTabs;
+195
View File
@@ -0,0 +1,195 @@
import React, { useState, useEffect } from 'react';
import {
Table,
Thead,
Tbody,
Tr,
Th,
Td,
Flex,
TableContainer,
useDisclosure,
Text,
Button,
IconButton,
Box,
Skeleton,
Stack,
Tooltip,
Grid,
} from '@chakra-ui/react';
import { DeleteIcon, AddIcon, EditIcon } from '@chakra-ui/icons';
import { useQuery } from '@tanstack/react-query';
import EditKeyword from './EditKeyword';
import {
getReplyList,
deleteReplyKeyword,
} from '../../services/platform/controller';
import { Reply } from '../../services/platform/platform';
const ReplyKeyword = () => {
const [keywords, setKeywords] = useState<Reply[]>([]);
const [editKeyword, setEditKeyword] = useState<Reply | null>(null);
const { isOpen, onOpen, onClose } = useDisclosure();
const { data, isLoading, refetch } = useQuery(
['replyList'],
() => {
return getReplyList({
page: 1,
pageSize: 100,
ptfId: '',
});
},
{
retry: () => {
return true;
},
retryDelay: () => {
return 1000;
},
},
);
useEffect(() => {
if (data) {
setKeywords(data?.data);
}
}, [data]);
if (isLoading) {
return (
<Stack>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
const handleDoubleClick = (keyword: Reply) => {
setEditKeyword(keyword);
onOpen();
};
const handleEdit = () => {
refetch();
onClose();
};
const handleDelete = async (id: number) => {
await deleteReplyKeyword(id);
refetch();
};
const handleAddKeyword = () => {
const newKeyword: Reply = {
keyword: '',
reply: '',
mode: 'fuzzy',
};
setKeywords([...keywords, newKeyword]);
setEditKeyword(newKeyword);
onOpen();
};
return (
<Box>
<Box display="flex" justifyContent="space-between" mb={2}>
<Text></Text>
<Flex alignItems="center">
{' '}
<Button
leftIcon={<AddIcon />}
color="white"
bgGradient="linear(to-r, teal.500, green.500)"
_hover={{
bgGradient: 'linear(to-r, teal.300, green.300)',
}}
variant="solid"
onClick={handleAddKeyword}
>
</Button>
{/* 在图标旁边添加文案,并设置左边距 */}
</Flex>
</Box>
<TableContainer maxH={'300px'} overflowY="scroll">
<Table variant="striped" size="sm" className="table-tiny">
<Thead>
<Tr>
<Th></Th>
<Th></Th>
<Th></Th>
<Th></Th>
</Tr>
</Thead>
<Tbody>
{keywords.map((keyword) => (
<Tr
sx={{ height: '30px' }}
key={keyword.id}
onDoubleClick={() => handleDoubleClick(keyword)}
>
<Td>{keyword.ptf_name}</Td>
<Td
maxW="80px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{keyword.keyword}
</Td>
<Td
maxW="150px"
whiteSpace="nowrap"
overflow="hidden"
textOverflow="ellipsis"
>
{keyword.reply}
</Td>
<Td>
<Grid templateColumns="repeat(2, 1fr)" gap={2}>
<Tooltip label="删除">
<IconButton
size="xs" // 设置为最小尺寸
fontSize="13px"
colorScheme="red"
aria-label="Delete keyword"
icon={<DeleteIcon />}
onClick={() => keyword.id && handleDelete(keyword.id)}
/>
</Tooltip>
<Tooltip label="编辑">
<IconButton
size="xs" // 设置为最小尺寸
fontSize="13px"
colorScheme="blue"
aria-label="Edit keyword"
icon={<EditIcon />}
onClick={() => {
setEditKeyword(keyword);
onOpen();
}}
/>
</Tooltip>
</Grid>
</Td>
</Tr>
))}
</Tbody>
</Table>
</TableContainer>
<EditKeyword
isOpen={isOpen}
onClose={onClose}
editKeyword={editKeyword}
handleEdit={handleEdit}
/>
</Box>
);
};
export default ReplyKeyword;
+28
View File
@@ -0,0 +1,28 @@
import React, { useEffect } from 'react';
import { Box } from '@chakra-ui/react';
import PageContainer from '../../components/PageContainer';
import PlatformTabs from './PlatformTabs';
import DriverSettings from './DriverSettings';
import ReplyKeyword from './ReplyKeyword';
import analytics from '../../services/analytics/index_template';
const HomePage = () => {
useEffect(() => {
// 页面访问埋点
analytics.onEvent('$PageView', {
$PageName: 'home',
});
}, []);
return (
<PageContainer>
<ReplyKeyword />
<Box mb={4}>
<DriverSettings />
</Box>
<PlatformTabs />
</PageContainer>
);
};
export default HomePage;
+91
View File
@@ -0,0 +1,91 @@
import React from 'react';
import {
Box,
Text,
Image,
Flex,
Collapse,
Button,
useDisclosure,
VStack,
Heading,
Code,
} from '@chakra-ui/react';
import { TriangleDownIcon, TriangleUpIcon } from '@chakra-ui/icons';
import { Message } from '../../services/platform/platform';
const SessionBox = ({
index,
sessionId,
messages,
}: {
index: number;
sessionId: string;
messages: Message[];
}) => {
const { isOpen, onToggle } = useDisclosure();
return (
<Box p={5} shadow="md" borderWidth="1px">
<Flex justify="space-between" align="center">
{/* 左对齐 */}
<VStack align="flex-start">
<Heading as="h6" size="xs" cursor="pointer" onClick={onToggle}>
<Code>{`#${index}`}</Code>
{` ${sessionId}`}
</Heading>
<Text fontSize="sm">
{messages[messages.length - 1].created_at}
</Text>
<Text fontSize="sm">{messages.length}</Text>
<Text fontSize="sm">{messages[0].platform}</Text>
{messages[0].goods_name && (
<Text mt={2}>: {messages[0].goods_name}</Text>
)}
{messages[0].goods_avatar && (
<Image
src={messages[0].goods_avatar}
alt="Goods Avatar"
boxSize="50px"
objectFit="cover"
mt={2}
/>
)}
</VStack>
<Button size="sm" onClick={onToggle}>
{isOpen ? <TriangleDownIcon /> : <TriangleUpIcon />}
</Button>
</Flex>
<Collapse in={isOpen} animateOpacity>
<VStack mt={4} align="stretch">
{messages.map((message) => (
<Box
key={message.id}
p={4}
borderWidth="1px"
borderRadius="lg"
overflow="hidden"
>
<Text fontWeight="bold">
{message.role === 'user' ? '用户' : '客服'}
{message.msg_type === 'text' ? message.content : '图片消息'}
{message.msg_type === 'image' && (
<Image
src={message.content}
alt={message.content}
boxSize="100px"
objectFit="cover"
mt={2}
/>
)}
</Text>
</Box>
))}
</VStack>
</Collapse>
</Box>
);
};
export default React.memo(SessionBox);
+178
View File
@@ -0,0 +1,178 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
VStack,
Skeleton,
Stack,
Select,
HStack,
Input,
} from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import { Search2Icon, CloseIcon } from '@chakra-ui/icons';
import {
getPlatformList,
getMessageList,
} from '../../services/platform/controller';
import SessionBox from './SessionBox';
import analytics from '../../services/analytics/index_template';
import { Message } from '../../services/platform/platform';
// 假设数据通过 props.sessions 传递
const MsgList = () => {
useEffect(() => {
// 页面访问埋点
analytics.onEvent('$PageView', {
$PageName: 'msg_list',
});
}, []);
const [ptf, setPtf] = useState<string>('');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchTerm, setSearchTerm] = useState('');
const [page, setPage] = useState(1); // 新增分页状态
const [sessions, setSessions] = useState<
{
sessionId: string;
messages: Message[];
}[]
>([]); // 新增会话状态
const { data: platforms, isLoading: isPlatformsLoading } = useQuery(
['platformList'],
getPlatformList,
);
const {
data,
isLoading: isMessagesLoading,
isFetching,
refetch,
} = useQuery(
['messageList', page],
() =>
getMessageList({
page,
pageSize: 10,
ptfId: ptf,
keyword: searchTerm,
startTime: startDate,
endTime: endDate,
}),
{
keepPreviousData: true, // 保留旧数据,直到新数据加载完成
},
);
useEffect(() => {
if (data?.data) {
// 参考下面的代码,懒加载取得的数据需要和旧的数据合并在一起重新分组
const newSessions = Object.entries(data.data).map(
([sessionId, messages]) => ({
sessionId,
messages,
}),
);
setSessions((prevSessions) => [...prevSessions, ...newSessions]);
}
}, [data]);
if (isPlatformsLoading || isMessagesLoading) {
return (
<Stack m={10}>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
const handlerSearch = () => {
setPage(1); // 搜索时重置页码
refetch();
};
return (
<Box>
<Box m={3}>
<HStack spacing={4} mt={3} align="stretch">
<Input
placeholder="Start Date (YYYY-MM-DD)"
type="date"
value={startDate}
onChange={(e) => setStartDate(e.target.value)}
/>
<Input
placeholder="End Date (YYYY-MM-DD)"
type="date"
value={endDate}
onChange={(e) => setEndDate(e.target.value)}
/>
</HStack>
<HStack spacing={4} mt={3} align="stretch">
<Select
value={ptf || ''}
onChange={(e) => setPtf(e.target.value)}
isDisabled={isPlatformsLoading}
>
{platforms?.data.map((platform) => (
<option key={platform.id} value={platform.id}>
{platform.name}
</option>
))}
</Select>
<Input
value={searchTerm}
placeholder="Search messages"
onChange={(e) => setSearchTerm(e.target.value)}
/>
<Button colorScheme="orange" variant="solid" onClick={handlerSearch}>
<Search2Icon />
</Button>
{(ptf || searchTerm || startDate || endDate) && (
<Button
onClick={() => {
setPtf('');
setStartDate('');
setEndDate('');
setSearchTerm('');
}}
>
<CloseIcon />
</Button>
)}
</HStack>
</Box>
<VStack spacing={4} mt={5} align="stretch">
{sessions.map((session, index) => (
<SessionBox
key={session.sessionId}
index={index}
sessionId={session.sessionId}
messages={session.messages}
/>
))}
</VStack>
{data?.data && data.total > 0 && (
<VStack spacing={4} mt={5} align="stretch">
<Button
mx={3}
isLoading={isFetching}
colorScheme="orange"
variant="solid"
onClick={() => setPage((prevPage) => prevPage + 1)}
>
</Button>
</VStack>
)}
</Box>
);
};
export default React.memo(MsgList);
+240
View File
@@ -0,0 +1,240 @@
import React, { useState, useEffect } from 'react';
import {
Box,
Button,
FormControl,
FormLabel,
Input,
Textarea,
Text,
Stack,
VStack,
Icon,
Heading,
Switch,
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
Skeleton,
Tooltip,
HStack,
useToast,
} from '@chakra-ui/react';
import { FiHelpCircle } from 'react-icons/fi';
import { useQuery, useMutation } from '@tanstack/react-query';
import {
getPlatformList,
getPlatformSettings,
updatePlatformSettings,
} from '../../services/platform/controller';
import {
Platform,
PlatformSettings as PS,
} from '../../services/platform/platform';
import analytics from '../../services/analytics/index_template';
const PlatformSettings = () => {
useEffect(() => {
// 页面访问埋点
analytics.onEvent('$PageView', {
$PageName: 'platform',
});
}, []);
const toast = useToast();
const { data, isLoading: isPlatformsLoading } = useQuery(
['platformList'],
getPlatformList,
);
const { data: settingsData, isLoading: isSettingsDataLoading } = useQuery(
['platformSettings'],
getPlatformSettings,
);
const updateMutation = useMutation({
mutationFn: async (newSettings: PS) => {
await updatePlatformSettings(newSettings);
},
onSuccess: () => {
toast({
title: '更新成功',
status: 'success',
duration: 3000,
isClosable: true,
});
},
onError: () => {
toast({
title: '更新失败',
status: 'error',
duration: 3000,
isClosable: true,
});
},
});
// 初始化配置
const [settings, setSettings] = useState<{
[key: string]: PS;
}>({});
useEffect(() => {
if (settingsData && data) {
setSettings(
data.data.reduce((acc, platform) => {
let setting = settingsData.data.find(
(item) => item.platform_id === platform.id,
);
if (!setting)
setting = {
platform_id: platform.id,
openai_url: '',
api_key: '',
prompt: '',
active: false,
};
return {
...acc,
[platform.id]: {
openai_url: setting.openai_url,
api_key: setting.api_key,
prompt: setting.prompt,
active: setting.active,
},
};
}, {}),
);
}
}, [settingsData, data]);
const handleInputChange = (id: string, field: string) => (e: any) => {
if (!settings) return;
setSettings({
...settings,
[id]: {
...settings[id],
[field]: e.target.value,
},
});
};
const handleActiveChange = (id: string) => (e: any) => {
if (!settings) return;
setSettings({
...settings,
[id]: {
...settings[id],
active: e.target.checked,
},
});
};
const handleSubmit = (platform: Platform) => (e: any) => {
e.preventDefault();
if (!settings) return;
updateMutation.mutate({
...settings[platform.id],
platform_id: platform.id,
});
};
if (isPlatformsLoading || isSettingsDataLoading) {
return (
<Stack m={10}>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
return (
<Box p={5} shadow="md" borderWidth="1px">
<VStack mb={4} align="flex-start">
<Heading as="h4" size="md" mb={4}>
</Heading>
<Text>使 OpenAI </Text>
</VStack>
<Accordion allowMultiple>
{data &&
settings &&
data.data
.filter((item) => item.impl)
.map((platform) => (
<AccordionItem key={platform.name}>
<h2>
<AccordionButton>
<Box flex="1" textAlign="left">
{platform.name}
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<form onSubmit={handleSubmit(platform)}>
<FormControl display="flex" alignItems="center" mb="3">
<FormLabel htmlFor="active" mb="0">
</FormLabel>
<Switch
id="active"
isChecked={settings[platform.id]?.active}
onChange={handleActiveChange(platform.id)}
/>
</FormControl>
<FormControl isRequired>
<HStack>
<FormLabel>OpenAI </FormLabel>
<Tooltip label="设置后则不使用全局设置的 OpenAI 地址">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</HStack>
<Input
type="url"
value={settings[platform.id]?.openai_url}
onChange={handleInputChange(platform.id, 'openai_url')}
/>
</FormControl>
<FormControl mt={4} isRequired>
<FormLabel></FormLabel>
<Input
value={settings[platform.id]?.api_key}
onChange={handleInputChange(platform.id, 'api_key')}
/>
</FormControl>
<FormControl mt={4}>
<HStack>
<Text></Text>
<Tooltip label="当前涉及到复杂的知识库需求时,可以使用懒人百宝箱或者 FastGPT 这类拓展知识库工具">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</HStack>
<Textarea
value={settings[platform.id]?.prompt}
onChange={handleInputChange(platform.id, 'prompt')}
/>
</FormControl>
<Button mt={4} colorScheme="blue" type="submit">
</Button>
</form>
</AccordionPanel>
</AccordionItem>
))}
</Accordion>
</Box>
);
};
export default React.memo(PlatformSettings);
@@ -0,0 +1,236 @@
import React from 'react';
import { FiHelpCircle, FiFolder } from 'react-icons/fi';
import {
Accordion,
AccordionButton,
AccordionIcon,
AccordionItem,
AccordionPanel,
Box,
Button,
Icon,
Checkbox,
Flex,
FormControl,
FormLabel,
Input,
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
Text,
useToast,
Tooltip,
} from '@chakra-ui/react';
import { useSettings } from './SettingsContext';
const CustomerServiceSettings = () => {
const toast = useToast();
const { customerServiceSettings, setCustomerServiceSettings } = useSettings();
const selectFolderPath = () => {
window.electron.ipcRenderer.sendMessage('select-directory');
window.electron.ipcRenderer.once('selected-directory', (path) => {
const selectedPath = path as string[];
setCustomerServiceSettings({
...customerServiceSettings,
folderPath: selectedPath[0],
});
});
};
const openSelectedFolder = () => {
if (customerServiceSettings.folderPath) {
window.electron.ipcRenderer.sendMessage(
'open-directory',
customerServiceSettings.folderPath,
);
} else {
toast({
title: '未选择文件夹',
description: '请先选择一个文件夹路径。',
status: 'warning',
duration: 5000,
isClosable: true,
});
}
};
return (
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box
flex="1"
textAlign="left"
fontSize={'large'}
fontWeight={'bold'}
>
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<Checkbox
isChecked={customerServiceSettings.extractPhone}
onChange={(e) =>
setCustomerServiceSettings({
...customerServiceSettings,
extractPhone: e.target.checked,
})
}
mr={4}
>
</Checkbox>
<Checkbox
isChecked={customerServiceSettings.extractProduct}
onChange={(e) => {
setCustomerServiceSettings({
...customerServiceSettings,
extractProduct: e.target.checked,
});
}}
mr={4}
>
</Checkbox>
<FormControl mt={3}>
<FormLabel></FormLabel>
<Flex>
<Input
value={customerServiceSettings.folderPath}
isReadOnly
placeholder="选择文件夹路径"
/>
<Button ml={2} onClick={selectFolderPath}>
</Button>
</Flex>
</FormControl>
<Button
onClick={openSelectedFolder}
leftIcon={<FiFolder />}
my={3}
w={'100%'}
>
</Button>
<Text mb="8px">
: {customerServiceSettings.replySpeed}
</Text>
<Slider
min={0}
max={10}
step={0.1}
value={customerServiceSettings.replySpeed}
onChange={(value) =>
setCustomerServiceSettings({
...customerServiceSettings,
replySpeed: value,
})
}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<Flex mt={3}>
<Text mb="8px" mr={3}>
:{' '}
{customerServiceSettings.mergeUnprocessedMessagesCount}
</Text>
<Tooltip label="使用 GPT 回复会指定的消息数量传递给 GPT 去生成下一条回复,数量设置的越大回复的速度越慢">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
<Slider
min={1}
max={20}
step={1}
value={customerServiceSettings.mergeUnprocessedMessagesCount}
onChange={(value) =>
setCustomerServiceSettings({
...customerServiceSettings,
mergeUnprocessedMessagesCount: value,
})
}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
<Flex mt={3}>
<Text mb="8px" mr={3}>
: {customerServiceSettings.manualInterventionInterval}
</Text>
<Tooltip label="多长时间没有回复则通知人工接入,单位秒">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
<Slider
min={10}
max={180}
step={10}
value={customerServiceSettings.manualInterventionInterval}
onChange={(value) =>
setCustomerServiceSettings({
...customerServiceSettings,
manualInterventionInterval: value,
})
}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
{/* <Flex mt={3}>
<Text mb="8px" mr={3}>
关键字触发间隔: {customerServiceSettings.keywordTriggerInterval}秒
</Text>
<Tooltip label="关键字触发间隔">
<Box color={'gray.500'}>
<Icon as={FiHelpCircle} w={6} h={6} />
</Box>
</Tooltip>
</Flex>
<Slider
min={10}
max={180}
step={10}
value={customerServiceSettings.keywordTriggerInterval}
onChange={(value) =>
setCustomerServiceSettings({
...customerServiceSettings,
keywordTriggerInterval: value,
})
}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider> */}
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default CustomerServiceSettings;
+212
View File
@@ -0,0 +1,212 @@
import React from 'react';
import {
Accordion,
AccordionItem,
AccordionButton,
AccordionPanel,
AccordionIcon,
FormControl,
FormLabel,
Switch,
Input,
Select,
Box,
Slider,
SliderFilledTrack,
SliderThumb,
SliderTrack,
HStack,
InputGroup,
InputRightElement,
Button,
} from '@chakra-ui/react';
import { ViewIcon, ViewOffIcon } from '@chakra-ui/icons';
import { useSettings } from './SettingsContext';
const GptSettings = () => {
const { gptSettings, setGptSettings } = useSettings();
const [show, setShow] = React.useState(false);
return (
<Accordion allowToggle>
<AccordionItem>
<h2>
<AccordionButton>
<Box
flex="1"
textAlign="left"
fontSize={'large'}
fontWeight={'bold'}
>
GPT
</Box>
<AccordionIcon />
</AccordionButton>
</h2>
<AccordionPanel pb={4}>
<FormControl display="flex" alignItems="center">
<FormLabel htmlFor="useLazyTools" mb="0">
使
</FormLabel>
<Switch
id="useLazyTools"
isChecked={gptSettings.useLazyTools}
onChange={(e) =>
setGptSettings({
...gptSettings,
useLazyTools: e.target.checked,
})
}
/>
</FormControl>
{!gptSettings.useLazyTools && (
<>
<FormControl>
<FormLabel htmlFor="gptAddress" mt="8px">
GPT
</FormLabel>
<Input
id="gptAddress"
value={gptSettings.gptAddress}
onChange={(e) =>
setGptSettings({
...gptSettings,
gptAddress: e.target.value,
})
}
/>
</FormControl>
<FormControl>
<FormLabel htmlFor="model" mt="8px">
使
</FormLabel>
<Select
id="model"
value={gptSettings.model}
onChange={(e) =>
setGptSettings({
...gptSettings,
model: e.target.value,
})
}
>
<option value="gpt3">GPT-3</option>
<option value="gpt3.5">GPT-3.5</option>
<option value="gpt4">GPT-4</option>
</Select>
</FormControl>
<FormControl>
<FormLabel htmlFor="temperature" mt="8px">
Temperature(): {gptSettings.temperature}
</FormLabel>
<Slider
min={0}
max={1}
step={0.05}
id="temperature"
value={gptSettings.temperature}
onChange={(value) => {
setGptSettings({
...gptSettings,
temperature: value,
});
}}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<FormLabel htmlFor="topP" mt="8px">
Top P(): {gptSettings.topP}
</FormLabel>
<Slider
min={0}
max={1}
step={0.05}
id="topP"
value={gptSettings.topP}
onChange={(value) => {
setGptSettings({
...gptSettings,
topP: value,
});
}}
>
<SliderTrack>
<SliderFilledTrack />
</SliderTrack>
<SliderThumb />
</Slider>
</FormControl>
<FormControl>
<HStack mb="4" mt="8px">
<FormLabel htmlFor="stream" width="30%">
Stream()
</FormLabel>
<Switch
id="stream"
isChecked={gptSettings.stream}
onChange={(e) => {
setGptSettings({
...gptSettings,
stream: e.target.checked,
});
}}
/>
</HStack>
</FormControl>
</>
)}
<FormControl>
<FormLabel htmlFor="apiKey">API Key</FormLabel>
{gptSettings.useLazyTools ? (
<Input
id="lazyKey"
type="password"
value={gptSettings.apiKey}
onChange={(e) =>
setGptSettings({ ...gptSettings, apiKey: e.target.value })
}
/>
) : (
<InputGroup size="md">
<Input
id="apiKey"
pr="4.5rem"
type={show ? 'text' : 'password'}
value={gptSettings.apiKey}
onChange={(e) => {
setGptSettings({ ...gptSettings, apiKey: e.target.value });
}}
placeholder="Enter password"
/>
<InputRightElement width="4.5rem">
<Button
h="1.75rem"
size="sm"
onClick={() => {
setShow(!show);
}}
>
{show ? <ViewIcon /> : <ViewOffIcon />}
</Button>
</InputRightElement>
</InputGroup>
)}
</FormControl>
</AccordionPanel>
</AccordionItem>
</Accordion>
);
};
export default GptSettings;
@@ -0,0 +1,72 @@
import React, {
createContext,
useContext,
useState,
useMemo,
ReactNode,
} from 'react';
import {
CustomerServiceSettingsForm,
GptSettingsForm,
} from '../../services/platform/platform';
// 定义Context的类型
interface SettingsContextType {
customerServiceSettings: CustomerServiceSettingsForm;
setCustomerServiceSettings: (settings: CustomerServiceSettingsForm) => void;
gptSettings: GptSettingsForm;
setGptSettings: (settings: GptSettingsForm) => void;
}
const SettingsContext = createContext<SettingsContextType | undefined>(
undefined,
);
export const SettingsProvider = ({ children }: { children: ReactNode }) => {
const [customerServiceSettings, setCustomerServiceSettings] =
useState<CustomerServiceSettingsForm>({
extractPhone: false,
extractProduct: false,
folderPath: '',
replySpeed: 0,
mergeUnprocessedMessagesCount: 0,
manualInterventionInterval: 0,
});
const [gptSettings, setGptSettings] = useState<GptSettingsForm>({
useLazyTools: false,
gptAddress: '',
apiKey: '',
lazyKey: '',
model: '',
temperature: 0,
topP: 0,
stream: false,
});
// 使用useMemo来记忆Provider的value
const value = useMemo(
() => ({
customerServiceSettings,
setCustomerServiceSettings,
gptSettings,
setGptSettings,
}),
[customerServiceSettings, gptSettings],
);
return (
<SettingsContext.Provider value={value}>
{children}
</SettingsContext.Provider>
);
};
// Hook用于子组件访问Context
export const useSettings = () => {
const context = useContext(SettingsContext);
if (context === undefined) {
throw new Error('useSettings must be used within a SettingsProvider');
}
return context;
};
+105
View File
@@ -0,0 +1,105 @@
import React, { useEffect } from 'react';
import { Box, Button, Stack, useToast, Skeleton } from '@chakra-ui/react';
import { useQuery } from '@tanstack/react-query';
import GptSettings from './GptSettings';
import CustomerServiceSettings from './CustomerServiceSettings';
import { getConfig, updateConfig } from '../../services/platform/controller';
import { useSettings } from './SettingsContext';
import analytics from '../../services/analytics/index_template';
const SettingsPage = () => {
useEffect(() => {
// 页面访问埋点
analytics.onEvent('$PageView', {
$PageName: 'settings',
});
}, []);
const {
customerServiceSettings,
setCustomerServiceSettings,
gptSettings,
setGptSettings,
} = useSettings();
const toast = useToast();
const { data, isLoading } = useQuery(['config'], getConfig);
useEffect(() => {
if (data && !isLoading) {
setGptSettings({
useLazyTools: data.data.use_lazy || false,
gptAddress: data.data.gpt_base_url || '',
model: data.data.gpt_model || '',
temperature: data.data.gpt_temperature || 0.7,
apiKey: data.data.gpt_key || '',
topP: data.data.gpt_top_p || 0.75,
stream: data.data.stream || false,
lazyKey: data.data.lazy_key || '',
});
setCustomerServiceSettings({
extractPhone: data.data.extract_phone || false,
extractProduct: data.data.extract_product || false,
folderPath: data.data.save_path || '',
replySpeed: data.data.reply_speed || 0,
mergeUnprocessedMessagesCount: data.data.merged_message_num || 7,
manualInterventionInterval: data.data.wait_humans_time || 60,
});
}
}, [isLoading, data, setGptSettings, setCustomerServiceSettings]);
const handleSaveSettings = async () => {
try {
await updateConfig({
extract_phone: customerServiceSettings.extractPhone,
extract_product: customerServiceSettings.extractProduct,
save_path: customerServiceSettings.folderPath,
reply_speed: customerServiceSettings.replySpeed,
merged_message_num:
customerServiceSettings.mergeUnprocessedMessagesCount,
wait_humans_time: customerServiceSettings.manualInterventionInterval,
gpt_base_url: gptSettings.gptAddress,
gpt_key: gptSettings.apiKey,
gpt_model: gptSettings.model,
gpt_temperature: gptSettings.temperature,
gpt_top_p: gptSettings.topP,
stream: gptSettings.stream,
use_lazy: gptSettings.useLazyTools,
lazy_key: gptSettings.lazyKey,
});
toast({
title: '保存成功',
status: 'success',
});
} catch (error: any) {
toast({
title: '保存失败',
description: error.message,
status: 'error',
});
}
};
if (isLoading) {
return (
<Stack m={10}>
<Skeleton height="20px" />
<Skeleton height="20px" />
<Skeleton height="20px" />
</Stack>
);
}
return (
<Box p={4}>
<Stack spacing={4}>
<CustomerServiceSettings />
<GptSettings />
<Button colorScheme="blue" onClick={handleSaveSettings}>
</Button>
</Stack>
</Box>
);
};
export default SettingsPage;
+10
View File
@@ -0,0 +1,10 @@
import { ElectronHandler } from '../main/preload';
declare global {
// eslint-disable-next-line no-unused-vars
interface Window {
electron: ElectronHandler;
}
}
export {};
@@ -0,0 +1,14 @@
import agconnect from '@agconnect/api';
import '@agconnect/instance';
import '@hw-hmscore/analytics-web';
const agConnectConfig = {
// TODO: 请替换为您的应用 ID
// 这里使用的是华为的服务用于打点
};
// 初始化分析实例
agconnect.instance().configInstance(agConnectConfig);
const analytics = agconnect.analytics();
export default analytics;
+153
View File
@@ -0,0 +1,153 @@
import axios, {
Method,
InternalAxiosRequestConfig,
AxiosResponse,
} from 'axios';
interface ConfigType {
headers?: { [key: string]: string };
hold?: boolean;
timeout?: number;
}
interface ResponseDataType {
code: number;
message: string;
data: any;
}
/**
* 请求开始
*/
function requestStart(
config: InternalAxiosRequestConfig,
): InternalAxiosRequestConfig {
return config;
}
/**
* 请求成功,检查请求头
*/
function responseSuccess(response: AxiosResponse<ResponseDataType>) {
return response;
}
/**
* 响应数据检查
*/
function checkRes(data: ResponseDataType) {
if (data === undefined) {
return Promise.reject('服务器异常');
}
if (data?.code && (data.code < 200 || data.code >= 400)) {
return Promise.reject(data);
}
return data;
}
/**
* 响应错误
*/
function responseError(err: any) {
if (!err) {
return Promise.reject({ message: '未知错误' });
}
// 检查网络错误(无响应)
if (err.message === 'Network Error') {
return Promise.reject({ message: '服务还在启动,请稍后尝试' });
}
// 检查超时错误
if (err.code === 'ECONNABORTED') {
return Promise.reject({ message: '请求超时,请稍后再试' });
}
// 检查是否有响应体和状态码
if (err.response) {
// 这里可以根据 err.response.status 进行更详细的错误处理
return Promise.reject(err.response.data);
}
// 对于其他类型的错误,直接返回
return Promise.reject(err);
}
/* 创建请求实例 */
const instance = axios.create({
timeout: 60000, // 超时时间
headers: {
'content-type': 'application/json',
'Cache-Control': 'no-cache',
},
});
/* 请求拦截 */
instance.interceptors.request.use(requestStart, (err) => Promise.reject(err));
/* 响应拦截 */
instance.interceptors.response.use(responseSuccess, (err) =>
Promise.reject(err),
);
export function request(
url: string,
data: any,
config: ConfigType,
method: Method,
): any {
/* 去空 */
Object.keys(data).forEach((key) => {
if (data[key] === null || data[key] === undefined) {
delete data[key]; // 如果属性值为 null 或 undefined,则删除该属性
}
});
return instance
.request({
baseURL: `http://127.0.0.1:${window.electron.getPort()}`,
url,
method,
data: ['POST', 'PUT'].includes(method) ? data : null,
params: !['POST', 'PUT'].includes(method) ? data : null,
...config, // custom config
})
.then((res) => checkRes(res.data))
.catch((err) => responseError(err));
}
/**
* api请求方式
* @param {String} url
* @param {Any} params
* @param {Object} config
* @returns
*/
export function GET<T = undefined>(
url: string,
params = {},
config: ConfigType = {},
): Promise<T> {
return request(url, params, config, 'GET');
}
export function POST<T = undefined>(
url: string,
data = {},
config: ConfigType = {},
): Promise<T> {
return request(url, data, config, 'POST');
}
export function PUT<T = undefined>(
url: string,
data = {},
config: ConfigType = {},
): Promise<T> {
return request(url, data, config, 'PUT');
}
export function DELETE<T = undefined>(
url: string,
data = {},
config: ConfigType = {},
): Promise<T> {
return request(url, data, config, 'DELETE');
}
@@ -0,0 +1,15 @@
export enum PlatformTypeEnum {
HOT = 'HOT',
E_COMMERCE = 'E_COMMERCE',
RECRUIT = 'RECRUIT',
OTHER = 'OTHER',
LAW = 'LAW',
}
export const PlatformTypeMap = {
[PlatformTypeEnum.HOT]: '热门',
[PlatformTypeEnum.E_COMMERCE]: '电商',
[PlatformTypeEnum.RECRUIT]: '招聘',
[PlatformTypeEnum.LAW]: '法律咨询',
[PlatformTypeEnum.OTHER]: '其他',
};
@@ -0,0 +1,123 @@
import { Platform, Reply, Config, Message } from './platform';
import { DELETE, GET, POST } from '../common/api/request';
export async function getPlatformList() {
const data = await GET<{
data: Platform[];
}>('/api/v1/base/platform/all');
return data;
}
export async function getActivePlatformList() {
const data = await GET<{
data: Platform[];
}>('/api/v1/base/platform/active');
return data;
}
export async function updatePlatform(ids: string[]) {
await POST('/api/v1/base/platform', ids);
}
export async function updateRunner(isPaused: boolean, isKeywordMatch: boolean) {
await POST('/api/v1/base/runner', {
is_paused: isPaused,
is_keyword_match: isKeywordMatch,
});
}
export async function getReplyList({
page,
pageSize,
ptfId,
}: {
page: number;
pageSize: number;
ptfId?: string;
}) {
const data = await GET<{
total: number;
data: Reply[];
}>('/api/v1/reply/list', {
page,
page_size: pageSize,
platform_id: ptfId,
});
return data;
}
export async function addReplyKeyword(keyword: Reply) {
await POST('/api/v1/reply/create', keyword);
}
export async function updateReplyKeyword(keyword: Reply) {
await POST('/api/v1/reply/update', keyword);
}
export async function deleteReplyKeyword(id: number) {
await DELETE('/api/v1/reply/delete', { id });
}
export async function getConfig() {
const data = await GET<{
data: Config;
}>('/api/v1/base/settings');
return data;
}
export async function updateConfig(config: Config) {
await POST('/api/v1/base/settings', config);
}
export async function getMessageList({
page,
pageSize,
ptfId,
keyword,
startTime,
endTime,
}: {
page: number;
pageSize: number;
ptfId?: string;
keyword?: string;
startTime?: string;
endTime?: string;
}) {
const data = await GET<{
total: number;
data: {
[key: string]: Message[];
};
}>('/api/v1/msg/list', {
page,
page_size: pageSize,
platform_id: ptfId,
keyword,
start_time: startTime,
end_time: endTime,
});
return data;
}
export async function getPlatformSettings() {
const data = await GET<{
data: {
platform_id: string;
openai_url: string;
api_key: string;
prompt: string;
active: boolean;
}[];
}>('/api/v1/base/platform/settings');
return data;
}
export async function updatePlatformSettings(settings: {
openai_url: string;
api_key: string;
prompt: string;
active: boolean;
}) {
await POST('/api/v1/base/platform/settings', settings);
}
+81
View File
@@ -0,0 +1,81 @@
import { PlatformTypeEnum } from './constant';
export interface Platform {
id: string;
name: string;
impl: boolean;
type?: PlatformTypeEnum;
urls?: string[];
avatar?: string;
desc?: string;
}
export interface PlatformSettings {
platform_id: string;
openai_url: string;
api_key: string;
prompt: string;
active: boolean;
}
export interface Reply {
id?: number;
platform_id?: string;
keyword: string;
reply: string;
mode?: 'fuzzy' | 'exact';
ptf_name?: string;
}
export interface Message {
id: number;
username: string;
session_id: number;
role: string;
created_at: string;
content: string;
msg_type: string;
platform_id: string;
platform: string;
goods_avatar: string | null;
goods_name: string | null;
}
export interface Config {
extract_phone: boolean; // 提取手机号
extract_product: boolean; // 提取商品
save_path?: string; // 保存路径
reply_speed: number; // 回复速度
merged_message_num: number; // 合并消息数量
wait_humans_time: number; // 等待人工时间
gpt_base_url?: string; // GPT服务地址
gpt_key?: string; // GPT服务key
gpt_model?: string; // GPT服务模型
gpt_temperature?: number; // GPT服务温度
gpt_top_p?: number; // GPT服务top_p
stream?: boolean; // 是否开启stream
use_lazy?: boolean; // 是否使用懒人百宝箱
lazy_key?: string; // 懒人百宝箱 key
}
export interface CustomerServiceSettingsForm {
extractPhone: boolean;
extractProduct: boolean;
folderPath: string;
replySpeed: number;
mergeUnprocessedMessagesCount: number;
manualInterventionInterval: number;
}
export interface GptSettingsForm {
useLazyTools: boolean;
gptAddress: string;
apiKey: string;
lazyKey: string;
model: string;
temperature: number;
topP: number;
stream: boolean;
}
@@ -0,0 +1,22 @@
import axios from 'axios';
export const getVersionInfo = async (currentVersion: string) => {
let result = null;
try {
const data = await axios.get<{
version: string;
url: string;
description: string;
}>('https://update.wizgadg.top/check-update/chatgpt-on-cs');
result = data.data;
// 检查版本是否需要更新
if (result.version === currentVersion) {
result = null;
}
} catch (error) {
console.error(error);
}
return result;
};
+33
View File
@@ -0,0 +1,33 @@
import { create } from 'zustand';
// 定义你的日志对象类型
interface LogObj {
time: string;
content: string;
}
// 定义 Store 的状态和方法
interface GlobalStore {
logs: LogObj[];
addLog: (log: LogObj) => void;
clearLogs: () => void;
}
// 创建 Store
const useGlobalStore = create<GlobalStore>((set) => ({
logs: [], // 初始日志数组为空
// 添加日志的方法
addLog: (log) =>
set((state) => ({
logs: [...state.logs, log].slice(-50), // 保持日志数组最多 50 条,如果超出则移除最旧的
})),
// 清空日志的方法
clearLogs: () =>
set(() => ({
logs: [],
})),
}));
export default useGlobalStore;
+66
View File
@@ -0,0 +1,66 @@
import { create } from 'zustand';
import { devtools, persist, createJSONStorage } from 'zustand/middleware';
import { immer } from 'zustand/middleware/immer';
const electronStore = {
getItem: (key: string) => {
const value = window.electron.store.get(key);
try {
return JSON.parse(value); // 确保字符串被正确解析为对象
} catch (error) {
return null; // 解析失败时返回null或合理的默认值
}
},
setItem: (key: string, value: any) => {
window.electron.store.set(key, JSON.stringify(value));
},
removeItem: (key: string) => {
window.electron.store.remove(key);
},
};
type State = {
selectedPlatforms: string[];
setSelectedPlatforms: (ids: string[]) => void;
driverSettings: {
isPaused: boolean;
isKeywordMatch: boolean;
};
setDriverSettings: (settings: {
isPaused: boolean;
isKeywordMatch: boolean;
}) => void;
};
export const useSystemStore = create<State>()(
devtools(
persist(
immer((set) => ({
selectedPlatforms: [],
setSelectedPlatforms: (ids: string[]) => {
set((state) => {
state.selectedPlatforms = ids;
});
},
driverSettings: {
isPaused: true,
runMode: 'AUTO_SWITCH',
isKeywordMatch: true,
},
setDriverSettings: (settings) => {
set((state) => {
state.driverSettings = settings;
});
},
})),
{
name: 'globalStore',
storage: createJSONStorage(() => electronStore),
partialize: (state) => ({
selectedPlatforms: state.selectedPlatforms,
driverSettings: state.driverSettings,
}),
},
),
),
);
+164
View File
@@ -0,0 +1,164 @@
const colors = {
transparent: 'transparent',
current: 'currentColor',
black: '#000000',
white: '#FFFFFF',
whiteAlpha: {
50: 'rgba(255, 255, 255, 0.04)',
100: 'rgba(255, 255, 255, 0.06)',
200: 'rgba(255, 255, 255, 0.08)',
300: 'rgba(255, 255, 255, 0.16)',
400: 'rgba(255, 255, 255, 0.24)',
500: 'rgba(255, 255, 255, 0.36)',
600: 'rgba(255, 255, 255, 0.48)',
700: 'rgba(255, 255, 255, 0.64)',
800: 'rgba(255, 255, 255, 0.80)',
900: 'rgba(255, 255, 255, 0.92)',
},
blackAlpha: {
50: 'rgba(0, 0, 0, 0.04)',
100: 'rgba(0, 0, 0, 0.06)',
200: 'rgba(0, 0, 0, 0.08)',
300: 'rgba(0, 0, 0, 0.16)',
400: 'rgba(0, 0, 0, 0.24)',
500: 'rgba(0, 0, 0, 0.36)',
600: 'rgba(0, 0, 0, 0.48)',
700: 'rgba(0, 0, 0, 0.64)',
800: 'rgba(0, 0, 0, 0.80)',
900: 'rgba(0, 0, 0, 0.92)',
},
gray: {
50: '#F7FAFC',
100: '#EDF2F7',
200: '#E2E8F0',
300: '#CBD5E0',
400: '#A0AEC0',
500: '#718096',
600: '#4A5568',
700: '#2D3748',
800: '#1A202C',
900: '#171923',
},
red: {
50: '#FFF5F5',
100: '#FED7D7',
200: '#FEB2B2',
300: '#FC8181',
400: '#F56565',
500: '#E53E3E',
600: '#C53030',
700: '#9B2C2C',
800: '#822727',
900: '#63171B',
},
orange: {
50: '#FFFAF0',
100: '#FEEBC8',
200: '#FBD38D',
300: '#F6AD55',
400: '#ED8936',
500: '#DD6B20',
600: '#C05621',
700: '#9C4221',
800: '#7B341E',
900: '#652B19',
},
yellow: {
50: '#FFFFF0',
100: '#FEFCBF',
200: '#FAF089',
300: '#F6E05E',
400: '#ECC94B',
500: '#D69E2E',
600: '#B7791F',
700: '#975A16',
800: '#744210',
900: '#5F370E',
},
green: {
50: '#F0FFF4',
100: '#C6F6D5',
200: '#9AE6B4',
300: '#68D391',
400: '#48BB78',
500: '#38A169',
600: '#2F855A',
700: '#276749',
800: '#22543D',
900: '#1C4532',
},
teal: {
50: '#E6FFFA',
100: '#B2F5EA',
200: '#81E6D9',
300: '#4FD1C5',
400: '#38B2AC',
500: '#319795',
600: '#2C7A7B',
700: '#285E61',
800: '#234E52',
900: '#1D4044',
},
blue: {
50: '#ebf8ff',
100: '#bee3f8',
200: '#90cdf4',
300: '#63b3ed',
400: '#4299e1',
500: '#3182ce',
600: '#2b6cb0',
700: '#2c5282',
800: '#2a4365',
900: '#1A365D',
},
cyan: {
50: '#EDFDFD',
100: '#C4F1F9',
200: '#9DECF9',
300: '#76E4F7',
400: '#0BC5EA',
500: '#00B5D8',
600: '#00A3C4',
700: '#0987A0',
800: '#086F83',
900: '#065666',
},
purple: {
50: '#FAF5FF',
100: '#E9D8FD',
200: '#D6BCFA',
300: '#B794F4',
400: '#9F7AEA',
500: '#805AD5',
600: '#6B46C1',
700: '#553C9A',
800: '#44337A',
900: '#322659',
},
pink: {
50: '#FFF5F7',
100: '#FED7E2',
200: '#FBB6CE',
300: '#F687B3',
400: '#ED64A6',
500: '#D53F8C',
600: '#B83280',
700: '#97266D',
800: '#702459',
900: '#521B41',
},
};
export default colors;
@@ -0,0 +1,56 @@
import { tableAnatomy } from '@chakra-ui/anatomy';
import { createMultiStyleConfigHelpers } from '@chakra-ui/react';
const { definePartsStyle, defineMultiStyleConfig } =
createMultiStyleConfigHelpers(tableAnatomy.keys);
const variantRounded = definePartsStyle((props) => {
const { colorScheme: c, colorMode } = props;
return {
th: {
'&[data-is-numeric=true]': {
textAlign: 'end',
},
},
td: {
'&[data-is-numeric=true]': {
textAlign: 'end',
},
},
caption: {
color: colorMode === 'light' ? `${c}.600` : `${c}.100`,
},
tbody: {
tr: {
'&:nth-of-type(odd)': {
'th, td': {
borderColor: colorMode === 'light' ? `${c}.100` : `${c}.700`,
},
td: {
background: colorMode === 'light' ? `${c}.100` : `${c}.700`,
},
},
'&:nth-of-type(even)': {
'th, td': {
borderColor: colorMode === 'light' ? `${c}.300` : `${c}.600`,
},
td: {
background: colorMode === 'light' ? `${c}.300` : `${c}.600`,
},
},
},
},
tfoot: {
tr: {
'&:last-of-type': {
th: { borderBottomWidth: 0 },
},
},
},
};
});
export const tableTheme = defineMultiStyleConfig({
variants: { variantRounded },
});
+85
View File
@@ -0,0 +1,85 @@
import { extendTheme } from '@chakra-ui/react';
import colors from './colors';
import { tableTheme } from './foundations/Table';
const theme = extendTheme({
styles: {
global: {
'html, body': {
bg: 'myBackground.100',
fontSize: 'md',
fontWeight: 400,
height: '100%',
},
a: {
color: 'myPrimary.100',
padding: '0',
},
},
},
borders: {
base: '1px solid #E3E3E3',
},
colors: {
// myText: {
// 50: '#E6E6E7 ',
// 100: '#313132',
// 200: '#626263',
// 300: '#929495',
// 400: '#C3C5C6',
// 500: '#F4F6F8',
// 600: '#FEFEFE',
// 700: '#FFFFFF',
// 800: '#FFFFFF',
// 900: '#FFFFFF',
// },
// myBackground: {
// // 从黑色到白色的灰色调色阶
// 50: '#23252D',
// 100: '#2b2e39',
// 200: '#323540',
// 300: '#3a3f4a',
// 400: '#424950',
// 500: '#4A535B',
// 600: '#53606B',
// 700: '#5D6D7E',
// 800: '#687A90',
// 900: '#7488A1',
// },
myPrimary: {
50: '#E6C4A8',
100: '#ec7210',
200: '#f09259',
300: '#f4b2a1',
400: '#F7CFC9',
500: '#F9EDE2',
600: '#FCFBF5',
700: '#FEFDFB',
800: '#FFFFFF',
900: '#FFFFFF',
},
// // 定义边框和分隔线的颜色
// myBorder: {
// 50: '#353A43',
// 100: '#4A5568',
// 200: '#3f454e',
// 300: '#282c32',
// 400: '#1D2026',
// 500: '#131519',
// 600: '#0B0D0F',
// 700: '#050608',
// 800: '#020304',
// 900: '#000000',
// },
},
components: {
Table: tableTheme,
},
});
theme.colors = {
...theme.colors,
...colors,
};
export default theme;
+1
View File
@@ -0,0 +1 @@
declare module 'node-cron';
+18
View File
@@ -0,0 +1,18 @@
{
"compilerOptions": {
"incremental": true,
"target": "es2022",
"module": "commonjs",
"lib": ["dom", "es2022"],
"jsx": "react-jsx",
"strict": true,
"sourceMap": true,
"moduleResolution": "node",
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"resolveJsonModule": true,
"allowJs": true,
"outDir": ".erb/dll"
},
"exclude": ["test", "release/build", "release/app/dist", ".erb/dll"]
}