Compare commits
25 Commits
14f2dad837
...
f912003ca9
| Author | SHA1 | Date | |
|---|---|---|---|
| f912003ca9 | |||
| 0ba8c9a7dc | |||
| 8cda5f6be3 | |||
| 8fc9605242 | |||
| a46510949b | |||
| 037146bf51 | |||
| 5d6bd4f429 | |||
| 6212194f95 | |||
| a74b984d95 | |||
| a76c2235ea | |||
| 5774391bbe | |||
| 310d727321 | |||
| 1d0db5f093 | |||
| 73d0c8c4ba | |||
| a80a3ede46 | |||
| 51822506d7 | |||
| ef891e529a | |||
| 2950b5a5be | |||
| 76a7dc0082 | |||
| 9051df7a37 | |||
| 3de3cca09c | |||
| 742a7a2992 | |||
| 98f21d8aaa | |||
| c2f5f504d3 | |||
| 220b008b90 |
@@ -10,7 +10,7 @@
|
||||
],
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.28.1",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"type": "module",
|
||||
"files": [
|
||||
"dist",
|
||||
@@ -25,6 +25,7 @@
|
||||
"dev:share": "bun --watch src/test/remote-app.ts ",
|
||||
"build:lib": "bun run bun-lib.config.mjs",
|
||||
"postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t",
|
||||
"dev:live": "bun --watch src/test/live-app.ts ",
|
||||
"build": "rimraf dist && bun run bun.config.mjs"
|
||||
},
|
||||
"bin": {
|
||||
@@ -41,28 +42,28 @@
|
||||
}
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/ai": "^0.0.22",
|
||||
"@kevisual/api": "^0.0.26",
|
||||
"@inquirer/prompts": "^8.2.0",
|
||||
"@kevisual/ai": "^0.0.24",
|
||||
"@kevisual/api": "^0.0.44",
|
||||
"@kevisual/load": "^0.0.6",
|
||||
"@kevisual/local-app-manager": "^0.1.32",
|
||||
"@kevisual/logger": "^0.0.4",
|
||||
"@kevisual/query": "0.0.38",
|
||||
"@kevisual/query": "0.0.39",
|
||||
"@kevisual/query-login": "0.0.7",
|
||||
"@kevisual/router": "^0.0.62",
|
||||
"@kevisual/router": "^0.0.70",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@kevisual/use-config": "^1.0.28",
|
||||
"@opencode-ai/plugin": "^1.1.36",
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/node": "^25.0.10",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"@opencode-ai/plugin": "^1.1.49",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/send": "^1.2.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.2",
|
||||
"commander": "^14.0.3",
|
||||
"cross-env": "^10.1.0",
|
||||
"dayjs": "^1.11.19",
|
||||
"dotenv": "^17.2.3",
|
||||
"get-port": "^7.1.0",
|
||||
"inquirer": "^13.2.1",
|
||||
"nanoid": "^5.1.6",
|
||||
"send": "^1.2.1",
|
||||
"supports-color": "^10.2.2",
|
||||
@@ -76,17 +77,17 @@
|
||||
"access": "public"
|
||||
},
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.975.0",
|
||||
"@kevisual/ha-api": "^0.0.6",
|
||||
"@aws-sdk/client-s3": "^3.981.0",
|
||||
"@kevisual/js-filter": "^0.0.5",
|
||||
"@kevisual/oss": "^0.0.16",
|
||||
"@kevisual/oss": "^0.0.19",
|
||||
"@kevisual/video-tools": "^0.0.13",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@opencode-ai/sdk": "^1.1.49",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"lowdb": "^7.0.1",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lru-cache": "^11.2.5",
|
||||
"pm2": "^6.0.14",
|
||||
"unstorage": "^1.17.4"
|
||||
"unstorage": "^1.17.4",
|
||||
"zod": "^4.3.6"
|
||||
}
|
||||
}
|
||||
15
assistant/pnpm-lock.yaml
generated
15
assistant/pnpm-lock.yaml
generated
@@ -84,9 +84,6 @@ importers:
|
||||
get-port:
|
||||
specifier: ^7.1.0
|
||||
version: 7.1.0
|
||||
inquirer:
|
||||
specifier: ^12.6.3
|
||||
version: 12.6.3(@types/node@22.15.29)
|
||||
lodash-es:
|
||||
specifier: ^4.17.21
|
||||
version: 4.17.21
|
||||
@@ -2136,18 +2133,6 @@ snapshots:
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
inquirer@12.6.3(@types/node@22.15.29):
|
||||
dependencies:
|
||||
'@inquirer/core': 10.1.13(@types/node@22.15.29)
|
||||
'@inquirer/prompts': 7.5.3(@types/node@22.15.29)
|
||||
'@inquirer/type': 3.0.7(@types/node@22.15.29)
|
||||
ansi-escapes: 4.3.2
|
||||
mute-stream: 2.0.0
|
||||
run-async: 3.0.0
|
||||
rxjs: 7.8.2
|
||||
optionalDependencies:
|
||||
'@types/node': 22.15.29
|
||||
|
||||
ip-address@9.0.5:
|
||||
dependencies:
|
||||
jsbn: 1.1.0
|
||||
|
||||
@@ -7,7 +7,8 @@ import { AssistantInit, parseHomeArg } from '@/services/init/index.ts';
|
||||
import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts';
|
||||
import { useContextKey } from '@kevisual/use-config/context';
|
||||
import { AssistantQuery } from '@/module/assistant/query/index.ts';
|
||||
|
||||
import { config } from '@/module/config.ts';
|
||||
export { config };
|
||||
const manualParse = parseHomeArg(HomeConfigDir);
|
||||
const _configDir = manualParse.configDir;
|
||||
export const configDir = AssistantInit.detectConfigDir(_configDir);
|
||||
@@ -24,9 +25,15 @@ export const assistantQuery = useContextKey('assistantQuery', () => {
|
||||
return new AssistantQuery(assistantConfig);
|
||||
});
|
||||
|
||||
export const runtime = useContextKey('runtime', () => {
|
||||
type Runtime = {
|
||||
type: 'client' | 'server';
|
||||
isServer?: boolean;
|
||||
}
|
||||
export const runtime: Runtime = useContextKey('runtime', () => {
|
||||
console.log('Runtime detected:', 'isDev:', manualParse.isDev, 'isServer:', manualParse.isServer);
|
||||
return {
|
||||
type: 'client',
|
||||
isServer: manualParse.isServer,
|
||||
};
|
||||
});
|
||||
|
||||
@@ -53,8 +60,9 @@ app.route({
|
||||
description: '获取路由列表',
|
||||
}).define(async (ctx) => {
|
||||
const list = ctx.app.getList((item) => {
|
||||
if (item?.path?.includes('auth') || item?.id?.includes('auth')) return false;
|
||||
if (item?.path?.includes?.('auth') || item?.id?.includes?.('auth')) return false;
|
||||
return true;
|
||||
})
|
||||
console.log('路由列表:', list.length);
|
||||
ctx.body = { list }
|
||||
}).addTo(app);
|
||||
@@ -1,6 +1,7 @@
|
||||
import { program, Command } from '@/program.ts';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
import { parseHomeArg, HomeConfigDir } from '@/module/assistant/config/args.ts';
|
||||
import './reload.ts'
|
||||
const command = new Command('server')
|
||||
.description('启动服务')
|
||||
.option('-d, --daemon', '是否以守护进程方式运行')
|
||||
@@ -28,18 +29,22 @@ const command = new Command('server')
|
||||
shellCommands.push(`-e ${options.interpreter}`);
|
||||
}
|
||||
const basename = _interpreter.split('/').pop();
|
||||
|
||||
const m = parseHomeArg(HomeConfigDir);
|
||||
const cwd = m.isDev ? process.cwd() : m.configDir;
|
||||
console.log('当前工作目录:', cwd);
|
||||
if (basename.includes('bun')) {
|
||||
console.log(`Assistant server shell command: bun src/run-server.ts server ${shellCommands.join(' ')}`);
|
||||
const child = spawnSync(_interpreter, ['src/run-server.ts', ...shellCommands], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: cwd,
|
||||
});
|
||||
} else {
|
||||
console.log(`Assistant server shell command: asst-server ${shellCommands.join(' ')}`);
|
||||
const child = spawnSync('asst-server', shellCommands, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: cwd,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
17
assistant/src/command/asst-server/reload.ts
Normal file
17
assistant/src/command/asst-server/reload.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { program, Command } from '@/program.ts';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
|
||||
const reload = new Command('reload')
|
||||
.description('重载正在运行的 Assistant Server 服务')
|
||||
.action(() => {
|
||||
console.log('正在重载 Assistant Server 服务...');
|
||||
const cwd = 'pm2 restart assistant-server';
|
||||
const child = spawnSync('pm2', ['restart', 'assistant-server'], {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: cwd,
|
||||
});
|
||||
console.log('Assistant Server 服务重载完成。');
|
||||
});
|
||||
|
||||
program.addCommand(reload);
|
||||
@@ -2,7 +2,7 @@ import { program, Command } from '@/program.ts';
|
||||
import { AssistantInit } from '@/services/init/index.ts';
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
import inquirer from 'inquirer';
|
||||
import { confirm } from '@inquirer/prompts';
|
||||
import chalk from 'chalk';
|
||||
|
||||
type InitCommandOptions = {
|
||||
@@ -41,23 +41,17 @@ const removeCommand = new Command('remove')
|
||||
const assistantDir = path.join(configDir, 'assistant-app');
|
||||
|
||||
if (fs.existsSync(assistantDir)) {
|
||||
inquirer
|
||||
.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: `确定要删除助手配置文件吗?\n助手配置文件路径:${assistantDir}`,
|
||||
default: false,
|
||||
},
|
||||
])
|
||||
.then((answers) => {
|
||||
if (answers.confirm) {
|
||||
fs.rmSync(assistantDir, { recursive: true, force: true });
|
||||
console.log(chalk.green('助手配置文件已删除'));
|
||||
} else {
|
||||
console.log(chalk.blue('助手配置文件未删除'));
|
||||
}
|
||||
});
|
||||
confirm({
|
||||
message: `确定要删除助手配置文件吗?\n助手配置文件路径:${assistantDir}`,
|
||||
default: false,
|
||||
}).then((confirmed) => {
|
||||
if (confirmed) {
|
||||
fs.rmSync(assistantDir, { recursive: true, force: true });
|
||||
console.log(chalk.green('助手配置文件已删除'));
|
||||
} else {
|
||||
console.log(chalk.blue('助手配置文件未删除'));
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log(chalk.blue('助手配置文件不存在'));
|
||||
}
|
||||
|
||||
79
assistant/src/command/plugins/install.ts
Normal file
79
assistant/src/command/plugins/install.ts
Normal file
@@ -0,0 +1,79 @@
|
||||
import { program, Command, assistantConfig } from '@/program.ts';
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import { parseHomeArg, HomeConfigDir } from '@/module/assistant/config/args.ts';
|
||||
import { execCommand } from '@/module/npm-install.ts';
|
||||
|
||||
/**
|
||||
* 解析包名,分离出安装包名和配置名称
|
||||
* 例如: @kevisual/cnb/routes -> pkgName: @kevisual/cnb, configName: routes
|
||||
* 例如: react -> pkgName: react, configName: react
|
||||
*/
|
||||
function parsePluginName(name: string): { pkgName: string; configName: string } {
|
||||
if (name.startsWith('@') && name.includes('/')) {
|
||||
const parts = name.split('/');
|
||||
if (parts.length >= 3) {
|
||||
// @scope/package/submodule -> pkgName: @scope/package, configName: @scope/package/submodule
|
||||
return {
|
||||
pkgName: parts.slice(0, 2).join('/'),
|
||||
configName: name,
|
||||
};
|
||||
}
|
||||
}
|
||||
return { pkgName: name, configName: name };
|
||||
}
|
||||
|
||||
const pluginCommand = new Command('plugin');
|
||||
|
||||
const installCommand = new Command('install')
|
||||
.alias('i')
|
||||
.argument('<plugin-name>')
|
||||
.description('安装Routes插件').action(async (name, options) => {
|
||||
const { pkgName, configName } = parsePluginName(name);
|
||||
const m = parseHomeArg(HomeConfigDir);
|
||||
const cwd = m.isDev ? process.cwd() : m.configDir;
|
||||
const shellCommand = `pnpm i ${pkgName} -w`;
|
||||
const result = execCommand(shellCommand, { cwd });
|
||||
if (result.status === 0) {
|
||||
const mount = assistantConfig.checkMounted();
|
||||
const config = assistantConfig.getConfig();
|
||||
const routes = config.routes || [];
|
||||
if (!routes.includes(configName)) {
|
||||
routes.push(configName);
|
||||
config.routes = routes;
|
||||
assistantConfig.setConfig(config);
|
||||
console.log(`插件 ${configName} 安装成功并已添加到配置中。`);
|
||||
} else {
|
||||
console.log(`插件 ${configName} 已存在于配置中。`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const uninstallCommand = new Command('remove')
|
||||
.alias('r')
|
||||
.argument('<plugin-name>')
|
||||
.description('卸载Routes插件').action(async (name, options) => {
|
||||
const { pkgName, configName } = parsePluginName(name);
|
||||
const m = parseHomeArg(HomeConfigDir);
|
||||
const cwd = m.isDev ? process.cwd() : m.configDir;
|
||||
const shellCommand = `pnpm remove ${pkgName} -w`;
|
||||
const result = execCommand(shellCommand, { cwd });
|
||||
assistantConfig.checkMounted();
|
||||
const config = assistantConfig.getConfig();
|
||||
let routes = config.routes || [];
|
||||
// 从配置中移除时,查找匹配的配置名称
|
||||
const index = routes.findIndex(r => r === configName);
|
||||
if (index !== -1) {
|
||||
routes.splice(index, 1);
|
||||
config.routes = routes;
|
||||
assistantConfig.setConfig(config);
|
||||
console.log(`插件 ${configName} 卸载成功并已从配置中移除。`);
|
||||
} else {
|
||||
console.log(`插件 ${configName} 不存在于配置中。`);
|
||||
}
|
||||
});
|
||||
|
||||
pluginCommand.addCommand(uninstallCommand);
|
||||
|
||||
pluginCommand.addCommand(installCommand);
|
||||
|
||||
program.addCommand(pluginCommand);
|
||||
@@ -13,7 +13,11 @@ const createRandomApp = (opts: { app: any, package: any, pwd: string, status?: s
|
||||
}
|
||||
if (!app.key) {
|
||||
const randomSuffix = Math.random().toString(36).substring(2, 8);
|
||||
app.key = packageJson.basename || `${'unknown-app'}-${randomSuffix}`;
|
||||
let appKey: string = packageJson.basename || `${'unknown-app'}-${randomSuffix}`;
|
||||
if (appKey.startsWith('/')) {
|
||||
appKey = appKey.slice(1)
|
||||
}
|
||||
app.key = appKey;
|
||||
}
|
||||
app.path = pwd;
|
||||
if (app.type === 'pm2-system-app' && !app.pm2Options) {
|
||||
|
||||
@@ -5,6 +5,7 @@ import './command/asst-server/index.ts';
|
||||
import './command/app/index.ts';
|
||||
import './command/run-scripts/index.ts';
|
||||
import './command/ai/index.ts';
|
||||
import './command/plugins/install.ts';
|
||||
|
||||
/**
|
||||
* 通过命令行解析器解析参数
|
||||
|
||||
89
assistant/src/module/assistant/config/args.ts
Normal file
89
assistant/src/module/assistant/config/args.ts
Normal file
@@ -0,0 +1,89 @@
|
||||
|
||||
|
||||
import { logger } from '@/module/logger.ts';
|
||||
import path from 'path';
|
||||
import { homedir } from 'os';
|
||||
|
||||
let kevisualDir = path.join(homedir(), 'kevisual');
|
||||
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
|
||||
if (envKevisualDir) {
|
||||
kevisualDir = envKevisualDir;
|
||||
logger.debug('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
|
||||
}
|
||||
|
||||
export const HomeConfigDir = path.join(kevisualDir, 'assistant-app');
|
||||
|
||||
export function parseArgs(args: string[]) {
|
||||
const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
// 处理 root 参数
|
||||
if (arg === '--root') {
|
||||
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
||||
result.root = args[i + 1];
|
||||
i++; // 跳过下一个参数,因为它是值
|
||||
}
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
if (result.root) {
|
||||
result.home = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
|
||||
/**
|
||||
* 手动解析命令行参数
|
||||
* @param homedir
|
||||
* @returns
|
||||
*/
|
||||
export const parseHomeArg = (homedir?: string) => {
|
||||
const args = process.argv.slice(2);
|
||||
const execPath = process.execPath;
|
||||
const options = parseArgs(args);
|
||||
let _configDir: string | undefined = undefined;
|
||||
if (options.home && homedir) {
|
||||
_configDir = homedir;
|
||||
} else if (options.root) {
|
||||
_configDir = options.root;
|
||||
}
|
||||
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
|
||||
const isOpencode = checkUrl.some((item) => execPath.includes(item))
|
||||
let isServer = false;
|
||||
// 如果args包含 server 则认为是服务端运行。其中config中server必须存在
|
||||
const checkArgs = process.argv.slice(1);
|
||||
const isDev = checkArgs.some(item => item.includes('run-server.ts'));
|
||||
|
||||
// console.log('parseHomeArg args:', checkArgs, execPath);
|
||||
const isPm2 = checkArgs.some(item => item.includes('pm2'))
|
||||
const isArgsServer = checkArgs.some((item) => item === 'server' || item.includes('asst-server') || item.includes('assistant-server') || item.includes('run-server.ts'));
|
||||
// const isDev = checkArgs.some(item => item.includes('run-server.ts'));
|
||||
let isDaemon = false;
|
||||
if (isArgsServer || isPm2) {
|
||||
// 判断 --daemon 参数, 如果有则认为是守护进程运行
|
||||
if (checkArgs.includes('--daemon') || checkArgs.includes('-d')) {
|
||||
isDaemon = true;
|
||||
}
|
||||
// 判断 -s 或者 --start 参数
|
||||
if (checkArgs.includes('-s') || checkArgs.includes('--start') || isPm2) {
|
||||
isServer = true;
|
||||
}
|
||||
}
|
||||
return {
|
||||
isOpencode,
|
||||
options,
|
||||
isDev,
|
||||
isDaemon,
|
||||
configDir: _configDir,
|
||||
isServer
|
||||
};
|
||||
};
|
||||
|
||||
export const parseHelpArg = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const options = parseArgs(args);
|
||||
return !!options?.help;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import fs from 'node:fs';
|
||||
import { useKey } from '@kevisual/use-config';
|
||||
export const getFileConfig = (filePath: string): any => {
|
||||
return JSON.parse(fs.readFileSync(filePath, 'utf8'));
|
||||
}
|
||||
@@ -5,17 +5,16 @@ import { checkFileExists, createDir } from '../file/index.ts';
|
||||
import { ProxyInfo } from '../proxy/proxy.ts';
|
||||
import dotenv from 'dotenv';
|
||||
import { logger } from '@/module/logger.ts';
|
||||
import { z } from 'zod'
|
||||
import { HomeConfigDir } from './args.ts'
|
||||
import { getFileConfig } from './get-assistan-config.ts';
|
||||
import { useKey } from '@kevisual/use-config';
|
||||
import { env } from 'pm2';
|
||||
|
||||
let kevisualDir = path.join(homedir(), 'kevisual');
|
||||
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
|
||||
if (envKevisualDir) {
|
||||
kevisualDir = envKevisualDir;
|
||||
logger.debug('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
|
||||
}
|
||||
/**
|
||||
* 助手配置文件路径, 全局配置文件目录
|
||||
*/
|
||||
export const configDir = createDir(path.join(kevisualDir, 'assistant-app'));
|
||||
export const configDir = createDir(HomeConfigDir);
|
||||
|
||||
/**
|
||||
* 助手配置文件初始化
|
||||
@@ -28,12 +27,15 @@ export const initConfig = (configRootPath: string) => {
|
||||
const pageConfigPath = path.join(configDir, 'assistant-page-config.json');
|
||||
const pagesDir = createDir(path.join(configDir, 'pages'));
|
||||
const appsDir = createDir(path.join(configDir, 'apps'));
|
||||
const skillsDir = createDir(path.join(configDir, 'skills'), false);
|
||||
const pluginsDir = createDir(path.join(configDir, 'plugins'), false);
|
||||
|
||||
const appsConfigPath = path.join(configDir, 'assistant-apps-config.json');
|
||||
const appPidPath = path.join(configDir, 'assistant-app.pid');
|
||||
const envConfigPath = path.join(configDir, '.env');
|
||||
return {
|
||||
/**
|
||||
* 助手配置文件路径
|
||||
* 助手配置文件路径, assistant-app 目录
|
||||
*/
|
||||
configDir,
|
||||
/**
|
||||
@@ -41,7 +43,7 @@ export const initConfig = (configRootPath: string) => {
|
||||
*/
|
||||
configPath,
|
||||
/**
|
||||
* 服务目录, 后端服务目录
|
||||
* 服务目录, 后端服务目录, apps 目录
|
||||
*/
|
||||
appsDir,
|
||||
/**
|
||||
@@ -49,7 +51,7 @@ export const initConfig = (configRootPath: string) => {
|
||||
*/
|
||||
appsConfigPath,
|
||||
/**
|
||||
* 应用目录, 前端应用目录
|
||||
* 应用目录, 前端应用目录, pages 目录
|
||||
*/
|
||||
pagesDir,
|
||||
/**
|
||||
@@ -64,6 +66,14 @@ export const initConfig = (configRootPath: string) => {
|
||||
* 环境变量配置文件路径
|
||||
*/
|
||||
envConfigPath,
|
||||
/**
|
||||
* 技能目录,配置给 opencode 去用的
|
||||
*/
|
||||
skillsDir,
|
||||
/**
|
||||
* 插件目录, 给 cli 用的,动态加载插件,每一个都是独立的
|
||||
*/
|
||||
pluginsDir,
|
||||
};
|
||||
};
|
||||
export type ReturnInitConfigType = ReturnType<typeof initConfig>;
|
||||
@@ -73,6 +83,7 @@ type AuthPermission = {
|
||||
username?: string; // 用户名
|
||||
admin?: string[];
|
||||
};
|
||||
type AssistantRoutes = { type: "npm" | "file", path: string } | string
|
||||
export type AssistantConfigData = {
|
||||
app?: {
|
||||
/**
|
||||
@@ -99,12 +110,15 @@ export type AssistantConfigData = {
|
||||
* 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] }
|
||||
* base: 是否使用 /api/router的基础路径,默认false
|
||||
* lightcode: 是否启用lightcode路由,默认false
|
||||
* livecode: 是否启用livecode路由,实时的注册和销毁,默认false
|
||||
*/
|
||||
router?: {
|
||||
proxy: ProxyInfo[];
|
||||
base?: boolean;
|
||||
lightcode?: boolean;
|
||||
livecode?: boolean;
|
||||
}
|
||||
routes?: AssistantRoutes[],
|
||||
/**
|
||||
* API 代理配置, 比如,api开头的,v1开头的等等
|
||||
*/
|
||||
@@ -225,10 +239,11 @@ export class AssistantConfig {
|
||||
proxy: [],
|
||||
};
|
||||
}
|
||||
assistantConfig = JSON.parse(fs.readFileSync(this.configPath.configPath, 'utf8'));
|
||||
assistantConfig = getFileConfig(this.configPath.configPath);
|
||||
return assistantConfig;
|
||||
} catch (error) {
|
||||
console.error('file read', error.message);
|
||||
process.exit(1);
|
||||
return {
|
||||
app: {
|
||||
url: 'https://kevisual.cn',
|
||||
@@ -247,6 +262,21 @@ export class AssistantConfig {
|
||||
const config = this.getCacheAssistantConfig();
|
||||
return config?.registry || config?.app?.url || 'https://kevisual.cn';
|
||||
}
|
||||
/**
|
||||
* 获取环境变量值
|
||||
* @param value
|
||||
* @returns
|
||||
*/
|
||||
getEnvValue(value: string) {
|
||||
if (value.startsWith('${env:') && value.endsWith('}')) {
|
||||
const envKey = value.replace('${env:', '').replace('}', '');
|
||||
const k = envKey.trim();
|
||||
if (k) {
|
||||
return useKey(k) || '';
|
||||
}
|
||||
}
|
||||
return value;
|
||||
}
|
||||
/**
|
||||
* 设置 assistant-config.json 配置
|
||||
* @param config
|
||||
@@ -368,55 +398,6 @@ export class AssistantConfig {
|
||||
type AppConfig = {
|
||||
list: any[];
|
||||
};
|
||||
export function parseArgs(args: string[]) {
|
||||
const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
|
||||
for (let i = 0; i < args.length; i++) {
|
||||
const arg = args[i];
|
||||
// 处理 root 参数
|
||||
if (arg === '--root') {
|
||||
if (i + 1 < args.length && !args[i + 1].startsWith('-')) {
|
||||
result.root = args[i + 1];
|
||||
i++; // 跳过下一个参数,因为它是值
|
||||
}
|
||||
}
|
||||
if (arg === '--help' || arg === '-h') {
|
||||
result.help = true;
|
||||
}
|
||||
}
|
||||
if (result.root) {
|
||||
result.home = false;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
/**
|
||||
* 手动解析命令行参数
|
||||
* @param homedir
|
||||
* @returns
|
||||
*/
|
||||
export const parseHomeArg = (homedir?: string) => {
|
||||
const args = process.argv.slice(2);
|
||||
const execPath = process.execPath;
|
||||
const options = parseArgs(args);
|
||||
let _configDir = undefined;
|
||||
if (options.home && homedir) {
|
||||
_configDir = homedir;
|
||||
} else if (options.root) {
|
||||
_configDir = options.root;
|
||||
}
|
||||
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
|
||||
const isOpencode = checkUrl.some((item) => execPath.includes(item))
|
||||
return {
|
||||
isOpencode,
|
||||
options,
|
||||
configDir: _configDir,
|
||||
};
|
||||
};
|
||||
|
||||
export const parseHelpArg = () => {
|
||||
const args = process.argv.slice(2);
|
||||
const options = parseArgs(args);
|
||||
return !!options?.help;
|
||||
};
|
||||
|
||||
export const parseIfJson = (content: string) => {
|
||||
try {
|
||||
@@ -426,3 +407,5 @@ export const parseIfJson = (content: string) => {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
export * from './args.ts';
|
||||
@@ -0,0 +1,124 @@
|
||||
import path from 'node:path';
|
||||
import fs from 'node:fs';
|
||||
|
||||
export class ModuleResolver {
|
||||
private root: string;
|
||||
private pkgCache = new Map<string, { pkg: any; mtime: number }>();
|
||||
|
||||
constructor(root: string) {
|
||||
this.root = root;
|
||||
}
|
||||
|
||||
/**
|
||||
* 解析模块路径
|
||||
* - 绝对路径 → 直接返回
|
||||
* - @开头 → 解析 scoped 包 exports/main
|
||||
* - 普通包名 → 从 node_modules 查找
|
||||
* - 相对路径 → 从 root 查找
|
||||
*/
|
||||
resolve(routePath: string): string {
|
||||
if (path.isAbsolute(routePath)) {
|
||||
return routePath;
|
||||
}
|
||||
|
||||
// Scoped 包 @org/pkg/subpath
|
||||
if (routePath.startsWith('@')) {
|
||||
return this.resolveScopedPackage(routePath);
|
||||
}
|
||||
|
||||
// 普通包名 pkg 或 pkg/subpath
|
||||
if (!routePath.startsWith('.') && !routePath.startsWith('/')) {
|
||||
const pkgPath = path.join(this.root, 'node_modules', routePath);
|
||||
if (this.fileIsExists(pkgPath)) {
|
||||
return pkgPath;
|
||||
}
|
||||
}
|
||||
|
||||
// 相对路径 ./xxx 或 ../xxx
|
||||
const localFullPath = path.resolve(this.root, routePath);
|
||||
if (!this.fileIsExists(localFullPath)) {
|
||||
return routePath;
|
||||
}
|
||||
|
||||
// 如果是目录,解析入口文件
|
||||
if (fs.statSync(localFullPath).isDirectory()) {
|
||||
const pkgJsonPath = path.join(localFullPath, 'package.json');
|
||||
const pkg = this.readPackageJson(pkgJsonPath);
|
||||
if (pkg) {
|
||||
const entryPath = this.resolvePackageExport(pkg, '');
|
||||
return path.join(localFullPath, entryPath);
|
||||
}
|
||||
// 没有 package.json,默认使用 index.ts
|
||||
return path.join(localFullPath, 'index.ts');
|
||||
}
|
||||
|
||||
return localFullPath;
|
||||
}
|
||||
|
||||
/** 解析 scoped 包 */
|
||||
private resolveScopedPackage(routePath: string): string {
|
||||
const parts = routePath.split('/');
|
||||
const pkgName = parts.slice(0, 2).join('/'); // @org/pkg
|
||||
const subPath = parts.slice(2).join('/'); // routes
|
||||
|
||||
const pkgPath = path.join(this.root, 'node_modules', pkgName);
|
||||
const pkgJsonPath = path.join(pkgPath, 'package.json');
|
||||
|
||||
const pkg = this.readPackageJson(pkgJsonPath);
|
||||
if (!pkg) {
|
||||
return routePath;
|
||||
}
|
||||
|
||||
const entryPath = this.resolvePackageExport(pkg, subPath);
|
||||
return path.join(pkgPath, entryPath);
|
||||
}
|
||||
|
||||
/** 解析 package.json exports/main 字段 */
|
||||
private resolvePackageExport(pkg: any, subPath: string): string {
|
||||
const exportsField = pkg.exports;
|
||||
|
||||
if (exportsField && typeof exportsField === 'object') {
|
||||
const exportKey = subPath ? `./${subPath}` : '.';
|
||||
if (exportsField[exportKey]) {
|
||||
const entry = exportsField[exportKey];
|
||||
if (typeof entry === 'object') {
|
||||
return entry.import || entry.default || entry;
|
||||
}
|
||||
return entry;
|
||||
}
|
||||
}
|
||||
|
||||
return pkg.main || 'index.js';
|
||||
}
|
||||
|
||||
/** 带缓存读取 package.json */
|
||||
private readPackageJson(pkgJsonPath: string): any | null {
|
||||
try {
|
||||
const cached = this.pkgCache.get(pkgJsonPath);
|
||||
const stats = fs.statSync(pkgJsonPath);
|
||||
if (cached && cached.mtime === stats.mtimeMs) {
|
||||
return cached.pkg;
|
||||
}
|
||||
const pkg = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf-8'));
|
||||
this.pkgCache.set(pkgJsonPath, { pkg, mtime: stats.mtimeMs });
|
||||
return pkg;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** 文件是否存在 */
|
||||
private fileIsExists(filepath: string): boolean {
|
||||
try {
|
||||
fs.accessSync(filepath, fs.constants.F_OK);
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/** 清空缓存 */
|
||||
clearCache(): void {
|
||||
this.pkgCache.clear();
|
||||
}
|
||||
}
|
||||
@@ -11,13 +11,16 @@ import { getEnvToken } from '@/module/http-token.ts';
|
||||
import { initApi } from '@kevisual/api/proxy'
|
||||
import { Query } from '@kevisual/query';
|
||||
import { initLightCode } from '@/module/light-code/index.ts';
|
||||
import { ModuleResolver } from './assistant-app-resolve.ts';
|
||||
import z from 'zod';
|
||||
export class AssistantApp extends Manager {
|
||||
config: AssistantConfig;
|
||||
pagesPath: string;
|
||||
remoteIsConnected = false;
|
||||
attemptedConnectTimes = 0;
|
||||
remoteApp: RemoteApp | null = null;
|
||||
remoteUrl: string | null = null;
|
||||
private resolver: ModuleResolver;
|
||||
|
||||
constructor(config: AssistantConfig, mainApp?: App) {
|
||||
config.checkMounted();
|
||||
const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps');
|
||||
@@ -31,6 +34,7 @@ export class AssistantApp extends Manager {
|
||||
});
|
||||
this.pagesPath = pagesPath;
|
||||
this.config = config;
|
||||
this.resolver = new ModuleResolver(config.configPath.configDir);
|
||||
}
|
||||
async pageList() {
|
||||
const pages = await glob(['*/*/package.json'], {
|
||||
@@ -82,7 +86,7 @@ export class AssistantApp extends Manager {
|
||||
const enabled = opts?.enabled ?? share?.enabled ?? false;
|
||||
if (share && enabled !== false) {
|
||||
if (this.remoteApp) {
|
||||
this.remoteApp.ws?.close();
|
||||
this.remoteApp.disconnect();
|
||||
this.remoteApp = null;
|
||||
this.remoteIsConnected = false;
|
||||
}
|
||||
@@ -95,31 +99,33 @@ export class AssistantApp extends Manager {
|
||||
token,
|
||||
id,
|
||||
app: this.mainApp,
|
||||
// 使用 RemoteApp 内置的自动重连机制
|
||||
autoReconnect: true,
|
||||
reconnectDelay: 5000, // 首次重连延迟 5 秒
|
||||
maxReconnectAttempts: Infinity, // 无限重连
|
||||
enableBackoff: true, // 启用指数退避
|
||||
});
|
||||
const isConnect = await remoteApp.isConnect();
|
||||
if (isConnect) {
|
||||
remoteApp.listenProxy();
|
||||
remoteApp.isConnect();
|
||||
|
||||
this.remoteIsConnected = true;
|
||||
// 监听连接成功和关闭事件
|
||||
remoteApp.on('open', () => {
|
||||
this.remoteIsConnected = true;
|
||||
// 清理已有的 close 事件监听器,防止多重绑定
|
||||
remoteApp.emitter.removeAllListeners('close');
|
||||
remoteApp.emitter.on('close', () => {
|
||||
setTimeout(() => {
|
||||
if (remoteApp.isError) {
|
||||
console.error('远程应用发生错误,不重连');
|
||||
} else {
|
||||
this.reconnectRemoteApp();
|
||||
}
|
||||
}, 5 * 1000); // 第一次断开5秒后重连
|
||||
});
|
||||
logger.debug('链接到了远程应用服务器');
|
||||
const appId = id;
|
||||
const username = config?.auth.username || 'unknown';
|
||||
const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/');
|
||||
this.remoteUrl = url.toString();
|
||||
console.log('远程地址', this.remoteUrl);
|
||||
} else {
|
||||
console.log('Not connected to remote app server');
|
||||
}
|
||||
logger.info('[remote-app] 远程地址', this.remoteUrl);
|
||||
logger.debug('链接到了远程应用服务器');
|
||||
remoteApp.listenProxy()
|
||||
});
|
||||
remoteApp.on('close', () => {
|
||||
this.remoteIsConnected = false;
|
||||
logger.info('[remote-app] 远程连接已关闭,自动重连机制正在处理...');
|
||||
});
|
||||
remoteApp.on('maxReconnectAttemptsReached', () => {
|
||||
logger.error('远程应用重连达到最大次数,停止重连');
|
||||
});
|
||||
this.remoteApp = remoteApp;
|
||||
} else {
|
||||
if (!token) {
|
||||
@@ -145,7 +151,9 @@ export class AssistantApp extends Manager {
|
||||
routerProxy.push({
|
||||
type: 'lightcode',
|
||||
lightcode: {
|
||||
check: true,
|
||||
id: 'main',
|
||||
sync: 'remote',
|
||||
rootPath: path.join(this.config.configPath.appsDir, 'light-code', 'code'),
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -158,9 +166,22 @@ export class AssistantApp extends Manager {
|
||||
continue;
|
||||
}
|
||||
if (proxyInfo.type === 'lightcode') {
|
||||
const schema = z.object({
|
||||
rootPath: z.string().describe('light-code 代码存放路径'),
|
||||
sync: z.enum(['remote', 'local', 'both']).describe('同步方式,remote: 仅从远程拉取,local: 仅上传本地代码,both: 双向同步').default('remote'),
|
||||
});
|
||||
const parseRes = schema.safeParse(proxyInfo.lightcode);
|
||||
if (!parseRes.success) {
|
||||
console.warn('lightcode 配置错误', parseRes.error);
|
||||
continue;
|
||||
}
|
||||
const lightcodeConfig = parseRes.data;
|
||||
|
||||
initLightCode({
|
||||
router: this.mainApp,
|
||||
config: this.config
|
||||
config: this.config,
|
||||
sync: lightcodeConfig.sync,
|
||||
rootPath: lightcodeConfig.rootPath,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -188,27 +209,17 @@ export class AssistantApp extends Manager {
|
||||
}
|
||||
}
|
||||
}
|
||||
async reconnectRemoteApp() {
|
||||
console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes);
|
||||
const remoteApp = this.remoteApp;;
|
||||
if (remoteApp) {
|
||||
// 先关闭旧的 WebSocket,防止竞态条件
|
||||
if (remoteApp.ws) {
|
||||
remoteApp.ws.close();
|
||||
}
|
||||
remoteApp.init();
|
||||
this.attemptedConnectTimes += 1;
|
||||
const isConnect = await remoteApp.isConnect();
|
||||
if (isConnect) {
|
||||
remoteApp.listenProxy();
|
||||
this.attemptedConnectTimes = 0;
|
||||
console.log('重新连接到了远程应用服务器');
|
||||
} else {
|
||||
this.reconnectRemoteApp();
|
||||
setTimeout(() => {
|
||||
this.initRouterApp()
|
||||
}, 30 * 1000 + this.attemptedConnectTimes * 10 * 1000); // 30秒后重连 + 每次增加10秒
|
||||
async initRoutes() {
|
||||
const routes = this.config.getConfig().routes || [];
|
||||
for (const route of routes) {
|
||||
try {
|
||||
const routeStr = typeof route === 'string' ? route : route.path;
|
||||
const resolvedPath = this.resolver.resolve(routeStr);
|
||||
await import(resolvedPath);
|
||||
console.log('[routes] 路由已初始化', route, resolvedPath);
|
||||
} catch (err) {
|
||||
console.error('初始化路由失败', route, err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { ChildProcess, fork, ForkOptions } from 'child_process';
|
||||
import { ChildProcess, fork, ForkOptions } from 'node:child_process';
|
||||
class BaseProcess {
|
||||
private process: ChildProcess;
|
||||
status: 'running' | 'stopped' | 'error' = 'stopped';
|
||||
|
||||
@@ -48,10 +48,8 @@ export type ProxyInfo = {
|
||||
},
|
||||
lightcode?: {
|
||||
id?: string;
|
||||
/**
|
||||
* 是否检测远程服务更新
|
||||
*/
|
||||
check?: boolean;
|
||||
sync?: 'remote' | 'local' | 'both';
|
||||
rootPath?: string;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
49
assistant/src/module/cmd/run.ts
Normal file
49
assistant/src/module/cmd/run.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import { spawn } from 'node:child_process'
|
||||
|
||||
type RunCmdOptions = {
|
||||
cmd: string;
|
||||
cwd?: string;
|
||||
env?: Record<string, string>;
|
||||
}
|
||||
|
||||
type RunResult = {
|
||||
code: number;
|
||||
data: string;
|
||||
}
|
||||
/**
|
||||
* 运行命令行指令
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
export const runCmd = (opts: RunCmdOptions): Promise<RunResult> => {
|
||||
const { cmd, cwd } = opts || {};
|
||||
return new Promise<RunResult>((resolve) => {
|
||||
const parts = cmd.split(' ');
|
||||
const command = parts[0];
|
||||
const args = parts.slice(1);
|
||||
const proc = spawn(command, args, {
|
||||
cwd: cwd || process.cwd(),
|
||||
shell: true,
|
||||
env: { ...process.env, ...opts?.env },
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
let result = ''
|
||||
proc.stdout.on('data', (data: Buffer) => {
|
||||
stdout += data.toString();
|
||||
});
|
||||
proc.stderr.on('data', (data: Buffer) => {
|
||||
stderr += data.toString();
|
||||
});
|
||||
proc.on('close', (code: number) => {
|
||||
result = stdout;
|
||||
if (stderr) {
|
||||
result += '\n' + stderr;
|
||||
}
|
||||
resolve({ code: code === 0 ? 200 : code, data: result });
|
||||
});
|
||||
proc.on('error', (err: Error) => {
|
||||
resolve({ code: 500, data: err.message });
|
||||
});
|
||||
});
|
||||
}
|
||||
10
assistant/src/module/config.ts
Normal file
10
assistant/src/module/config.ts
Normal file
@@ -0,0 +1,10 @@
|
||||
import { useConfig } from '@kevisual/use-config';
|
||||
import { HomeConfigDir } from './assistant/config/index.ts';
|
||||
import path from 'node:path';
|
||||
export const config = useConfig({
|
||||
dotenvOpts: {
|
||||
path: [path.join(HomeConfigDir, '.env'), '.env'],
|
||||
}
|
||||
})
|
||||
|
||||
// console.log('配置文件目录:', config, HomeConfigDir);
|
||||
@@ -1,8 +1,7 @@
|
||||
import { App, QueryRouterServer } from '@kevisual/router';
|
||||
import { AssistantInit } from '../../services/init/index.ts';
|
||||
import path from 'node:path';
|
||||
import fs, { write } from 'node:fs';
|
||||
import os from 'node:os';
|
||||
import fs from 'node:fs';
|
||||
import glob from 'fast-glob';
|
||||
import { runCode } from './run.ts';
|
||||
const codeDemoId = '0e700dc8-90dd-41b7-91dd-336ea51de3d2'
|
||||
@@ -35,44 +34,47 @@ const writeCodeDemo = async (appDir: string) => {
|
||||
}
|
||||
// writeCodeDemo(path.join(os.homedir(), 'kevisual', 'assistant-app', 'apps'));
|
||||
|
||||
type opts = {
|
||||
type Opts = {
|
||||
router: QueryRouterServer | App
|
||||
config: AssistantConfig | AssistantInit
|
||||
sync?: boolean
|
||||
sync?: 'remote' | 'local' | 'both'
|
||||
rootPath?: string
|
||||
}
|
||||
type LightCodeFile = {
|
||||
id?: string, code?: string, hash?: string, filepath: string
|
||||
}
|
||||
export const initLightCode = async (opts: opts) => {
|
||||
export const initLightCode = async (opts: Opts) => {
|
||||
// 注册 light-code 路由
|
||||
console.log('初始化 light-code 路由');
|
||||
const config = opts.config as AssistantInit;
|
||||
const app = opts.router;
|
||||
const token = config.getConfig()?.token || '';
|
||||
const query = config.query;
|
||||
const sync = opts.sync ?? true;
|
||||
const sync = opts.sync ?? 'remote';
|
||||
if (!config || !app) {
|
||||
console.error('initLightCode 缺少必要参数, config 或 app');
|
||||
return;
|
||||
}
|
||||
const appDir = config.configPath.appsDir;
|
||||
const lightcodeDir = path.join(appDir, 'light-code', 'code');
|
||||
const lightcodeDir = opts.rootPath;
|
||||
if (!fs.existsSync(lightcodeDir)) {
|
||||
fs.mkdirSync(lightcodeDir, { recursive: true });
|
||||
}
|
||||
let diffList: LightCodeFile[] = [];
|
||||
|
||||
const codeFiles = glob.sync(['**/*.ts', '**/*.js'], {
|
||||
cwd: lightcodeDir,
|
||||
onlyFiles: true,
|
||||
}).map(file => {
|
||||
return {
|
||||
filepath: path.join(lightcodeDir, file),
|
||||
// hash: getHash(path.join(lightcodeDir, file))
|
||||
}
|
||||
});
|
||||
const findGlob = (opts: { cwd: string }) => {
|
||||
return glob.sync(['**/*.ts', '**/*.js'], {
|
||||
cwd: opts.cwd,
|
||||
onlyFiles: true,
|
||||
}).map(file => {
|
||||
return {
|
||||
filepath: path.join(opts.cwd, file),
|
||||
// hash: getHash(path.join(lightcodeDir, file))
|
||||
}
|
||||
});
|
||||
}
|
||||
const codeFiles = findGlob({ cwd: lightcodeDir });
|
||||
|
||||
if (sync) {
|
||||
if (sync === 'remote' || sync === 'both') {
|
||||
const queryRes = await query.post({
|
||||
path: 'light-code',
|
||||
key: 'list',
|
||||
@@ -100,13 +102,6 @@ export const initLightCode = async (opts: opts) => {
|
||||
fs.writeFileSync(item.filepath, item.code, 'utf-8');
|
||||
// console.log(`新增 light-code 文件: ${item.filepath}`);
|
||||
}
|
||||
|
||||
// 执行删除
|
||||
for (const filepath of toDelete) {
|
||||
fs.unlinkSync(filepath.filepath);
|
||||
// console.log(`删除 light-code 文件: ${filepath.filepath}`);
|
||||
}
|
||||
|
||||
// 执行更新
|
||||
for (const item of toUpdate) {
|
||||
fs.writeFileSync(item.filepath, item.code, 'utf-8');
|
||||
@@ -117,23 +112,38 @@ export const initLightCode = async (opts: opts) => {
|
||||
// filepath: d.filepath,
|
||||
// hash: d.hash
|
||||
// }));
|
||||
|
||||
if (sync === 'remote') {
|
||||
// 执行删除
|
||||
for (const filepath of toDelete) {
|
||||
// console.log(`删除 light-code 文件: ${filepath.filepath}`);
|
||||
const parentDir = path.dirname(filepath.filepath);
|
||||
// console.log('parentDir', parentDir, lightcodeDir);
|
||||
if (parentDir === lightcodeDir) {
|
||||
fs.unlinkSync(filepath.filepath);
|
||||
}
|
||||
}
|
||||
}
|
||||
diffList = findGlob({ cwd: lightcodeDir });
|
||||
} else {
|
||||
console.error('light-code 同步失败', queryRes.message);
|
||||
diffList = codeFiles;
|
||||
}
|
||||
} else {
|
||||
} else if (sync === 'local') {
|
||||
diffList = codeFiles;
|
||||
}
|
||||
|
||||
|
||||
for (const file of diffList) {
|
||||
const tsPath = file.filepath;
|
||||
const runRes = await runCode(tsPath, { path: 'router', key: 'list' }, { timeout: 10000 });
|
||||
const runRes = await runCode(tsPath, { message: { path: 'router', key: 'list' } }, { timeout: 10000 });
|
||||
// console.log('light-code 运行结果', file.filepath, runRes);
|
||||
if (runRes.success) {
|
||||
const res = runRes.data;
|
||||
if (res.code === 200) {
|
||||
const list = res.data?.list || [];
|
||||
for (const routerItem of list) {
|
||||
// console.log('注册 light-code 路由项:', routerItem.id, routerItem.path);
|
||||
if (routerItem.path?.includes('auth') || routerItem.path?.includes('router') || routerItem.path?.includes('call')) {
|
||||
continue;
|
||||
}
|
||||
@@ -144,6 +154,10 @@ export const initLightCode = async (opts: opts) => {
|
||||
} else {
|
||||
metadata.tags = ['light-code'];
|
||||
}
|
||||
metadata.source = 'light-code';
|
||||
metadata['light-code'] = {
|
||||
id: file.id
|
||||
}
|
||||
app.route({
|
||||
id: routerItem.id,
|
||||
path: `${routerItem.id}__${routerItem.path}`,
|
||||
@@ -153,8 +167,13 @@ export const initLightCode = async (opts: opts) => {
|
||||
middleware: ['auth'],
|
||||
}).define(async (ctx) => {
|
||||
const tokenUser = ctx.state?.tokenUser || {};
|
||||
const query = { ...ctx.query, tokenUser }
|
||||
const runRes2 = await runCode(tsPath, query, { timeout: 30000 });
|
||||
const query = { ...ctx.query }
|
||||
const runRes2 = await runCode(tsPath, {
|
||||
message: query,
|
||||
context: {
|
||||
state: { tokenUser, user: tokenUser },
|
||||
}
|
||||
}, { timeout: 30000 });
|
||||
if (runRes2.success) {
|
||||
const res2 = runRes2.data;
|
||||
if (res2.code === 200) {
|
||||
@@ -165,8 +184,10 @@ export const initLightCode = async (opts: opts) => {
|
||||
} else {
|
||||
ctx.throw(runRes2.error || 'Lightcode 路由执行失败');
|
||||
}
|
||||
}).addTo(app);
|
||||
|
||||
}).addTo(app, {
|
||||
overwrite: false
|
||||
});// 不允许覆盖已存在的路由
|
||||
// console.log(`light-code 路由注册成功: [${routerItem.path}] ${routerItem.id} 来自文件: ${file.filepath}`);
|
||||
}
|
||||
}
|
||||
} else {
|
||||
@@ -174,4 +195,19 @@ export const initLightCode = async (opts: opts) => {
|
||||
}
|
||||
}
|
||||
console.log(`light-code 路由注册成功`, `注册${diffList.length}个路由`);
|
||||
}
|
||||
|
||||
export const clearLightCodeRoutes = (opts: Pick<Opts, 'router'>) => {
|
||||
const app = opts.router;
|
||||
if (!app) {
|
||||
console.error('clearLightCodeRoutes 缺少必要参数, app');
|
||||
return;
|
||||
}
|
||||
const routes = app.getList();
|
||||
for (const route of routes) {
|
||||
if (route.metadata?.source === 'light-code') {
|
||||
// console.log(`删除 light-code 路由: ${route.path} ${route.id}`);
|
||||
app.removeById(route.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
import { fork } from 'child_process'
|
||||
import fs from 'fs';
|
||||
|
||||
import { fork } from 'node:child_process'
|
||||
import fs from 'node:fs';
|
||||
import { ListenProcessParams, ListenProcessResponse } from '@kevisual/router';
|
||||
export const fileExists = (path: string): boolean => {
|
||||
try {
|
||||
fs.accessSync(path, fs.constants.F_OK);
|
||||
@@ -10,30 +10,12 @@ export const fileExists = (path: string): boolean => {
|
||||
}
|
||||
}
|
||||
|
||||
export type RunCodeParams = {
|
||||
path?: string;
|
||||
key?: string;
|
||||
payload?: string;
|
||||
[key: string]: any
|
||||
}
|
||||
export type RunCodeParams = ListenProcessParams
|
||||
type RunCodeOptions = {
|
||||
timeout?: number; // 超时时间,单位毫秒
|
||||
[key: string]: any
|
||||
}
|
||||
type RunCode = {
|
||||
// 调用进程的功能
|
||||
success?: boolean
|
||||
data?: {
|
||||
// 调用router的结果
|
||||
code?: number
|
||||
data?: any
|
||||
message?: string
|
||||
[key: string]: any
|
||||
};
|
||||
error?: any
|
||||
timestamp?: string
|
||||
output?: string
|
||||
}
|
||||
type RunCode = ListenProcessResponse & { output?: string }
|
||||
export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?: RunCodeOptions): Promise<RunCode> => {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (fileExists(tsPath) === false) {
|
||||
@@ -81,7 +63,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
||||
silent: true, // 启用 stdio 重定向
|
||||
env: {
|
||||
...process.env,
|
||||
BUN_CHILD_PROCESS: 'true' // 标记为子进程
|
||||
KEVISUAL_CHILD_PROCESS: 'true' // 标记为子进程
|
||||
}
|
||||
})
|
||||
// 监听来自子进程的消息
|
||||
@@ -150,6 +132,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
|
||||
// 向子进程发送消息
|
||||
|
||||
} catch (error) {
|
||||
console.error('启动子进程失败:', error)
|
||||
resolveOnce({
|
||||
success: false,
|
||||
error: `启动子进程失败: ${error instanceof Error ? error.message : '未知错误'}`
|
||||
|
||||
142
assistant/src/module/livecode/index.ts
Normal file
142
assistant/src/module/livecode/index.ts
Normal file
@@ -0,0 +1,142 @@
|
||||
import { WSSManager } from './wss.ts';
|
||||
import { App, Route } from '@kevisual/router'
|
||||
import { WebSocketReq, ListenProcessParams } from '@kevisual/router'
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
import { customAlphabet } from 'nanoid';
|
||||
const letter = 'abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
|
||||
const customId = customAlphabet(letter, 16);
|
||||
/**
|
||||
* 实时注册的代码模块
|
||||
*
|
||||
* 别人通过 WebSocket 连接到此模块,发送路由列表和请求数据
|
||||
*/
|
||||
export class LiveCode {
|
||||
wssManager: WSSManager;
|
||||
app: App;
|
||||
emitter: EventEmitter;
|
||||
constructor(app: App) {
|
||||
this.wssManager = new WSSManager({ heartbeatInterval: 6 * 5000 });
|
||||
this.app = app;
|
||||
this.emitter = new EventEmitter();
|
||||
console.log('[LiveCode] 模块已初始化');
|
||||
}
|
||||
async conn(req: WebSocketReq) {
|
||||
const { ws, emitter, id } = req;
|
||||
const that = this;
|
||||
// @ts-ignore
|
||||
let wid = ws.data?.wid;
|
||||
if (!wid) {
|
||||
const _id = this.wssManager.addConnection(req, { userId: id });
|
||||
// @ts-ignore
|
||||
ws.data.wid = _id;
|
||||
emitter.once('close--' + id, () => {
|
||||
that.wssManager.closeConnection(_id);
|
||||
this.deinitAppRoutes(_id);
|
||||
});
|
||||
console.log('[LiveCode]新的 WebSocket 连接已打开', _id);
|
||||
const res = await that.init(_id);
|
||||
if (res.code === 200) {
|
||||
console.log('[LiveCode]初始化路由列表完成');
|
||||
that.initAppRoutes(res.data?.list || [], _id);
|
||||
} else {
|
||||
console.error('[LiveCode]初始化路由列表失败:', res?.message);
|
||||
}
|
||||
return this;
|
||||
}
|
||||
that.onMessage(req);
|
||||
return this;
|
||||
}
|
||||
getWss(id: string) {
|
||||
return this.wssManager.getConnection(id)
|
||||
}
|
||||
async init(id: string): Promise<{ code: number, message?: string, data?: any }> {
|
||||
return this.sendData({ message: { path: 'router', key: 'list', } }, id);
|
||||
}
|
||||
sendData(data: ListenProcessParams, id: string): Promise<{ code: number, message?: string, data?: any }> {
|
||||
const reqId = customId()
|
||||
const wss = this.getWss(id);
|
||||
if (!wss) {
|
||||
return Promise.resolve({ code: 500, message: '连接不存在或已关闭' });
|
||||
}
|
||||
const emitter = this.emitter;
|
||||
const wsReq = wss.wsReq;
|
||||
try {
|
||||
wsReq.ws.send(JSON.stringify({
|
||||
type: 'router',
|
||||
id: reqId,
|
||||
data: data
|
||||
}));
|
||||
} catch (error) {
|
||||
console.error('[LiveCode]发送数据失败:', error);
|
||||
return Promise.resolve({ code: 500, message: '发送数据失败' });
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve({ code: 500, message: '请求超时' });
|
||||
emitter.off(reqId, listenOnce);
|
||||
}, 5000);
|
||||
const listenOnce = (resData: any) => {
|
||||
clearTimeout(timeout);
|
||||
resolve(resData);
|
||||
emitter.off(reqId, listenOnce);
|
||||
}
|
||||
emitter.once(reqId, listenOnce);
|
||||
});
|
||||
}
|
||||
onMessage(req: WebSocketReq) {
|
||||
const { data } = req;
|
||||
if (data?.id) {
|
||||
// console.log('LiveCode 收到消息:', data);
|
||||
this.emitter.emit(data.id, data.data);
|
||||
} else {
|
||||
console.warn('[LiveCode] 未知的消息格式', data);
|
||||
}
|
||||
}
|
||||
initAppRoutes(list: Route[], wid: string) {
|
||||
for (const route of list) {
|
||||
const path = route.path || '';
|
||||
const id = route.id || '';
|
||||
if (path.startsWith('router') || path.startsWith('auth') || path.startsWith('admin-autu') || path.startsWith('call')) {
|
||||
continue;
|
||||
}
|
||||
// console.log('注册路由:', route.path, route.description, route.metadata, route.id);
|
||||
this.app.route({
|
||||
path: route.id,
|
||||
key: route.key,
|
||||
description: route.description,
|
||||
metadata: {
|
||||
...route.metadata,
|
||||
source: 'livecode',
|
||||
liveCodeId: wid
|
||||
},
|
||||
middleware: ['auth'],
|
||||
}).define(async (ctx) => {
|
||||
const { token, cookie, ...rest } = ctx.query;
|
||||
const tokenUser = ctx.state.tokernUser;
|
||||
const res = await this.sendData({
|
||||
message: {
|
||||
id: route.id,
|
||||
payload: rest,
|
||||
},
|
||||
context: {
|
||||
state: {
|
||||
tokenUser
|
||||
}
|
||||
}
|
||||
}, wid);
|
||||
// console.log('路由响应数据:', res);
|
||||
ctx.forward(res)
|
||||
}).addTo(this.app, {
|
||||
// override: false,
|
||||
// // @ts-ignore
|
||||
// overwrite: false
|
||||
});
|
||||
}
|
||||
}
|
||||
deinitAppRoutes(wid: string) {
|
||||
const routesToRemove = this.app.routes.filter(route => route.metadata?.liveCodeId === wid);
|
||||
for (const route of routesToRemove) {
|
||||
this.app.removeById(route.id);
|
||||
}
|
||||
}
|
||||
}
|
||||
134
assistant/src/module/livecode/sse.ts
Normal file
134
assistant/src/module/livecode/sse.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
import { nanoid } from "nanoid";
|
||||
type ConnectionInfo = {
|
||||
id: string;
|
||||
writer: WritableStreamDefaultWriter;
|
||||
stream: ReadableStream<any>;
|
||||
connectedAt: Date;
|
||||
heartbeatInterval: NodeJS.Timeout | null;
|
||||
userId?: string;
|
||||
};
|
||||
export class SSEManager {
|
||||
private connections: Map<string, ConnectionInfo> = new Map();
|
||||
private userConnections: Map<string, Set<string>> = new Map(); // userId -> connectionIds
|
||||
|
||||
constructor() {
|
||||
// 初始化逻辑
|
||||
}
|
||||
createConnection(info?: { userId?: string }): ConnectionInfo {
|
||||
const connectionId = nanoid(16);
|
||||
const { readable, writable } = new TransformStream();
|
||||
const writer = writable.getWriter();
|
||||
|
||||
// 存储连接信息
|
||||
const connectionInfo = {
|
||||
id: connectionId,
|
||||
writer,
|
||||
stream: readable,
|
||||
connectedAt: new Date(),
|
||||
heartbeatInterval: null,
|
||||
userId: info?.userId
|
||||
};
|
||||
|
||||
this.connections.set(connectionId, connectionInfo);
|
||||
|
||||
// 添加到用户索引
|
||||
if (info?.userId) {
|
||||
const userSet = this.userConnections.get(info.userId) || new Set();
|
||||
userSet.add(connectionId);
|
||||
this.userConnections.set(info.userId, userSet);
|
||||
}
|
||||
|
||||
return connectionInfo;
|
||||
}
|
||||
|
||||
sendToConnection(connectionId: string, data: any) {
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (connection) {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
return connection.writer.write(new TextEncoder().encode(message));
|
||||
}
|
||||
throw new Error(`Connection ${connectionId} not found`);
|
||||
}
|
||||
|
||||
getConnection(connectionId: string) {
|
||||
return this.connections.get(connectionId);
|
||||
}
|
||||
|
||||
broadcast(data: any, opts?: { userId?: string }) {
|
||||
const message = `data: ${JSON.stringify(data)}\n\n`;
|
||||
const promises = [];
|
||||
|
||||
// 指定 userId:只发送给目标用户(通过索引快速查找)
|
||||
if (opts?.userId) {
|
||||
const userConnIds = this.userConnections.get(opts.userId);
|
||||
if (userConnIds) {
|
||||
for (const connId of userConnIds) {
|
||||
const conn = this.connections.get(connId);
|
||||
if (conn) {
|
||||
promises.push(
|
||||
conn.writer.write(new TextEncoder().encode(message))
|
||||
.catch(() => {
|
||||
this.closeConnection(connId);
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
// 未指定 userId:广播给所有人
|
||||
for (const [id, connection] of this.connections) {
|
||||
promises.push(
|
||||
connection.writer.write(new TextEncoder().encode(message))
|
||||
.catch(() => {
|
||||
this.closeConnection(id);
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
closeConnection(connectionId: string) {
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (connection) {
|
||||
// 清理心跳定时器
|
||||
if (connection.heartbeatInterval) {
|
||||
clearInterval(connection.heartbeatInterval);
|
||||
}
|
||||
|
||||
// 从用户索引中移除
|
||||
if (connection.userId) {
|
||||
const userSet = this.userConnections.get(connection.userId);
|
||||
if (userSet) {
|
||||
userSet.delete(connectionId);
|
||||
if (userSet.size === 0) {
|
||||
this.userConnections.delete(connection.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 关闭写入器
|
||||
connection.writer.close().catch(console.error);
|
||||
|
||||
// 从管理器中移除
|
||||
this.connections.delete(connectionId);
|
||||
|
||||
console.log(`Connection ${connectionId} closed`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
closeAllConnections() {
|
||||
for (const [connectionId, connection] of this.connections) {
|
||||
this.closeConnection(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
getActiveConnections() {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
}
|
||||
|
||||
214
assistant/src/module/livecode/wss.ts
Normal file
214
assistant/src/module/livecode/wss.ts
Normal file
@@ -0,0 +1,214 @@
|
||||
import { nanoid } from "nanoid";
|
||||
import { WebSocketReq } from '@kevisual/router'
|
||||
import { logger } from "../logger.ts";
|
||||
type ConnectionInfo = {
|
||||
id: string;
|
||||
wsReq: WebSocketReq;
|
||||
connectedAt: Date;
|
||||
heartbeatInterval: NodeJS.Timeout | null;
|
||||
userId?: string;
|
||||
lastHeartbeat: Date;
|
||||
};
|
||||
|
||||
export class WSSManager {
|
||||
private connections: Map<string, ConnectionInfo> = new Map();
|
||||
private userConnections: Map<string, Set<string>> = new Map();
|
||||
private heartbeatInterval: number = 30000; // 默认30秒
|
||||
|
||||
constructor(opts?: { heartbeatInterval?: number }) {
|
||||
if (opts?.heartbeatInterval) {
|
||||
this.heartbeatInterval = opts.heartbeatInterval;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 添加 WebSocket 连接
|
||||
*/
|
||||
addConnection(wsReq: WebSocketReq, info?: { userId?: string }): string {
|
||||
const connectionId = nanoid(16);
|
||||
const now = new Date();
|
||||
|
||||
const connectionInfo: ConnectionInfo = {
|
||||
id: connectionId,
|
||||
wsReq: wsReq,
|
||||
connectedAt: now,
|
||||
heartbeatInterval: null,
|
||||
userId: info?.userId,
|
||||
lastHeartbeat: now,
|
||||
};
|
||||
|
||||
// 启动心跳
|
||||
this.startHeartbeat(connectionInfo);
|
||||
|
||||
// 存储连接
|
||||
this.connections.set(connectionId, connectionInfo);
|
||||
|
||||
// 添加到用户索引
|
||||
if (info?.userId) {
|
||||
const userSet = this.userConnections.get(info.userId) || new Set();
|
||||
userSet.add(connectionId);
|
||||
this.userConnections.set(info.userId, userSet);
|
||||
}
|
||||
return connectionId;
|
||||
}
|
||||
|
||||
/**
|
||||
* 启动心跳
|
||||
*/
|
||||
private startHeartbeat(connection: ConnectionInfo) {
|
||||
connection.heartbeatInterval = setInterval(() => {
|
||||
const ws = connection.wsReq.ws;
|
||||
ws.send(JSON.stringify({ type: 'heartbeat', timestamp: new Date().toISOString() }));
|
||||
connection.lastHeartbeat = new Date();
|
||||
logger.debug(`[LiveCode] 发送心跳给连接 ${connection.id}`);
|
||||
}, this.heartbeatInterval);
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到指定连接
|
||||
*/
|
||||
sendToConnection(connectionId: string, data: any): boolean {
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (connection) {
|
||||
// 发送消息
|
||||
connection.wsReq.ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 发送消息到指定用户的所有连接
|
||||
*/
|
||||
sendToUser(userId: string, data: any): number {
|
||||
const userConnIds = this.userConnections.get(userId);
|
||||
if (!userConnIds) return 0;
|
||||
|
||||
let sentCount = 0;
|
||||
for (const connId of userConnIds) {
|
||||
if (this.sendToConnection(connId, data)) {
|
||||
sentCount++;
|
||||
}
|
||||
}
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 广播消息到所有连接
|
||||
*/
|
||||
broadcast(data: any, opts?: { userId?: string; excludeConnectionId?: string }): number {
|
||||
if (opts?.userId) {
|
||||
// 发送给指定用户
|
||||
return this.sendToUser(opts.userId, data);
|
||||
}
|
||||
|
||||
let sentCount = 0;
|
||||
for (const [connId, connection] of this.connections) {
|
||||
// 跳过排除的连接
|
||||
if (opts?.excludeConnectionId && connId === opts.excludeConnectionId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (this.sendToConnection(connId, data)) {
|
||||
sentCount++;
|
||||
}
|
||||
}
|
||||
return sentCount;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接信息
|
||||
*/
|
||||
getConnection(connectionId: string): ConnectionInfo | undefined {
|
||||
return this.connections.get(connectionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户的所有连接
|
||||
*/
|
||||
getUserConnections(userId: string): ConnectionInfo[] {
|
||||
const userConnIds = this.userConnections.get(userId);
|
||||
if (!userConnIds) return [];
|
||||
|
||||
return Array.from(userConnIds)
|
||||
.map((id) => this.connections.get(id))
|
||||
.filter((conn): conn is ConnectionInfo => conn !== undefined);
|
||||
}
|
||||
|
||||
/**
|
||||
* 检查连接是否活跃(基于心跳)
|
||||
*/
|
||||
isConnectionAlive(connectionId: string, timeout: number = 60000): boolean {
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (!connection) return false;
|
||||
|
||||
const now = new Date();
|
||||
const timeSinceLastHeartbeat = now.getTime() - connection.lastHeartbeat.getTime();
|
||||
return timeSinceLastHeartbeat < timeout;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭指定连接
|
||||
*/
|
||||
closeConnection(connectionId: string): boolean {
|
||||
const connection = this.connections.get(connectionId);
|
||||
if (connection) {
|
||||
// 清理心跳定时器
|
||||
if (connection.heartbeatInterval) {
|
||||
clearInterval(connection.heartbeatInterval);
|
||||
}
|
||||
|
||||
// 从用户索引中移除
|
||||
if (connection.userId) {
|
||||
const userSet = this.userConnections.get(connection.userId);
|
||||
if (userSet) {
|
||||
userSet.delete(connectionId);
|
||||
if (userSet.size === 0) {
|
||||
this.userConnections.delete(connection.userId);
|
||||
}
|
||||
}
|
||||
}
|
||||
try {
|
||||
connection.wsReq.ws.close();
|
||||
} catch (error) {
|
||||
console.error(`Error closing WebSocket for connection ${connectionId}:`, error);
|
||||
}
|
||||
// 从管理器中移除
|
||||
this.connections.delete(connectionId);
|
||||
|
||||
console.log(`WebSocket connection ${connectionId} closed`);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* 关闭所有连接
|
||||
*/
|
||||
closeAllConnections(): void {
|
||||
for (const [connectionId] of this.connections) {
|
||||
this.closeConnection(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取活跃连接列表
|
||||
*/
|
||||
getActiveConnections(): string[] {
|
||||
return Array.from(this.connections.keys());
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取连接数量
|
||||
*/
|
||||
getConnectionCount(): number {
|
||||
return this.connections.size;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取用户连接数量
|
||||
*/
|
||||
getUserConnectionCount(userId: string): number {
|
||||
return this.userConnections.get(userId)?.size || 0;
|
||||
}
|
||||
}
|
||||
@@ -32,7 +32,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
|
||||
return fileProxy(req, res, {
|
||||
path: localProxyProxy.path,
|
||||
rootPath: localProxy.pagesDir,
|
||||
indexPath: localProxyProxy.indexPath,
|
||||
indexPath: localProxyProxy.file?.indexPath,
|
||||
});
|
||||
}
|
||||
res.statusCode = 404;
|
||||
|
||||
@@ -33,4 +33,14 @@ export const installDeps = async (opts: InstallDepsOptions) => {
|
||||
} else {
|
||||
syncSpawn('npm', params, { cwd: appPath, stdio: 'inherit', env: process.env });
|
||||
}
|
||||
};
|
||||
|
||||
export const execCommand = (command: string, options: { cwd?: string } = {}) => {
|
||||
const { cwd } = options;
|
||||
return spawnSync(command, {
|
||||
stdio: 'inherit',
|
||||
shell: true,
|
||||
cwd: cwd,
|
||||
env: process.env,
|
||||
});
|
||||
};
|
||||
272
assistant/src/module/remote-app/README.md
Normal file
272
assistant/src/module/remote-app/README.md
Normal file
@@ -0,0 +1,272 @@
|
||||
# RemoteApp
|
||||
|
||||
RemoteApp 是一个 WebSocket 远程应用连接类,支持断开自动重连机制。
|
||||
|
||||
## 功能特性
|
||||
|
||||
- WebSocket 远程代理连接
|
||||
- **断开自动重连**
|
||||
- 指数退避算法
|
||||
- 可配置重连策略参数
|
||||
- 手动重连/断开控制
|
||||
|
||||
## 安装
|
||||
|
||||
```bash
|
||||
npm install @kevisual/remote-app
|
||||
```
|
||||
|
||||
## 基本使用
|
||||
|
||||
```typescript
|
||||
import { RemoteApp } from '@kevisual/remote-app';
|
||||
|
||||
const app = new RemoteApp({
|
||||
url: 'https://kevisual.cn/ws/proxy',
|
||||
id: 'my-app',
|
||||
token: 'your-token',
|
||||
app: mainApp
|
||||
});
|
||||
|
||||
// 等待连接建立
|
||||
await app.isConnect();
|
||||
|
||||
// 监听代理消息
|
||||
app.listenProxy();
|
||||
```
|
||||
|
||||
## 断开重连配置
|
||||
|
||||
### 配置选项
|
||||
|
||||
| 选项 | 类型 | 默认值 | 说明 |
|
||||
|------|------|--------|------|
|
||||
| `app` | `App` | - | 本地应用实例 |
|
||||
| `url` | `string` | - | 远程服务地址 |
|
||||
| `id` | `string` | - | 应用 ID |
|
||||
| `token` | `string` | - | 认证令牌 |
|
||||
| `emitter` | `EventEmitter` | `new EventEmitter()` | 事件发射器 |
|
||||
| `autoReconnect` | `boolean` | `true` | 是否启用自动重连 |
|
||||
| `maxReconnectAttempts` | `number` | `Infinity` | 最大重连次数 |
|
||||
| `reconnectDelay` | `number` | `1000` | 初始重连延迟(毫秒) |
|
||||
| `maxReconnectDelay` | `number` | `30000` | 重连延迟最大值(毫秒) |
|
||||
| `enableBackoff` | `boolean` | `true` | 是否启用指数退避 |
|
||||
|
||||
### 配置示例
|
||||
|
||||
```typescript
|
||||
// 禁用自动重连
|
||||
const app = new RemoteApp({
|
||||
url: 'https://kevisual.cn/ws/proxy',
|
||||
id: 'app1',
|
||||
token: 'your-token',
|
||||
autoReconnect: false
|
||||
});
|
||||
|
||||
// 限制最大重连次数为 10 次
|
||||
const app = new RemoteApp({
|
||||
url: 'https://kevisual.cn/ws/proxy',
|
||||
id: 'app1',
|
||||
token: 'your-token',
|
||||
maxReconnectAttempts: 10
|
||||
});
|
||||
|
||||
// 自定义重连延迟策略(线性增长)
|
||||
const app = new RemoteApp({
|
||||
url: 'https://kevisual.cn/ws/proxy',
|
||||
id: 'app1',
|
||||
token: 'your-token',
|
||||
enableBackoff: false, // 禁用指数退避
|
||||
reconnectDelay: 2000, // 固定 2 秒延迟
|
||||
maxReconnectAttempts5 // 最多重连 5 次
|
||||
});
|
||||
|
||||
// 快速重连策略(适合内网环境)
|
||||
const app = new RemoteApp({
|
||||
url: 'ws://internal-server/ws',
|
||||
id: 'app1',
|
||||
reconnectDelay: 500, // 0.5 秒开始
|
||||
maxReconnectDelay: 5000, // 最大 5 秒
|
||||
enableBackoff: true
|
||||
});
|
||||
```
|
||||
|
||||
## 方法
|
||||
|
||||
### `isConnect(): Promise<boolean>`
|
||||
|
||||
检查或等待连接建立。
|
||||
|
||||
```typescript
|
||||
const connected = await app.isConnect();
|
||||
console.log(connected ? '已连接' : '连接失败');
|
||||
```
|
||||
|
||||
### `disconnect()`
|
||||
|
||||
手动关闭连接并停止自动重连。
|
||||
|
||||
```typescript
|
||||
app.disconnect();
|
||||
```
|
||||
|
||||
### `reconnect()`
|
||||
|
||||
手动触发重连。
|
||||
|
||||
```typescript
|
||||
app.reconnect();
|
||||
```
|
||||
|
||||
### `json(data: any)`
|
||||
|
||||
发送 JSON 数据到远程服务。
|
||||
|
||||
```typescript
|
||||
app.json({ type: 'ping' });
|
||||
```
|
||||
|
||||
### `listenProxy()`
|
||||
|
||||
启动代理监听,处理远程请求。
|
||||
|
||||
```typescript
|
||||
app.listenProxy();
|
||||
````
|
||||
|
||||
## 事件
|
||||
|
||||
### 可监听事件
|
||||
|
||||
| 事件 | 参数 | 说明 |
|
||||
|------|------|------|
|
||||
| `open` | `id: string` | 连接建立时触发 |
|
||||
| `close` | `id: string` | 连接关闭时时触发 |
|
||||
| `message` | `data: any` | 收到消息时触发 |
|
||||
| `error` | `error: any` | 发生错误时触发 |
|
||||
| `maxReconnectAttemptsReached` | `id: string` | 达到最大重连次数时触发 |
|
||||
| `reconnectFailed` | `{ id, attempt, error }` | 单次重连失败时触发 |
|
||||
|
||||
### 事件监听示例
|
||||
|
||||
```typescript
|
||||
// 连接建立
|
||||
app.on('open', (id) => {
|
||||
console.log(`应用 ${id} 已连接`);
|
||||
});
|
||||
|
||||
// 连接关闭
|
||||
app.on('close', (id) => {
|
||||
console.log(`应用 ${id} 连接已关闭`);
|
||||
});
|
||||
|
||||
// 收到消息
|
||||
app.on('message', (data) => {
|
||||
console.log('收到消息:', data);
|
||||
});
|
||||
|
||||
// 发生错误
|
||||
app.on('error', (error) => {
|
||||
console.error('连接错误:', error);
|
||||
});
|
||||
|
||||
// 达到最大重连次数
|
||||
app.on('maxReconnectAttemptsReached', (id) => {
|
||||
console.error(`应用 ${id} 已达到最大重连次数,停止重连`);
|
||||
// 可以在这里提示用户或采取其他措施
|
||||
});
|
||||
|
||||
// 重连失败
|
||||
app.on('reconnectFailed', ({ id, attempt, error }) => {
|
||||
console.error(`应用 ${id} 第 ${attempt} 次重连失败:`, error);
|
||||
});
|
||||
```
|
||||
|
||||
## 重连机制说明
|
||||
|
||||
### 重连流程
|
||||
|
||||
1. 连接断开时触发 `close` 事件
|
||||
2. 检查是否满足自动重连条件(启用自动重连 + 非手动关闭)
|
||||
3. 计算重连延迟(使用指数退避算法)
|
||||
4. 达到最大重连次数则停止,触发 `maxReconnectAttemptsReached` 事件
|
||||
5. 延迟后尝试重新连接
|
||||
6. 连接成功则重置重连计数器,触发 `open` 事件
|
||||
7. 连接失败则触发 `reconnectFailed` 事件并继续尝试重连
|
||||
|
||||
### 指数退避算法
|
||||
|
||||
当启用指数退避时,重连延迟按以下公式计算:
|
||||
|
||||
```
|
||||
delay = initialDelay * 2^(attempts - 1)
|
||||
```
|
||||
|
||||
实际延迟取上述值与 `maxReconnectDelay` 的较小值。
|
||||
|
||||
例如,初始延迟为 1000ms 时:
|
||||
- 第 1 次重连:1000ms (1s)
|
||||
- 第 2 次重连:2000ms (2s)
|
||||
- 第 3 次重连:4000ms (4s)
|
||||
- 第 4 次重连:8000ms (8s)
|
||||
- 第 5 次重连:16000ms (16s)
|
||||
- 第 6 次重连:30000ms (30s,达到上限)
|
||||
|
||||
## 完整示例
|
||||
|
||||
```typescript
|
||||
import { RemoteApp } from '@kevisual/remote-app';
|
||||
|
||||
class MyService {
|
||||
private remoteApp: RemoteApp;
|
||||
|
||||
constructor() {
|
||||
this.remoteApp = new RemoteApp({
|
||||
url: 'https://kevisual.cn/ws/proxy',
|
||||
id: 'my-service',
|
||||
token: process.env.REMOTE_TOKEN,
|
||||
maxReconnectAttempts: 10,
|
||||
reconnectDelay: 1000,
|
||||
maxReconnectDelay: 30000
|
||||
});
|
||||
|
||||
this.setupEventListeners();
|
||||
}
|
||||
|
||||
private setupEventListeners() {
|
||||
this.remoteApp.on('open', () => {
|
||||
console.log('远程连接已建立');
|
||||
this.remoteApp.listenProxy();
|
||||
});
|
||||
|
||||
this.remoteApp.on('close', () => {
|
||||
console.log('远程连接已断开,正在尝试重连...');
|
||||
});
|
||||
|
||||
this.remoteApp.on('maxReconnectAttemptsReached', () => {
|
||||
console.error('重连失败,请检查网络连接或服务状态');
|
||||
// 可以触发告警或通知管理员
|
||||
});
|
||||
}
|
||||
|
||||
async connect() {
|
||||
const connected = await this.remoteApp.isConnect();
|
||||
if (connected) {
|
||||
console.log('连接成功');
|
||||
} else {
|
||||
console.error('连接超时');
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.remoteApp.disconnect();
|
||||
}
|
||||
}
|
||||
|
||||
const service = new MyService();
|
||||
service.connect();
|
||||
```
|
||||
|
||||
## License
|
||||
|
||||
MIT
|
||||
13
assistant/src/module/remote-app/package-lock.json
generated
Normal file
13
assistant/src/module/remote-app/package-lock.json
generated
Normal file
@@ -0,0 +1,13 @@
|
||||
{
|
||||
"name": "@kevisual/remote-app",
|
||||
"version": "0.0.2",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@kevisual/remote-app",
|
||||
"version": "0.0.2",
|
||||
"license": "MIT"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +1,28 @@
|
||||
{
|
||||
"name": "@kevisual/remote-app",
|
||||
"version": "0.0.1",
|
||||
"version": "0.0.4",
|
||||
"description": "",
|
||||
"main": "remote-app.ts",
|
||||
"main": "dist/app.js",
|
||||
"scripts": {
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
"build": "code-builder build -e remote-app.ts --dts"
|
||||
},
|
||||
"keywords": [],
|
||||
"files": [
|
||||
"dist",
|
||||
"remote-app.ts"
|
||||
],
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"exports": {
|
||||
".": "./dist/app.js"
|
||||
},
|
||||
"devDependencies": {
|
||||
"eventemitter3": "^5.0",
|
||||
"@kevisual/router": "^0.0.70"
|
||||
},
|
||||
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
|
||||
"license": "MIT",
|
||||
"packageManager": "pnpm@10.26.0",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"type": "module"
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import type { App } from '@kevisual/router';
|
||||
import type { App, ListenProcessParams } from '@kevisual/router';
|
||||
import { EventEmitter } from 'eventemitter3';
|
||||
type RemoteAppOptions = {
|
||||
app?: App;
|
||||
@@ -6,7 +6,20 @@ type RemoteAppOptions = {
|
||||
token?: string;
|
||||
emitter?: EventEmitter;
|
||||
id?: string;
|
||||
/** 是否启用自动重连,默认 true */
|
||||
autoReconnect?: boolean;
|
||||
/** 最大重连次数,默认 Infinity */
|
||||
maxReconnectAttempts?: number;
|
||||
/** 初始重连延迟(毫秒),默认 1000 */
|
||||
reconnectDelay?: number;
|
||||
/** 重连延迟最大值(毫秒),默认 30000 */
|
||||
maxReconnectDelay?: number;
|
||||
/** 是否启用指数退避,默认 true */
|
||||
enableBackoff?: boolean;
|
||||
};
|
||||
/**
|
||||
* 远程共享地址类似:https://kevisual.cn/ws/proxy
|
||||
*/
|
||||
export class RemoteApp {
|
||||
mainApp: App;
|
||||
url: string;
|
||||
@@ -16,6 +29,15 @@ export class RemoteApp {
|
||||
ws: WebSocket;
|
||||
remoteIsConnected: boolean;
|
||||
isError: boolean = false;
|
||||
// 重连相关属性
|
||||
autoReconnect: boolean;
|
||||
maxReconnectAttempts: number;
|
||||
reconnectDelay: number;
|
||||
maxReconnectDelay: number;
|
||||
enableBackoff: boolean;
|
||||
reconnectAttempts: number = 0;
|
||||
reconnectTimer: NodeJS.Timeout | null = null;
|
||||
isManuallyClosed: boolean = false;
|
||||
constructor(opts?: RemoteAppOptions) {
|
||||
this.mainApp = opts?.app;
|
||||
const token = opts.token;
|
||||
@@ -29,6 +51,12 @@ export class RemoteApp {
|
||||
_url.searchParams.set('id', id);
|
||||
this.url = _url.toString();
|
||||
this.id = id;
|
||||
// 初始化重连相关配置
|
||||
this.autoReconnect = opts?.autoReconnect ?? true;
|
||||
this.maxReconnectAttempts = opts?.maxReconnectAttempts ?? Infinity;
|
||||
this.reconnectDelay = opts?.reconnectDelay ?? 1000;
|
||||
this.maxReconnectDelay = opts?.maxReconnectDelay ?? 30000;
|
||||
this.enableBackoff = opts?.enableBackoff ?? true;
|
||||
this.init();
|
||||
}
|
||||
async isConnect(): Promise<boolean> {
|
||||
@@ -36,6 +64,11 @@ export class RemoteApp {
|
||||
if (this.isConnected) {
|
||||
return true;
|
||||
}
|
||||
// 如果正在进行重连,等待连接成功
|
||||
if (this.reconnectTimer !== null) {
|
||||
console.log(`远程应用 ${this.id} 正在重连中...`);
|
||||
}
|
||||
// 等待连接成功(支持初次连接和重连场景)
|
||||
return new Promise((resolve) => {
|
||||
const timeout = setTimeout(() => {
|
||||
resolve(false);
|
||||
@@ -52,6 +85,9 @@ export class RemoteApp {
|
||||
}
|
||||
getWsURL(url: string) {
|
||||
const { protocol } = new URL(url);
|
||||
if (protocol.startsWith('ws')) {
|
||||
return url.toString()
|
||||
}
|
||||
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
|
||||
const wsURL = url.toString().replace(protocol, wsProtocol);
|
||||
return wsURL;
|
||||
@@ -73,6 +109,7 @@ export class RemoteApp {
|
||||
ws.onopen = function () {
|
||||
that.isConnected = true;
|
||||
that.onOpen();
|
||||
console.log('[remote-app] WebSocket connection opened');
|
||||
};
|
||||
ws.onclose = function () {
|
||||
that.isConnected = false;
|
||||
@@ -87,12 +124,84 @@ export class RemoteApp {
|
||||
this.ws = ws;
|
||||
}
|
||||
onOpen() {
|
||||
this.isError = false;
|
||||
this.reconnectAttempts = 0;
|
||||
// 清除可能存在的重连定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.emitter.emit('open', this.id);
|
||||
}
|
||||
onClose() {
|
||||
console.log('远程应用关闭:', this.id);
|
||||
this.emitter.emit('close', this.id);
|
||||
this.isConnected = false;
|
||||
this.emitter.emit('close', this.id);
|
||||
// 触发自动重连逻辑
|
||||
if (this.autoReconnect && !this.isManuallyClosed) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}
|
||||
/** 计算下一次重连延迟 */
|
||||
calculateReconnectDelay(): number {
|
||||
if (!this.enableBackoff) {
|
||||
return this.reconnectDelay;
|
||||
}
|
||||
// 指数退避算法:delay = initialDelay * 2^(attempts - 1)
|
||||
const delay = this.reconnectDelay * Math.pow(2, this.reconnectAttempts);
|
||||
return Math.min(delay, this.maxReconnectDelay);
|
||||
}
|
||||
/** 安排重连 */
|
||||
scheduleReconnect() {
|
||||
// 检查是否达到最大重连次数
|
||||
if (this.reconnectAttempts >= this.maxReconnectAttempts) {
|
||||
console.error(`远程应用 ${this.id} 已达到最大重连次数 ${this.maxReconnectAttempts},停止重连`);
|
||||
this.emitter.emit('maxReconnectAttemptsReached', this.id);
|
||||
return;
|
||||
}
|
||||
// 清除可能存在的定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
}
|
||||
const delay = this.calculateReconnectDelay();
|
||||
this.reconnectAttempts++;
|
||||
console.log(`远程应用 ${this.id} 将在 ${delay}ms 后尝试第 ${this.reconnectAttempts} 次重连`);
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
try {
|
||||
this.init();
|
||||
} catch (error) {
|
||||
console.error(`远程应用 ${this.id} 重连失败:`, error);
|
||||
this.emitter.emit('reconnectFailed', { id: this.id, attempt: this.reconnectAttempts, error });
|
||||
// 重连失败后继续尝试重连
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
}, delay);
|
||||
}
|
||||
/** 手动关闭连接,停止自动重连 */
|
||||
disconnect() {
|
||||
this.isManuallyClosed = true;
|
||||
this.autoReconnect = false;
|
||||
// 清除重连定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
// 关闭 WebSocket
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
}
|
||||
}
|
||||
/** 手动重连 */
|
||||
reconnect() {
|
||||
this.isManuallyClosed = false;
|
||||
this.reconnectAttempts = 0;
|
||||
// 清除可能存在的定时器
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
this.init();
|
||||
}
|
||||
onMessage(data: any) {
|
||||
this.emitter.emit('message', data);
|
||||
@@ -102,14 +211,12 @@ export class RemoteApp {
|
||||
this.isError = true;
|
||||
this.emitter.emit('error', error);
|
||||
}
|
||||
on(event: 'open' | 'close' | 'message' | 'error', listener: (data: any) => void) {
|
||||
on(event: 'open' | 'close' | 'message' | 'error' | 'maxReconnectAttemptsReached' | 'reconnectFailed', listener: (data: any) => void) {
|
||||
this.emitter.on(event, listener);
|
||||
return () => {
|
||||
this.emitter.off(event, listener);
|
||||
};
|
||||
}
|
||||
|
||||
sendData(data: any) { }
|
||||
json(data: any) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
}
|
||||
@@ -119,8 +226,10 @@ export class RemoteApp {
|
||||
const listenFn = async (event: any) => {
|
||||
try {
|
||||
const data = event.toString();
|
||||
const body = JSON.parse(data);
|
||||
const message = body.data || {};
|
||||
const body = JSON.parse(data)
|
||||
const bodyData = body?.data as ListenProcessParams;
|
||||
const message = bodyData?.message || {};
|
||||
const context = bodyData?.context || {};
|
||||
if (body?.code === 401) {
|
||||
console.error('远程应用认证失败,请检查 token 是否正确');
|
||||
this.isError = true;
|
||||
@@ -138,12 +247,12 @@ export class RemoteApp {
|
||||
return;
|
||||
|
||||
}
|
||||
const res = await app.call(message);
|
||||
const res = await app.run(message, context);
|
||||
remoteApp.json({
|
||||
id: body.id,
|
||||
data: {
|
||||
code: res.code,
|
||||
data: res.body,
|
||||
data: res.data,
|
||||
message: res.message,
|
||||
},
|
||||
});
|
||||
|
||||
@@ -29,4 +29,6 @@ app.route({
|
||||
...ctx
|
||||
});
|
||||
ctx.forward(res);
|
||||
}).addTo(app)
|
||||
}).addTo(app, {
|
||||
overwrite: false
|
||||
})
|
||||
2
assistant/src/routes/client/index.ts
Normal file
2
assistant/src/routes/client/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './ip.ts';
|
||||
import './system.ts'
|
||||
72
assistant/src/routes/client/ip.ts
Normal file
72
assistant/src/routes/client/ip.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
|
||||
import { app } from '../../app.ts';
|
||||
import { createSkill } from '@kevisual/router';
|
||||
import os from 'node:os';
|
||||
|
||||
const baseURLv4 = 'https://4.ipw.cn/';
|
||||
const baseURLv6 = 'https://6.ipw.cn/';
|
||||
|
||||
export const isIpv6 = (ip: string): boolean => {
|
||||
return ip.includes(':');
|
||||
}
|
||||
|
||||
export const isIpv4 = (ip: string): boolean => {
|
||||
return ip.split('.').length === 4;
|
||||
}
|
||||
export const fetchIP = async (url: string): Promise<string> => {
|
||||
const response = await fetch(url);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch IP from ${url}: ${response.statusText}`);
|
||||
}
|
||||
const ip = (await response.text()).trim();
|
||||
return ip;
|
||||
}
|
||||
|
||||
app.route({
|
||||
path: 'client',
|
||||
key: 'ip',
|
||||
description: '获取客户端 IP 地址',
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'view-client-ip',
|
||||
title: '查看客户端 IP 地址',
|
||||
summary: '获取当前客户端的 IP 地址信息',
|
||||
})
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const networkInterfaces = os.networkInterfaces();
|
||||
const ipAddresses: { type: string, address: string }[] = [];
|
||||
for (const interfaceDetails of Object.values(networkInterfaces)) {
|
||||
if (interfaceDetails) {
|
||||
for (const detail of interfaceDetails) {
|
||||
if (detail.family === 'IPv4' && !detail.internal) {
|
||||
ipAddresses.push({
|
||||
type: 'IPv4-local',
|
||||
address: detail.address,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
const res = await fetchIP(baseURLv6);
|
||||
if (isIpv6(res)) {
|
||||
ipAddresses.push({
|
||||
type: 'IPv6',
|
||||
address: res,
|
||||
});
|
||||
}
|
||||
const res4 = await fetchIP(baseURLv4);
|
||||
if (isIpv4(res4)) {
|
||||
ipAddresses.push({
|
||||
type: 'IPv4',
|
||||
address: res4,
|
||||
});
|
||||
}
|
||||
|
||||
ctx.body = {
|
||||
ipAddresses,
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
85
assistant/src/routes/client/system.ts
Normal file
85
assistant/src/routes/client/system.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import { app, assistantConfig } from '../../app.ts';
|
||||
import { createSkill } from '@kevisual/router';
|
||||
import os from 'node:os';
|
||||
import { runCommand } from '@/services/app/index.ts';
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'version',
|
||||
description: '获取客户端版本号',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = 'v1.0.0';
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'time',
|
||||
description: '获取当前时间',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = {
|
||||
time: new Date().getTime(),
|
||||
date: new Date().toLocaleDateString(),
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
// 调用 path: client key: system
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'system',
|
||||
description: '获取系统信息',
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'view-system-info',
|
||||
title: '查看系统信息',
|
||||
summary: '获取服务器操作系统平台、架构和版本信息',
|
||||
})
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { platform, arch, release } = os;
|
||||
ctx.body = {
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
release: release(),
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
|
||||
app.route({
|
||||
path: 'client',
|
||||
key: 'restart',
|
||||
description: '重启客户端',
|
||||
middleware: ['admin-auth'],
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'restart-client',
|
||||
title: '重启客户端',
|
||||
summary: '重启当前运行的客户端应用程序',
|
||||
})
|
||||
}
|
||||
}).define(async (ctx) => {
|
||||
const cmd = 'pm2 restart assistant-server --update-env';
|
||||
try {
|
||||
runCommand(cmd, []);
|
||||
ctx.body = {
|
||||
message: '客户端重启命令已执行',
|
||||
};
|
||||
} catch (error) {
|
||||
ctx.status = 500;
|
||||
ctx.body = {
|
||||
message: '重启客户端失败',
|
||||
error: error.message,
|
||||
};
|
||||
}
|
||||
}).addTo(app);
|
||||
@@ -1,37 +0,0 @@
|
||||
import { LightHA } from "@kevisual/ha-api";
|
||||
export const lightHA = new LightHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL });
|
||||
|
||||
export const callText = async (text: string) => {
|
||||
const command = text?.trim().slice(0, 20);
|
||||
type ParseCommand = {
|
||||
type?: '打开' | '关闭',
|
||||
appName?: string,
|
||||
command?: string,
|
||||
}
|
||||
let obj: ParseCommand = {};
|
||||
if (command.startsWith('打开')) {
|
||||
obj.appName = command.replace('打开', '').trim();
|
||||
obj.type = '打开';
|
||||
} else if (command.startsWith('关闭')) {
|
||||
obj.appName = command.replace('关闭', '').trim();
|
||||
obj.type = '关闭';
|
||||
}
|
||||
let endTime = Date.now();
|
||||
if (obj.type) {
|
||||
try {
|
||||
const search = await lightHA.searchLight(obj.appName || '');
|
||||
console.log('searchTime', Date.now() - endTime);
|
||||
if (search.id) {
|
||||
await lightHA.runService({ entity_id: search.id, service: obj.type === '打开' ? 'turn_on' : 'turn_off' });
|
||||
} else if (search.hasMore) {
|
||||
const [first] = search.result;
|
||||
await lightHA.runService({ entity_id: first.entity_id, service: obj.type === '打开' ? 'turn_on' : 'turn_off' });
|
||||
} else {
|
||||
console.log('未找到对应设备:', obj.appName);
|
||||
}
|
||||
console.log('解析到控制指令', obj);
|
||||
} catch (e) {
|
||||
console.error('控制失败', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,20 +0,0 @@
|
||||
import { app } from '@/app.ts';
|
||||
// import { Hotkeys } from '@kevisual/hot-api';
|
||||
import { Hotkeys } from './lib.ts';
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
app.route({
|
||||
path: 'key-sender',
|
||||
// middleware: ['admin-auth']
|
||||
}).define(async (ctx) => {
|
||||
let keys = ctx.query.keys;
|
||||
if (keys.includes(' ')) {
|
||||
keys = keys.replace(/\s+/g, '+');
|
||||
}
|
||||
const hotKeys: Hotkeys = useContextKey('hotkeys', () => new Hotkeys());
|
||||
if (typeof keys === 'string') {
|
||||
await hotKeys.pressHotkey({
|
||||
hotkey: keys,
|
||||
});
|
||||
}
|
||||
ctx.body = 'ok';
|
||||
}).addTo(app);
|
||||
@@ -1,89 +0,0 @@
|
||||
import { keyboard, Key } from "@nut-tree-fork/nut-js";
|
||||
|
||||
/**
|
||||
* 控制功能部分的案件映射
|
||||
*/
|
||||
export const keyMap: Record<string, Key> = {
|
||||
'ctrl': Key.LeftControl,
|
||||
'leftctrl': Key.LeftControl,
|
||||
'rightctrl': Key.RightControl,
|
||||
'alt': Key.LeftAlt,
|
||||
'leftalt': Key.LeftAlt,
|
||||
'rightalt': Key.RightAlt,
|
||||
'shift': Key.LeftShift,
|
||||
'leftshift': Key.LeftShift,
|
||||
'rightshift': Key.RightShift,
|
||||
'meta': Key.LeftSuper,
|
||||
'cmd': Key.LeftCmd,
|
||||
'win': Key.LeftWin,
|
||||
// 根据操作系统选择 Ctrl 或 Command 键
|
||||
'ctrlorcommand': process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
|
||||
};
|
||||
|
||||
/**
|
||||
* 将快捷键字符串转换为 Key 枚举值
|
||||
* @param hotkey
|
||||
* @returns
|
||||
*/
|
||||
export const parseHotkey = (hotkey: string): Key[] => {
|
||||
return hotkey
|
||||
.toLowerCase()
|
||||
.split('+')
|
||||
.map(key => {
|
||||
const trimmed = key.trim().toLowerCase();
|
||||
// 如果是修饰键,从映射表中获取
|
||||
if (keyMap[trimmed]) {
|
||||
return keyMap[trimmed];
|
||||
}
|
||||
// 如果是字母,转换为大写并查找对应的 Key
|
||||
if (trimmed.length === 1 && /[a-z]/.test(trimmed)) {
|
||||
const upperKey = trimmed.toUpperCase();
|
||||
return Key[upperKey as keyof typeof Key] as Key;
|
||||
}
|
||||
// 其他情况直接查找
|
||||
return Key[trimmed as keyof typeof Key] as Key;
|
||||
})
|
||||
.filter((key): key is Key => key !== undefined);
|
||||
}
|
||||
|
||||
type PressHostKeysOptions = {
|
||||
hotkey: string;
|
||||
durationMs?: number;
|
||||
}
|
||||
export const pressHotkey = async (opts: PressHostKeysOptions): Promise<boolean> => {
|
||||
const { hotkey, durationMs = 100 } = opts;
|
||||
const keys = parseHotkey(hotkey);
|
||||
|
||||
console.log('准备模拟按下快捷键:', hotkey);
|
||||
// 同时按下所有键
|
||||
await keyboard.pressKey(...keys);
|
||||
// 短暂延迟后释放
|
||||
await new Promise(resolve => setTimeout(resolve, durationMs));
|
||||
// 释放所有键
|
||||
await keyboard.releaseKey(...keys);
|
||||
return true
|
||||
}
|
||||
|
||||
/**
|
||||
* 模拟按下一组快捷键,支持逗号分隔的多个快捷键
|
||||
* @param opts
|
||||
* @returns
|
||||
*/
|
||||
export const pressHotkeys = async (opts: PressHostKeysOptions): Promise<boolean> => {
|
||||
let { hotkey } = opts;
|
||||
hotkey = hotkey.replace(/\s+/g, ''); // 去除所有空格
|
||||
const hotkeyList = hotkey.split(',').map(hk => hk.trim());
|
||||
if (hotkeyList.length === 0) {
|
||||
return await pressHotkey({ ...opts, hotkey });
|
||||
}
|
||||
for (const hk of hotkeyList) {
|
||||
await pressHotkey({ ...opts, hotkey: hk });
|
||||
// 每个快捷键之间稍作延迟
|
||||
await new Promise(resolve => setTimeout(resolve, 200));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
export class Hotkeys {
|
||||
pressHotkey = pressHotkey;
|
||||
pressHotkeys = pressHotkeys;
|
||||
}
|
||||
@@ -1,20 +1,17 @@
|
||||
import { app, assistantConfig } from '../app.ts';
|
||||
import './config/index.ts';
|
||||
import './client/index.ts';
|
||||
import './shop-install/index.ts';
|
||||
import './ai/index.ts';
|
||||
// TODO:
|
||||
// import './light-code/index.ts';
|
||||
import './user/index.ts';
|
||||
import './call/index.ts'
|
||||
|
||||
// TODO: 移除
|
||||
// import './hot-api/key-sender/index.ts';
|
||||
import './opencode/index.ts';
|
||||
import './remote/index.ts';
|
||||
// import './kevisual/index.ts'
|
||||
|
||||
import os from 'node:os';
|
||||
import { authCache } from '@/module/cache/auth.ts';
|
||||
import { createSkill } from '@kevisual/router';
|
||||
|
||||
import { logger } from '@/module/logger.ts';
|
||||
const getTokenUser = async (token: string) => {
|
||||
const query = assistantConfig.query
|
||||
@@ -64,18 +61,16 @@ export const checkAuth = async (ctx: any, isAdmin = false) => {
|
||||
}
|
||||
authCache.set(token, tokenUser);
|
||||
}
|
||||
if (ctx.state) {
|
||||
ctx.state = {
|
||||
...ctx.state,
|
||||
token,
|
||||
tokenUser,
|
||||
};
|
||||
}
|
||||
ctx.state = {
|
||||
...ctx.state,
|
||||
token,
|
||||
tokenUser,
|
||||
};
|
||||
const { username } = tokenUser;
|
||||
if (!auth.username) {
|
||||
// 初始管理员账号
|
||||
auth.username = username;
|
||||
assistantConfig.setConfig({ auth });
|
||||
assistantConfig.setConfig({ auth, token: token });
|
||||
}
|
||||
if (isAdmin && auth.username) {
|
||||
const admins = config.auth?.admin || [];
|
||||
@@ -83,6 +78,12 @@ export const checkAuth = async (ctx: any, isAdmin = false) => {
|
||||
const admin = auth.username;
|
||||
if (admin === username) {
|
||||
isCheckAdmin = true;
|
||||
const _token = config.token;
|
||||
if (!_token) {
|
||||
assistantConfig.setConfig({ token: token });
|
||||
} else if (_token && _token.startsWith('st-') && _token !== token) {
|
||||
assistantConfig.setConfig({ token: token });
|
||||
}
|
||||
}
|
||||
if (!isCheckAdmin && admins.length > 0 && admins.includes(username)) {
|
||||
isCheckAdmin = true;
|
||||
@@ -106,7 +107,7 @@ app
|
||||
description: '获取当前登录用户信息, 第一个登录的用户为管理员用户',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
if (!ctx.query?.token && ctx.appId === app.appId) {
|
||||
if (!ctx.query?.token && ctx.appId === app.appId) {
|
||||
return;
|
||||
}
|
||||
const authResult = await checkAuth(ctx);
|
||||
@@ -122,7 +123,7 @@ app
|
||||
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
logger.debug('query', ctx.query);
|
||||
// logger.debug('query', ctx.query);
|
||||
if (!ctx.query?.token && ctx.appId === app.appId) {
|
||||
return;
|
||||
}
|
||||
@@ -132,52 +133,3 @@ app
|
||||
}
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'version',
|
||||
description: '获取客户端版本号',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = 'v1.0.0';
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'time',
|
||||
description: '获取当前时间',
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
ctx.body = {
|
||||
time: new Date().getTime(),
|
||||
date: new Date().toLocaleDateString(),
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
app
|
||||
.route({
|
||||
path: 'client',
|
||||
key: 'system',
|
||||
description: '获取系统信息',
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'view-system-info',
|
||||
title: '查看系统信息',
|
||||
summary: '获取服务器操作系统平台、架构和版本信息',
|
||||
})
|
||||
}
|
||||
})
|
||||
.define(async (ctx) => {
|
||||
const { platform, arch, release } = os;
|
||||
ctx.body = {
|
||||
platform: platform(),
|
||||
arch: arch(),
|
||||
release: release(),
|
||||
};
|
||||
})
|
||||
.addTo(app);
|
||||
|
||||
67
assistant/src/routes/kevisual/auth.ts
Normal file
67
assistant/src/routes/kevisual/auth.ts
Normal file
@@ -0,0 +1,67 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { runCmd } from '@/module/cmd/run.ts';
|
||||
import { createSkill, tool } from "@kevisual/router";
|
||||
import { useKey } from '@kevisual/use-config';
|
||||
|
||||
// 查看 ev cli 是否登录
|
||||
app.route({
|
||||
path: 'kevisual',
|
||||
key: ' me',
|
||||
description: '查看 ev cli 是否登录',
|
||||
middleware: ['admin-auth'],
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'kevisual-me',
|
||||
title: '查看 ev cli 是否登录',
|
||||
summary: '查看 ev cli 是否登录',
|
||||
args: {
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const cmd = 'ev me';
|
||||
const res = await runCmd({ cmd })
|
||||
if (res.code === 200) {
|
||||
ctx.body = { content: res.data };
|
||||
} else {
|
||||
ctx.throw(500, res.data);
|
||||
}
|
||||
}).addTo(app);
|
||||
|
||||
// 执行工具 kevisual-login-by-admin
|
||||
// 执行工具 通过当前登录用户 ev cl
|
||||
// 调用 path: kevisual key: loginByAdmin
|
||||
app.route({
|
||||
path: 'kevisual',
|
||||
key: 'loginByAdmin',
|
||||
description: '通过当前登录用户 ev cli',
|
||||
middleware: ['admin-auth'],
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'kevisual-login-by-admin',
|
||||
title: '通过当前登录用户 ev cli',
|
||||
summary: '通过当前登录用户登录 ev cli, 直接用当前的用户的 token 直接设置 token 给 ev cli, 登录失败直接停止任务',
|
||||
args: {}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const token = ctx.query?.token || useKey('KEVISUAL_TOKEN');
|
||||
if (!token) {
|
||||
ctx.throw(400, '登录的 token 不能为空,请传入 token 参数');
|
||||
return;
|
||||
}
|
||||
const cmd = `ev login -e `;
|
||||
const res = await runCmd({
|
||||
cmd,
|
||||
env: {
|
||||
'KEVISUAL_TOKEN': token
|
||||
}
|
||||
})
|
||||
if (res.code === 200) {
|
||||
ctx.body = { content: res.data };
|
||||
} else {
|
||||
ctx.throw(500, res.data);
|
||||
}
|
||||
}).addTo(app);
|
||||
45
assistant/src/routes/kevisual/deploy.ts
Normal file
45
assistant/src/routes/kevisual/deploy.ts
Normal file
@@ -0,0 +1,45 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { runCmd } from '@/module/cmd/run.ts';
|
||||
import { createSkill, tool } from "@kevisual/router";
|
||||
|
||||
// 调用 path: kevisual key: deploy
|
||||
app.route({
|
||||
path: 'kevisual',
|
||||
key: 'deploy',
|
||||
description: '部署一个网页',
|
||||
middleware: ['admin-auth'],
|
||||
metadata: {
|
||||
tags: ['kevisual'],
|
||||
...createSkill({
|
||||
skill: 'kevisual-deploy',
|
||||
title: '部署一个网页',
|
||||
summary: '部署一个网页到 kevisual 平台',
|
||||
args: {
|
||||
filepath: tool.schema.string().describe('要部署的网页文件路径'),
|
||||
appKey: tool.schema.string().optional().describe('应用的 appKey,如果不传则创建一个新的应用'),
|
||||
version: tool.schema.string().optional().describe('应用的版本号,默认为 1.0.0'),
|
||||
update: tool.schema.boolean().optional().describe('是否同时更新部署,默认为 false'),
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const { filepath, appKey, update } = ctx.query;
|
||||
console.log('部署网页,filepath:', filepath, 'appKey:', appKey);
|
||||
|
||||
ctx.body = { content: '部署功能正在开发中,敬请期待!' };
|
||||
// ev deloly ${filepath} -k ${appKey} -v 1.0.0 -u -y y
|
||||
// if (!filepath) {
|
||||
// ctx.throw(400, '文件路径 filepath 不能为空');
|
||||
// return;
|
||||
// }
|
||||
// let cmd = `ev deploy ${filepath} --type web`;
|
||||
// if (appKey) {
|
||||
// cmd += ` --appKey ${appKey}`;
|
||||
// }
|
||||
// const res = await runCmd({ cmd });
|
||||
// if (res.code === 200) {
|
||||
// ctx.body = { content: res.data };
|
||||
// } else {
|
||||
// ctx.throw(500, res.data);
|
||||
// }
|
||||
}).addTo(app);
|
||||
2
assistant/src/routes/kevisual/index.ts
Normal file
2
assistant/src/routes/kevisual/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import './auth.ts'
|
||||
import './deploy.ts'
|
||||
@@ -1,31 +0,0 @@
|
||||
import { app, assistantConfig } from '../../app.ts'
|
||||
import path from 'path'
|
||||
import { runCode } from '../../module/light-code/run.ts'
|
||||
|
||||
// http://localhost:4005/api/router?path=call
|
||||
app.route({
|
||||
path: 'light-code',
|
||||
key: 'call',
|
||||
// middleware: ['auth']
|
||||
}).define(async (ctx) => {
|
||||
const filename = ctx.query?.filename || 'root/light-code-demo/demo-router.ts'
|
||||
const data = ctx.query?.data || {}
|
||||
const appsConfigPath = assistantConfig.configPath?.appsDir || '';
|
||||
const testA = path.join(appsConfigPath, filename)
|
||||
try {
|
||||
const resulst = await runCode(testA, data);
|
||||
if (resulst.success) {
|
||||
const callResult = resulst.data;
|
||||
if (callResult.code === 200) ctx.body = callResult.data
|
||||
else {
|
||||
const callError = `调用程序错误: ${callResult.message}`
|
||||
ctx.throw(callResult.code, callError)
|
||||
}
|
||||
} else {
|
||||
ctx.body = `执行脚本错误: ${resulst.error}`
|
||||
}
|
||||
} catch (error) {
|
||||
ctx.body = `执行脚本异常: ${error?.message || error}`
|
||||
}
|
||||
|
||||
}).addTo(app)
|
||||
@@ -1,3 +0,0 @@
|
||||
import './call.ts'
|
||||
|
||||
import './upload.ts'
|
||||
5
assistant/src/routes/light-code/reload.ts
Normal file
5
assistant/src/routes/light-code/reload.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
// TODO: 重载 light-code
|
||||
import { initLightCode } from "@/module/light-code/index.ts";
|
||||
|
||||
// 下载最新代码,覆盖本地文件
|
||||
// 重新启动 light-code 相关服务
|
||||
@@ -1,10 +0,0 @@
|
||||
import { app } from '../../app.ts';
|
||||
|
||||
app.route({
|
||||
path: 'light-code',
|
||||
key: 'upload',
|
||||
middleware: ['auth'],
|
||||
description: '上传轻代码应用代码',
|
||||
}).define(async (ctx) => {
|
||||
const files = ctx.query.files;
|
||||
}).addTo(app);
|
||||
2
assistant/src/routes/manager/index.ts
Normal file
2
assistant/src/routes/manager/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
import { AssistantApp } from '../../module/assistant/local-app-manager/assistant-app.ts';
|
||||
// AssistantApp
|
||||
@@ -1,8 +1,6 @@
|
||||
import { app } from '@/app.ts'
|
||||
import { createSkill, tool } from "@kevisual/router";
|
||||
import { opencodeManager } from './module/open.ts'
|
||||
import path from "node:path";
|
||||
import { execSync } from "node:child_process";
|
||||
import { useKey } from '@kevisual/use-config';
|
||||
|
||||
// 创建一个opencode 客户端
|
||||
@@ -18,16 +16,16 @@ app.route({
|
||||
title: '创建 OpenCode 客户端',
|
||||
summary: '创建 OpenCode 客户端,如果存在则复用',
|
||||
args: {
|
||||
|
||||
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const client = await opencodeManager.getClient();
|
||||
const client = await opencodeManager.getClient({ port: ctx.query.port });
|
||||
ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已就绪` };
|
||||
}).addTo(app);
|
||||
|
||||
// 关闭 opencode 客户端
|
||||
// 关闭 opencode 客户端 5000
|
||||
app.route({
|
||||
path: 'opencode',
|
||||
key: 'close',
|
||||
@@ -38,17 +36,39 @@ app.route({
|
||||
...createSkill({
|
||||
skill: 'close-opencode-client',
|
||||
title: '关闭 OpenCode 客户端',
|
||||
summary: '关闭 OpenCode 客户端',
|
||||
summary: '关闭 OpenCode 客户端, 未提供端口则关闭默认端口',
|
||||
args: {
|
||||
|
||||
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
await opencodeManager.close();
|
||||
const port = ctx.query.port;
|
||||
await opencodeManager.close({ port });
|
||||
ctx.body = { content: 'OpenCode 客户端已关闭' };
|
||||
}).addTo(app);
|
||||
|
||||
app.route({
|
||||
path: 'opencode',
|
||||
key: 'restart',
|
||||
middleware: ['auth'],
|
||||
description: '重启 OpenCode 客户端',
|
||||
metadata: {
|
||||
tags: ['opencode'],
|
||||
...createSkill({
|
||||
skill: 'restart-opencode-client',
|
||||
title: '重启 OpenCode 客户端',
|
||||
summary: '重启 OpenCode 客户端',
|
||||
args: {
|
||||
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const port = ctx.query.port;
|
||||
const res = await opencodeManager.restart({ port });
|
||||
ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已经重启` };
|
||||
}).addTo(app);
|
||||
// 调用 path: opencode key: getUrl
|
||||
app.route({
|
||||
path: 'opencode',
|
||||
@@ -62,12 +82,12 @@ app.route({
|
||||
title: '获取 OpenCode 服务 URL',
|
||||
summary: '获取当前 OpenCode 服务的 URL 地址',
|
||||
args: {
|
||||
|
||||
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
|
||||
}
|
||||
})
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
const url = await opencodeManager.getUrl();
|
||||
const url = await opencodeManager.getUrl({ port: ctx.query.port });
|
||||
const cnbURL = useKey('CNB_VSCODE_PROXY_URI') as string | undefined;
|
||||
let content = `本地访问地址: ${url}`
|
||||
if (cnbURL) {
|
||||
@@ -75,6 +95,7 @@ app.route({
|
||||
}
|
||||
ctx.body = { content };
|
||||
}).addTo(app);
|
||||
|
||||
// 调用 path: opencode key: ls-projects
|
||||
app.route({
|
||||
path: 'opencode',
|
||||
|
||||
@@ -1,18 +1,15 @@
|
||||
import { createOpencode, createOpencodeClient, OpencodeClient, } from "@opencode-ai/sdk";
|
||||
import { randomInt } from "es-toolkit";
|
||||
import getPort from "get-port";
|
||||
import os from "node:os";
|
||||
import path from "node:path";
|
||||
import fs from "node:fs";
|
||||
import { execSync } from "node:child_process";
|
||||
|
||||
const DEFAULT_PORT = 5000;
|
||||
|
||||
export class OpencodeManager {
|
||||
private static instance: OpencodeManager | null = null;
|
||||
private client: OpencodeClient | null = null;
|
||||
private server: { url: string; close(): void } | null = null;
|
||||
private isInitializing: boolean = false;
|
||||
private currentPort: number | null = null;
|
||||
|
||||
public url: string = '';
|
||||
private constructor() { }
|
||||
@@ -24,23 +21,21 @@ export class OpencodeManager {
|
||||
return OpencodeManager.instance;
|
||||
}
|
||||
|
||||
async getClient(): Promise<OpencodeClient> {
|
||||
// 如果已经有 client,直接返回
|
||||
async getClient(opts?: { port?: number }): Promise<OpencodeClient> {
|
||||
const port = opts?.port ?? DEFAULT_PORT;
|
||||
|
||||
if (this.client) {
|
||||
return this.client;
|
||||
}
|
||||
|
||||
// 如果正在初始化,等待初始化完成
|
||||
if (this.isInitializing) {
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
return this.getClient();
|
||||
return this.getClient(opts);
|
||||
}
|
||||
|
||||
// 开始初始化
|
||||
this.isInitializing = true;
|
||||
try {
|
||||
const port = 5000;
|
||||
const currentPort = await getPort({ port: port });
|
||||
const currentPort = await getPort({ port });
|
||||
if (port === currentPort) {
|
||||
const result = await createOpencode({
|
||||
hostname: '0.0.0.0',
|
||||
@@ -59,6 +54,7 @@ export class OpencodeManager {
|
||||
this.isInitializing = false;
|
||||
}
|
||||
}
|
||||
|
||||
async createOpencodeProject({
|
||||
directory,
|
||||
port = 5000
|
||||
@@ -69,11 +65,10 @@ export class OpencodeManager {
|
||||
});
|
||||
return client;
|
||||
}
|
||||
|
||||
async killPort(port: number): Promise<void> {
|
||||
try {
|
||||
// 尝试 使用命令行去关闭 port为5000的服务
|
||||
if (os.platform() === 'win32') {
|
||||
// Windows 平台
|
||||
execSync(`netstat -ano | findstr :${port} | findstr LISTENING`).toString().split('\n').forEach(line => {
|
||||
const parts = line.trim().split(/\s+/);
|
||||
const pid = parts[parts.length - 1];
|
||||
@@ -83,7 +78,6 @@ export class OpencodeManager {
|
||||
}
|
||||
});
|
||||
} else {
|
||||
// Unix-like 平台
|
||||
const result = execSync(`lsof -i :${port} -t`).toString();
|
||||
result.split('\n').forEach(pid => {
|
||||
if (pid) {
|
||||
@@ -96,14 +90,15 @@ export class OpencodeManager {
|
||||
console.error('Failed to close OpenCode server:', error);
|
||||
}
|
||||
}
|
||||
async close(): Promise<void> {
|
||||
|
||||
async close(opts?: { port?: number }): Promise<void> {
|
||||
if (this.server) {
|
||||
this.server.close();
|
||||
this.server = null;
|
||||
return
|
||||
}
|
||||
const port = 5000;
|
||||
const currentPort = await getPort({ port: port });
|
||||
const port = opts?.port ?? DEFAULT_PORT;
|
||||
const currentPort = await getPort({ port });
|
||||
if (port === currentPort) {
|
||||
this.client = null;
|
||||
return;
|
||||
@@ -113,14 +108,21 @@ export class OpencodeManager {
|
||||
|
||||
this.client = null;
|
||||
}
|
||||
async getUrl(): Promise<string> {
|
||||
|
||||
async getUrl(opts?: { port?: number }): Promise<string> {
|
||||
const port = opts?.port ?? DEFAULT_PORT;
|
||||
if (this.url) {
|
||||
return this.url;
|
||||
}
|
||||
if (!this.url) {
|
||||
await this.getClient();
|
||||
await this.getClient(opts);
|
||||
}
|
||||
return 'http://localhost:5000';
|
||||
return `http://localhost:${port}`;
|
||||
}
|
||||
async restart(opts?: { port?: number }): Promise<OpencodeClient> {
|
||||
const port = opts?.port ?? DEFAULT_PORT;
|
||||
await this.close({ port });
|
||||
return await this.getClient({ port });
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useContextKey } from '@kevisual/context';
|
||||
import { app, assistantConfig } from './app.ts';
|
||||
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
|
||||
import { app, assistantConfig, runtime } from './app.ts';
|
||||
import { proxyLivecodeWs, proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
|
||||
import './routes/index.ts';
|
||||
import './routes-simple/index.ts';
|
||||
|
||||
@@ -49,6 +49,7 @@ export const runServer = async (port: number = 51515, listenPath = '127.0.0.1')
|
||||
func: proxyRoute as any,
|
||||
},
|
||||
...proxyWs(),
|
||||
...proxyLivecodeWs(),
|
||||
qwenAsr,
|
||||
]);
|
||||
const manager = useContextKey('manager', new AssistantApp(assistantConfig, app));
|
||||
@@ -58,6 +59,9 @@ export const runServer = async (port: number = 51515, listenPath = '127.0.0.1')
|
||||
});
|
||||
manager.initRemoteApp()
|
||||
manager.initRouterApp()
|
||||
if (runtime.isServer) {
|
||||
manager.initRoutes();
|
||||
}
|
||||
}, 1000);
|
||||
|
||||
return {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts';
|
||||
import path from 'path';
|
||||
import fs from 'fs';
|
||||
import inquirer from 'inquirer';
|
||||
import { confirm } from '@inquirer/prompts';
|
||||
|
||||
import { spawnSync } from 'child_process';
|
||||
export const runCommand = (command: string, args: string[]) => {
|
||||
@@ -91,15 +91,10 @@ export class AppDownload {
|
||||
return runCommand(command, args);
|
||||
}
|
||||
async confirm(message?: string) {
|
||||
const { confirm } = await inquirer.prompt([
|
||||
{
|
||||
type: 'confirm',
|
||||
name: 'confirm',
|
||||
message: message || '是否继续删除应用?',
|
||||
default: false,
|
||||
},
|
||||
]);
|
||||
return confirm;
|
||||
return await confirm({
|
||||
message: message || '是否继续删除应用?',
|
||||
default: false,
|
||||
});
|
||||
}
|
||||
async deleteApp(opts: DeleteAppOptions) {
|
||||
const { id, type = 'web', yes = false } = opts;
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { QwenAsrRelatime } from "@kevisual/video-tools/src/asr/index.ts";
|
||||
|
||||
import { Listener, WebSocketListenerFun, WebSocketReq } from "@kevisual/router";
|
||||
import { callText } from "@/routes/ha-api/ha.ts";
|
||||
import { assistantConfig } from "@/app.ts";
|
||||
|
||||
const func: WebSocketListenerFun = async (req: WebSocketReq<{ asr: QwenAsrRelatime, msgId: string, startTime?: number, loading?: boolean }>, res) => {
|
||||
@@ -60,8 +59,6 @@ const func: WebSocketListenerFun = async (req: WebSocketReq<{ asr: QwenAsrRelati
|
||||
time: Date.now(),
|
||||
text,
|
||||
}));
|
||||
if (!text) return;
|
||||
await callText(text);
|
||||
console.log('toogle light time', Date.now() - endTime);
|
||||
});
|
||||
asr.start();
|
||||
|
||||
@@ -136,11 +136,11 @@ export class AssistantInit extends AssistantConfig {
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.975.0",
|
||||
"@aws-sdk/client-s3": "^3.978.0",
|
||||
"@kevisual/oss": "^0.0.16",
|
||||
"@kevisual/query": "^0.0.38",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"@kevisual/router": "^0.0.62",
|
||||
"@kevisual/router": "^0.0.64",
|
||||
"@kevisual/use-config": "^1.0.28",
|
||||
"ioredis": "^5.9.2",
|
||||
"minio": "^8.0.6",
|
||||
@@ -157,16 +157,18 @@ export class AssistantInit extends AssistantConfig {
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^25.0.10"
|
||||
"@types/node": "^25.1.0"
|
||||
}
|
||||
}
|
||||
`,
|
||||
);
|
||||
console.log(chalk.green('助手 package.json 文件创建成功, 正在安装依赖...'));
|
||||
installDeps({ appPath: path.dirname(packagePath), isProduction: true }).then(() => {
|
||||
console.log('------------------------------------------------');
|
||||
console.log(chalk.green('助手依赖安装完成'));
|
||||
console.log('------------------------------------------------');
|
||||
});
|
||||
}
|
||||
return {
|
||||
|
||||
@@ -12,15 +12,15 @@
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@aws-sdk/client-s3": "^3.975.0",
|
||||
"@kevisual/oss": "^0.0.16",
|
||||
"@kevisual/query": "^0.0.38",
|
||||
"@aws-sdk/client-s3": "^3.981.0",
|
||||
"@kevisual/oss": "^0.0.19",
|
||||
"@kevisual/query": "^0.0.39",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"@kevisual/router": "^0.0.62",
|
||||
"@kevisual/use-config": "^1.0.28",
|
||||
"@kevisual/router": "^0.0.70",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"ioredis": "^5.9.2",
|
||||
"minio": "^8.0.6",
|
||||
"pg": "^8.17.2",
|
||||
"pg": "^8.18.0",
|
||||
"pm2": "^6.0.14",
|
||||
"sequelize": "^6.37.7",
|
||||
"crypto-js": "^4.2.0",
|
||||
@@ -33,8 +33,8 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/node": "^25.0.10"
|
||||
"@types/node": "^25.2.0"
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,14 @@
|
||||
import { createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts';
|
||||
import http from 'node:http';
|
||||
import { LocalProxy } from './local-proxy.ts';
|
||||
import { assistantConfig, simpleRouter } from '@/app.ts';
|
||||
import { assistantConfig, simpleRouter, app } from '@/app.ts';
|
||||
import { log, logger } from '@/module/logger.ts';
|
||||
import { getToken } from '@/module/http-token.ts';
|
||||
import { getTokenUserCache } from '@/routes/index.ts';
|
||||
import type { WebSocketListenerFun } from "@kevisual/router";
|
||||
import WebSocket from 'ws';
|
||||
import { renderNoAuthAndLogin } from '@/module/assistant/html/login.ts';
|
||||
import { LiveCode } from '@/module/livecode/index.ts';
|
||||
const localProxy = new LocalProxy({});
|
||||
localProxy.initFromAssistantConfig(assistantConfig);
|
||||
|
||||
@@ -234,6 +235,27 @@ export const proxyWs = () => {
|
||||
}
|
||||
return proxyApi.map(createProxyInfo);
|
||||
};
|
||||
const liveCode = new LiveCode(app)
|
||||
export const proxyLivecodeWs = () => {
|
||||
const livecode = assistantConfig.getCacheAssistantConfig()?.router?.livecode ?? true;
|
||||
if (!livecode) {
|
||||
return [];
|
||||
}
|
||||
const fun: WebSocketListenerFun = async (req, res) => {
|
||||
const { ws, emitter, id, data } = req;
|
||||
// if (!id) {
|
||||
// ws.send(JSON.stringify({ type: 'error', message: 'not found id' }));
|
||||
// ws.close();
|
||||
// return;
|
||||
// }
|
||||
liveCode.conn(req)
|
||||
}
|
||||
return [{
|
||||
path: '/livecode/ws',
|
||||
io: true,
|
||||
func: fun
|
||||
}]
|
||||
}
|
||||
export const createProxyInfo = (proxyApiItem: ProxyInfo) => {
|
||||
const func: WebSocketListenerFun = async (req, res) => {
|
||||
const { ws, emitter, id, data } = req;
|
||||
|
||||
215
assistant/src/test/live-app-origin.ts
Normal file
215
assistant/src/test/live-app-origin.ts
Normal file
@@ -0,0 +1,215 @@
|
||||
import { App } from '@kevisual/router'
|
||||
import { WebSocket } from 'ws'
|
||||
import net from 'net';
|
||||
|
||||
type ReconnectConfig = {
|
||||
maxRetries?: number; // 最大重试次数,默认无限
|
||||
retryDelay?: number; // 重试延迟(ms),默认1000
|
||||
maxDelay?: number; // 最大延迟(ms),默认30000
|
||||
backoffMultiplier?: number; // 退避倍数,默认2
|
||||
};
|
||||
|
||||
class ReconnectingWebSocket {
|
||||
private ws: WebSocket | null = null;
|
||||
private url: string;
|
||||
private config: Required<ReconnectConfig>;
|
||||
private retryCount: number = 0;
|
||||
private reconnectTimer: NodeJS.Timeout | null = null;
|
||||
private isManualClose: boolean = false;
|
||||
private messageHandlers: Array<(data: any) => void> = [];
|
||||
private openHandlers: Array<() => void> = [];
|
||||
private closeHandlers: Array<(code: number, reason: Buffer) => void> = [];
|
||||
private errorHandlers: Array<(error: Error) => void> = [];
|
||||
|
||||
constructor(url: string, config: ReconnectConfig = {}) {
|
||||
this.url = url;
|
||||
this.config = {
|
||||
maxRetries: config.maxRetries ?? Infinity,
|
||||
retryDelay: config.retryDelay ?? 1000,
|
||||
maxDelay: config.maxDelay ?? 30000,
|
||||
backoffMultiplier: config.backoffMultiplier ?? 2,
|
||||
};
|
||||
}
|
||||
|
||||
connect(): void {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`正在连接到 ${this.url}...`);
|
||||
this.ws = new WebSocket(this.url);
|
||||
|
||||
this.ws.on('open', () => {
|
||||
console.log('WebSocket 连接已打开');
|
||||
this.retryCount = 0;
|
||||
this.openHandlers.forEach(handler => handler());
|
||||
this.send({ type: 'heartbeat', timestamp: new Date().toISOString() });
|
||||
});
|
||||
|
||||
this.ws.on('message', (data: any) => {
|
||||
this.messageHandlers.forEach(handler => {
|
||||
try {
|
||||
const message = JSON.parse(data.toString());
|
||||
handler(message);
|
||||
} catch {
|
||||
handler(data.toString());
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
this.ws.on('close', (code: number, reason: Buffer) => {
|
||||
console.log(`WebSocket 连接已关闭: code=${code}, reason=${reason.toString()}`);
|
||||
this.closeHandlers.forEach(handler => handler(code, reason));
|
||||
|
||||
if (!this.isManualClose) {
|
||||
this.scheduleReconnect();
|
||||
}
|
||||
});
|
||||
|
||||
this.ws.on('error', (error: Error) => {
|
||||
console.error('WebSocket 错误:', error.message);
|
||||
this.errorHandlers.forEach(handler => handler(error));
|
||||
});
|
||||
}
|
||||
|
||||
private scheduleReconnect(): void {
|
||||
if (this.reconnectTimer) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.retryCount >= this.config.maxRetries) {
|
||||
console.error(`已达到最大重试次数 (${this.config.maxRetries}),停止重连`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 计算延迟(指数退避)
|
||||
const delay = Math.min(
|
||||
this.config.retryDelay * Math.pow(this.config.backoffMultiplier, this.retryCount),
|
||||
this.config.maxDelay
|
||||
);
|
||||
|
||||
this.retryCount++;
|
||||
console.log(`将在 ${delay}ms 后进行第 ${this.retryCount} 次重连尝试...`);
|
||||
|
||||
this.reconnectTimer = setTimeout(() => {
|
||||
this.reconnectTimer = null;
|
||||
this.connect();
|
||||
}, delay);
|
||||
}
|
||||
|
||||
send(data: any): boolean {
|
||||
if (this.ws?.readyState === WebSocket.OPEN) {
|
||||
this.ws.send(JSON.stringify(data));
|
||||
return true;
|
||||
}
|
||||
console.warn('WebSocket 未连接,无法发送消息');
|
||||
return false;
|
||||
}
|
||||
|
||||
onMessage(handler: (data: any) => void): void {
|
||||
this.messageHandlers.push(handler);
|
||||
}
|
||||
|
||||
onOpen(handler: () => void): void {
|
||||
this.openHandlers.push(handler);
|
||||
}
|
||||
|
||||
onClose(handler: (code: number, reason: Buffer) => void): void {
|
||||
this.closeHandlers.push(handler);
|
||||
}
|
||||
|
||||
onError(handler: (error: Error) => void): void {
|
||||
this.errorHandlers.push(handler);
|
||||
}
|
||||
|
||||
close(): void {
|
||||
this.isManualClose = true;
|
||||
if (this.reconnectTimer) {
|
||||
clearTimeout(this.reconnectTimer);
|
||||
this.reconnectTimer = null;
|
||||
}
|
||||
if (this.ws) {
|
||||
this.ws.close();
|
||||
this.ws = null;
|
||||
}
|
||||
}
|
||||
|
||||
getReadyState(): number {
|
||||
return this.ws?.readyState ?? WebSocket.CLOSED;
|
||||
}
|
||||
|
||||
getRetryCount(): number {
|
||||
return this.retryCount;
|
||||
}
|
||||
}
|
||||
|
||||
const app = new App();
|
||||
|
||||
app.route({
|
||||
path: 'livecode-status',
|
||||
description: 'LiveCode 状态路由',
|
||||
metadata: {
|
||||
tags: ['livecode', 'status'],
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
ctx.body = {
|
||||
status: 'LiveCode 模块运行正常',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}).addTo(app)
|
||||
|
||||
app.createRouteList();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// 创建支持断开重连的 WebSocket 客户端
|
||||
const ws = new ReconnectingWebSocket('ws://localhost:51516/livecode/ws?id=test-live-app', {
|
||||
maxRetries: Infinity, // 无限重试
|
||||
retryDelay: 1000, // 初始重试延迟 1 秒
|
||||
maxDelay: 30000, // 最大延迟 30 秒
|
||||
backoffMultiplier: 2, // 指数退避倍数
|
||||
});
|
||||
|
||||
ws.onMessage(async (message) => {
|
||||
console.log('收到消息:', message);
|
||||
if (message.type === 'router' && message.id) {
|
||||
console.log('收到路由响应:', message);
|
||||
const data = message?.data;
|
||||
if (!data) {
|
||||
ws.send({
|
||||
type: 'router',
|
||||
id: message.id,
|
||||
data: { code: 500, message: 'No data received' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const res = await app.run(message.data);
|
||||
console.log('路由处理结果:', res);
|
||||
ws.send({
|
||||
type: 'router',
|
||||
id: message.id,
|
||||
data: res
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.onOpen(() => {
|
||||
console.log('连接已建立,可以开始通信');
|
||||
});
|
||||
|
||||
ws.onError((error) => {
|
||||
console.error('连接错误:', error.message);
|
||||
});
|
||||
|
||||
ws.onClose((code, reason) => {
|
||||
console.log(`连接关闭: ${code} - ${reason.toString()}`);
|
||||
});
|
||||
|
||||
// 启动连接
|
||||
ws.connect();
|
||||
|
||||
|
||||
net.createServer((socket) => {
|
||||
console.log('TCP 客户端已连接');
|
||||
}).listen(61616, () => {
|
||||
console.log('TCP 服务器正在监听端口 61616');
|
||||
});
|
||||
85
assistant/src/test/live-app.ts
Normal file
85
assistant/src/test/live-app.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
|
||||
import { App, ListenProcessResponse } from '@kevisual/router'
|
||||
import { WebSocket } from 'ws'
|
||||
import { ReconnectingWebSocket, handleCallApp } from '@kevisual/router/ws'
|
||||
import net from 'net';
|
||||
|
||||
const app = new App();
|
||||
|
||||
app.route({
|
||||
path: 'livecode-status',
|
||||
description: 'LiveCode 状态路由',
|
||||
metadata: {
|
||||
tags: ['livecode', 'status'],
|
||||
},
|
||||
}).define(async (ctx) => {
|
||||
ctx.body = {
|
||||
status: 'LiveCode 模块运行正常',
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
}).addTo(app)
|
||||
|
||||
app.createRouteList();
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
|
||||
// 创建支持断开重连的 WebSocket 客户端
|
||||
const ws = new ReconnectingWebSocket('ws://localhost:51515/livecode/ws?id=test-live-app', {
|
||||
maxRetries: Infinity, // 无限重试
|
||||
retryDelay: 1000, // 初始重试延迟 1 秒
|
||||
maxDelay: 30000, // 最大延迟 30 秒
|
||||
backoffMultiplier: 2, // 指数退避倍数
|
||||
});
|
||||
ws.onMessage(async (message) => {
|
||||
console.log('收到消息:', message);
|
||||
if (message.type === 'router' && message.id) {
|
||||
console.log('收到路由响应:', message);
|
||||
const data = message?.data as ListenProcessResponse;
|
||||
if (!data) {
|
||||
ws.send({
|
||||
type: 'router',
|
||||
id: message.id,
|
||||
data: { code: 500, message: 'No data received' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const msg = data.message;
|
||||
if (!msg) {
|
||||
ws.send({
|
||||
type: 'router',
|
||||
id: message.id,
|
||||
data: { code: 500, message: 'No {message} received' }
|
||||
});
|
||||
return;
|
||||
}
|
||||
const context = data.context || {};
|
||||
const res = await app.run(msg, context);
|
||||
console.log('路由处理结果:', res);
|
||||
ws.send({
|
||||
type: 'router',
|
||||
id: message.id,
|
||||
data: res
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
ws.onOpen(() => {
|
||||
console.log('连接已建立,可以开始通信');
|
||||
});
|
||||
|
||||
ws.onError((error) => {
|
||||
console.error('连接错误:', error.message);
|
||||
});
|
||||
|
||||
ws.onClose((code, reason) => {
|
||||
console.log(`连接关闭: ${code} - ${reason.toString()}`);
|
||||
});
|
||||
|
||||
// 启动连接
|
||||
ws.connect();
|
||||
|
||||
|
||||
net.createServer((socket) => {
|
||||
console.log('TCP 客户端已连接');
|
||||
}).listen(61616, () => {
|
||||
console.log('TCP 服务器正在监听端口 61616');
|
||||
});
|
||||
@@ -1,14 +1,14 @@
|
||||
{
|
||||
"name": "@kevisual/cli",
|
||||
"name": "@kevisual/cli-docs",
|
||||
"version": "0.0.3",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"basename": "/root/cli",
|
||||
"basename": "/root/cli-docs",
|
||||
"scripts": {
|
||||
"dev": "astro dev",
|
||||
"build": "astro build",
|
||||
"preview": "astro preview",
|
||||
"pub": "envision deploy ./dist -k cli -v 0.0.4 -u -y y",
|
||||
"pub": "envision deploy ./dist -k cli-docs -v 0.0.4 -u -y y",
|
||||
"slide:dev": "slidev --open slides/index.md",
|
||||
"slide:build": "slidev build slides/index.md --base /root/cli-slide/",
|
||||
"slide:pub": "envision deploy ./slides/dist -k cli-slide -v 0.0.4 -u",
|
||||
@@ -23,46 +23,46 @@
|
||||
"@astrojs/react": "^4.4.2",
|
||||
"@astrojs/sitemap": "^3.7.0",
|
||||
"@astrojs/vue": "^5.1.4",
|
||||
"@kevisual/api": "^0.0.17",
|
||||
"@kevisual/api": "^0.0.28",
|
||||
"@kevisual/context": "^0.0.4",
|
||||
"@kevisual/kv-code": "^0.0.4",
|
||||
"@kevisual/query": "^0.0.35",
|
||||
"@kevisual/query": "^0.0.38",
|
||||
"@kevisual/query-login": "^0.0.7",
|
||||
"@kevisual/registry": "^0.0.1",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"@tailwindcss/vite": "^4.1.18",
|
||||
"@uiw/react-md-editor": "^4.0.11",
|
||||
"antd": "^6.2.0",
|
||||
"astro": "^5.16.11",
|
||||
"antd": "^6.2.2",
|
||||
"astro": "^5.16.15",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"dayjs": "^1.11.19",
|
||||
"es-toolkit": "^1.44.0",
|
||||
"github-markdown-css": "^5.8.1",
|
||||
"highlight.js": "^11.11.1",
|
||||
"lucide-react": "^0.562.0",
|
||||
"lucide-react": "^0.563.0",
|
||||
"marked": "^17.0.1",
|
||||
"marked-highlight": "^2.2.3",
|
||||
"nanoid": "^5.1.6",
|
||||
"react": "^19.2.3",
|
||||
"react-dom": "^19.2.3",
|
||||
"react": "^19.2.4",
|
||||
"react-dom": "^19.2.4",
|
||||
"react-toastify": "^11.0.5",
|
||||
"tailwind-merge": "^3.4.0",
|
||||
"vue": "^3.5.26",
|
||||
"vue": "^3.5.27",
|
||||
"zustand": "^5.0.10"
|
||||
},
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/types": "^0.0.11",
|
||||
"@types/react": "^19.2.8",
|
||||
"@kevisual/types": "^0.0.12",
|
||||
"@types/react": "^19.2.10",
|
||||
"@types/react-dom": "^19.2.3",
|
||||
"dotenv": "^17.2.3",
|
||||
"tailwindcss": "^4.1.18",
|
||||
"tw-animate-css": "^1.4.0"
|
||||
},
|
||||
"packageManager": "pnpm@10.28.0",
|
||||
"packageManager": "pnpm@10.28.2",
|
||||
"onlyBuiltDependencies": [
|
||||
"@tailwindcss/oxide",
|
||||
"esbuild",
|
||||
23
package.json
23
package.json
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@kevisual/cli",
|
||||
"version": "0.0.94",
|
||||
"version": "0.1.2",
|
||||
"description": "envision 命令行工具",
|
||||
"type": "module",
|
||||
"basename": "/root/cli",
|
||||
@@ -48,33 +48,34 @@
|
||||
"@kevisual/app": "^0.0.2",
|
||||
"@kevisual/auth": "^2.0.3",
|
||||
"@kevisual/context": "^0.0.4",
|
||||
"@kevisual/use-config": "^1.0.28",
|
||||
"@opencode-ai/sdk": "^1.1.36",
|
||||
"@kevisual/use-config": "^1.0.30",
|
||||
"@opencode-ai/sdk": "^1.1.50",
|
||||
"@types/busboy": "^1.5.4",
|
||||
"busboy": "^1.6.0",
|
||||
"eventemitter3": "^5.0.4",
|
||||
"jose": "^6.1.3",
|
||||
"lowdb": "^7.0.1",
|
||||
"lru-cache": "^11.2.4",
|
||||
"lru-cache": "^11.2.5",
|
||||
"micromatch": "^4.0.8",
|
||||
"pm2": "latest",
|
||||
"semver": "^7.7.3",
|
||||
"unstorage": "^1.17.4"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@kevisual/api": "^0.0.44",
|
||||
"@kevisual/dts": "^0.0.3",
|
||||
"@kevisual/load": "^0.0.6",
|
||||
"@kevisual/logger": "^0.0.4",
|
||||
"@kevisual/query": "0.0.38",
|
||||
"@kevisual/query": "0.0.39",
|
||||
"@kevisual/query-login": "0.0.7",
|
||||
"@types/bun": "^1.3.6",
|
||||
"@types/bun": "^1.3.8",
|
||||
"@types/crypto-js": "^4.2.2",
|
||||
"@types/jsonwebtoken": "^9.0.10",
|
||||
"@types/micromatch": "^4.0.10",
|
||||
"@types/node": "^25.0.10",
|
||||
"@types/node": "^25.2.0",
|
||||
"@types/semver": "^7.7.1",
|
||||
"chalk": "^5.6.2",
|
||||
"commander": "^14.0.2",
|
||||
"commander": "^14.0.3",
|
||||
"crypto-js": "^4.2.0",
|
||||
"fast-glob": "^3.3.3",
|
||||
"filesize": "^11.0.13",
|
||||
@@ -82,8 +83,8 @@
|
||||
"ignore": "^7.0.5",
|
||||
"jsonwebtoken": "^9.0.3",
|
||||
"pm2": "^6.0.14",
|
||||
"tar": "^7.5.6",
|
||||
"zustand": "^5.0.10"
|
||||
"tar": "^7.5.7",
|
||||
"zustand": "^5.0.11"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=22.0.0"
|
||||
@@ -91,4 +92,4 @@
|
||||
"publishConfig": {
|
||||
"access": "public"
|
||||
}
|
||||
}
|
||||
}
|
||||
2107
pnpm-lock.yaml
generated
2107
pnpm-lock.yaml
generated
File diff suppressed because it is too large
Load Diff
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user