Compare commits

...

25 Commits

Author SHA1 Message Date
f912003ca9 feat: 添加 packages 到 pnpm 工作区配置 2026-02-09 04:33:28 +08:00
0ba8c9a7dc update 2026-02-06 18:21:27 +08:00
8cda5f6be3 fix:fix reconnect 2026-02-05 16:51:40 +08:00
8fc9605242 feat: 添加自动重连机制和重连配置选项到 RemoteApp 类,并更新相关文档 2026-02-05 14:09:14 +08:00
a46510949b feat: 更新 RemoteApp 类以支持 ListenProcessParams 和改进消息处理逻辑
fix: 更新 package.json 版本号至 0.1.2
feat: 在部署函数中添加 detect 标志以增强功能
2026-02-05 03:00:51 +08:00
037146bf51 update 2026-02-05 00:14:29 +08:00
5d6bd4f429 Refactor client routes and add IP fetching functionality
- Moved client route definitions to separate files for better organization.
- Added new route to fetch client IP addresses, supporting both IPv4 and IPv6.
- Implemented system information retrieval in the client routes.
- Updated package dependencies to their latest versions.
- Adjusted call route to prevent overwriting existing routes.
2026-02-04 13:20:12 +08:00
6212194f95 add gent env Value check 2026-02-03 20:19:15 +08:00
a74b984d95 feat: 添加 npm patch 命令以自动更新补丁版本 2026-02-03 03:01:25 +08:00
a76c2235ea feat: Implement LiveCode module with WebSocket and SSE support
- Added config management using `useConfig` for environment variables.
- Created `LiveCode` class to manage WebSocket connections and routing.
- Implemented `SSEManager` for Server-Sent Events handling.
- Developed `WSSManager` for managing WebSocket connections with heartbeat functionality.
- Introduced `ReconnectingWebSocket` class for robust WebSocket client with automatic reconnection.
- Added test files for live application demonstrating WebSocket and TCP server integration.
2026-02-02 23:29:58 +08:00
5774391bbe feat: refactor deploy command to enhance file upload process and user handling
- Updated the deploy command to include a new username retrieval mechanism, falling back to the organization if not specified.
- Introduced uploadFilesV2 function to streamline file upload logic, including hash checking to prevent redundant uploads.
- Modified queryAppVersion to accept a create parameter for better version management.
- Added a new test file to validate the uploadFilesV2 functionality.
2026-02-02 17:57:50 +08:00
310d727321 feat: 将命令名称从 'oc' 更改为 'ccc' 2026-01-31 22:09:17 +08:00
1d0db5f093 feat: 更新版本至 0.0.99,移除未使用的 callText 函数调用 2026-01-31 20:48:36 +08:00
73d0c8c4ba feat: 更新版本至 0.0.98 2026-01-31 17:47:15 +08:00
a80a3ede46 feat: add argument parsing and module resolution for assistant app
- Implemented argument parsing in args.ts to handle root, home, and help options.
- Added parseHomeArg and parseHelpArg functions for command line argument handling.
- Created ModuleResolver class in assistant-app-resolve.ts to resolve module paths, including scoped packages and relative paths.
- Introduced caching mechanism for package.json reads to improve performance.
- Added utility functions for checking file existence and clearing the cache.
2026-01-31 17:42:53 +08:00
51822506d7 feat(client): add routes for version, time, system info, and restart functionality 2026-01-31 00:48:15 +08:00
ef891e529a if config error exit 2026-01-30 21:55:14 +08:00
2950b5a5be feat: 更新版本至 0.0.96,重构claude命令,新增opencode模型切换功能 2026-01-30 04:37:38 +08:00
76a7dc0082 fix: 修复应用键生成逻辑,确保键值不以斜杠开头 2026-01-29 01:56:29 +08:00
9051df7a37 update pnpm-lock.yaml 2026-01-28 11:26:59 +08:00
3de3cca09c chore: 更新版本号至 0.0.95 2026-01-28 00:04:30 +08:00
742a7a2992 feat: add zod dependency and implement kevisual routes for CLI commands
- Added zod as a dependency in package.json.
- Enhanced assistant configuration to include skills and plugins directories.
- Implemented runCmd function to execute CLI commands in run.ts.
- Updated light-code module to use node:child_process.
- Created new kevisual routes for checking CLI login status and deploying web pages.
- Added restart functionality for OpenCode client in opencode module.
2026-01-28 00:02:38 +08:00
98f21d8aaa update 2026-01-27 22:25:40 +08:00
c2f5f504d3 update 2026-01-27 22:24:29 +08:00
220b008b90 refactor: 移除不再使用的轻代码路由,优化 OpenCode 客户端的端口配置 2026-01-27 22:21:51 +08:00
109 changed files with 3525 additions and 1756 deletions

View File

@@ -10,7 +10,7 @@
], ],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.28.1", "packageManager": "pnpm@10.28.2",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -25,6 +25,7 @@
"dev:share": "bun --watch src/test/remote-app.ts ", "dev:share": "bun --watch src/test/remote-app.ts ",
"build:lib": "bun run bun-lib.config.mjs", "build:lib": "bun run bun-lib.config.mjs",
"postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t", "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" "build": "rimraf dist && bun run bun.config.mjs"
}, },
"bin": { "bin": {
@@ -41,28 +42,28 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@kevisual/ai": "^0.0.22", "@inquirer/prompts": "^8.2.0",
"@kevisual/api": "^0.0.26", "@kevisual/ai": "^0.0.24",
"@kevisual/api": "^0.0.44",
"@kevisual/load": "^0.0.6", "@kevisual/load": "^0.0.6",
"@kevisual/local-app-manager": "^0.1.32", "@kevisual/local-app-manager": "^0.1.32",
"@kevisual/logger": "^0.0.4", "@kevisual/logger": "^0.0.4",
"@kevisual/query": "0.0.38", "@kevisual/query": "0.0.39",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@kevisual/router": "^0.0.62", "@kevisual/router": "^0.0.70",
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.28", "@kevisual/use-config": "^1.0.30",
"@opencode-ai/plugin": "^1.1.36", "@opencode-ai/plugin": "^1.1.49",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.8",
"@types/node": "^25.0.10", "@types/node": "^25.2.0",
"@types/send": "^1.2.1", "@types/send": "^1.2.1",
"@types/ws": "^8.18.1", "@types/ws": "^8.18.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"commander": "^14.0.2", "commander": "^14.0.3",
"cross-env": "^10.1.0", "cross-env": "^10.1.0",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"get-port": "^7.1.0", "get-port": "^7.1.0",
"inquirer": "^13.2.1",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"send": "^1.2.1", "send": "^1.2.1",
"supports-color": "^10.2.2", "supports-color": "^10.2.2",
@@ -76,17 +77,17 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.981.0",
"@kevisual/ha-api": "^0.0.6",
"@kevisual/js-filter": "^0.0.5", "@kevisual/js-filter": "^0.0.5",
"@kevisual/oss": "^0.0.16", "@kevisual/oss": "^0.0.19",
"@kevisual/video-tools": "^0.0.13", "@kevisual/video-tools": "^0.0.13",
"@opencode-ai/sdk": "^1.1.36", "@opencode-ai/sdk": "^1.1.49",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.5",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"unstorage": "^1.17.4" "unstorage": "^1.17.4",
"zod": "^4.3.6"
} }
} }

View File

@@ -84,9 +84,6 @@ importers:
get-port: get-port:
specifier: ^7.1.0 specifier: ^7.1.0
version: 7.1.0 version: 7.1.0
inquirer:
specifier: ^12.6.3
version: 12.6.3(@types/node@22.15.29)
lodash-es: lodash-es:
specifier: ^4.17.21 specifier: ^4.17.21
version: 4.17.21 version: 4.17.21
@@ -2136,18 +2133,6 @@ snapshots:
ini@1.3.8: {} 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: ip-address@9.0.5:
dependencies: dependencies:
jsbn: 1.1.0 jsbn: 1.1.0

View File

@@ -7,7 +7,8 @@ import { AssistantInit, parseHomeArg } from '@/services/init/index.ts';
import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts'; import { configDir as HomeConfigDir } from '@/module/assistant/config/index.ts';
import { useContextKey } from '@kevisual/use-config/context'; import { useContextKey } from '@kevisual/use-config/context';
import { AssistantQuery } from '@/module/assistant/query/index.ts'; import { AssistantQuery } from '@/module/assistant/query/index.ts';
import { config } from '@/module/config.ts';
export { config };
const manualParse = parseHomeArg(HomeConfigDir); const manualParse = parseHomeArg(HomeConfigDir);
const _configDir = manualParse.configDir; const _configDir = manualParse.configDir;
export const configDir = AssistantInit.detectConfigDir(_configDir); export const configDir = AssistantInit.detectConfigDir(_configDir);
@@ -24,9 +25,15 @@ export const assistantQuery = useContextKey('assistantQuery', () => {
return new AssistantQuery(assistantConfig); 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 { return {
type: 'client', type: 'client',
isServer: manualParse.isServer,
}; };
}); });
@@ -53,8 +60,9 @@ app.route({
description: '获取路由列表', description: '获取路由列表',
}).define(async (ctx) => { }).define(async (ctx) => {
const list = ctx.app.getList((item) => { 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; return true;
}) })
console.log('路由列表:', list.length);
ctx.body = { list } ctx.body = { list }
}).addTo(app); }).addTo(app);

View File

@@ -1,6 +1,7 @@
import { program, Command } from '@/program.ts'; import { program, Command } from '@/program.ts';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import { parseHomeArg, HomeConfigDir } from '@/module/assistant/config/args.ts';
import './reload.ts'
const command = new Command('server') const command = new Command('server')
.description('启动服务') .description('启动服务')
.option('-d, --daemon', '是否以守护进程方式运行') .option('-d, --daemon', '是否以守护进程方式运行')
@@ -28,18 +29,22 @@ const command = new Command('server')
shellCommands.push(`-e ${options.interpreter}`); shellCommands.push(`-e ${options.interpreter}`);
} }
const basename = _interpreter.split('/').pop(); const basename = _interpreter.split('/').pop();
const m = parseHomeArg(HomeConfigDir);
const cwd = m.isDev ? process.cwd() : m.configDir;
console.log('当前工作目录:', cwd);
if (basename.includes('bun')) { if (basename.includes('bun')) {
console.log(`Assistant server shell command: bun src/run-server.ts server ${shellCommands.join(' ')}`); console.log(`Assistant server shell command: bun src/run-server.ts server ${shellCommands.join(' ')}`);
const child = spawnSync(_interpreter, ['src/run-server.ts', ...shellCommands], { const child = spawnSync(_interpreter, ['src/run-server.ts', ...shellCommands], {
stdio: 'inherit', stdio: 'inherit',
shell: true, shell: true,
cwd: cwd,
}); });
} else { } else {
console.log(`Assistant server shell command: asst-server ${shellCommands.join(' ')}`); console.log(`Assistant server shell command: asst-server ${shellCommands.join(' ')}`);
const child = spawnSync('asst-server', shellCommands, { const child = spawnSync('asst-server', shellCommands, {
stdio: 'inherit', stdio: 'inherit',
shell: true, shell: true,
cwd: cwd,
}); });
} }
}); });

View 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);

View File

@@ -2,7 +2,7 @@ import { program, Command } from '@/program.ts';
import { AssistantInit } from '@/services/init/index.ts'; import { AssistantInit } from '@/services/init/index.ts';
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import inquirer from 'inquirer'; import { confirm } from '@inquirer/prompts';
import chalk from 'chalk'; import chalk from 'chalk';
type InitCommandOptions = { type InitCommandOptions = {
@@ -41,23 +41,17 @@ const removeCommand = new Command('remove')
const assistantDir = path.join(configDir, 'assistant-app'); const assistantDir = path.join(configDir, 'assistant-app');
if (fs.existsSync(assistantDir)) { if (fs.existsSync(assistantDir)) {
inquirer confirm({
.prompt([ message: `确定要删除助手配置文件吗?\n助手配置文件路径${assistantDir}`,
{ default: false,
type: 'confirm', }).then((confirmed) => {
name: 'confirm', if (confirmed) {
message: `确定要删除助手配置文件吗?\n助手配置文件路径${assistantDir}`, fs.rmSync(assistantDir, { recursive: true, force: true });
default: false, console.log(chalk.green('助手配置文件已删除'));
}, } else {
]) console.log(chalk.blue('助手配置文件未删除'));
.then((answers) => { }
if (answers.confirm) { });
fs.rmSync(assistantDir, { recursive: true, force: true });
console.log(chalk.green('助手配置文件已删除'));
} else {
console.log(chalk.blue('助手配置文件未删除'));
}
});
} else { } else {
console.log(chalk.blue('助手配置文件不存在')); console.log(chalk.blue('助手配置文件不存在'));
} }

View 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);

View File

@@ -13,7 +13,11 @@ const createRandomApp = (opts: { app: any, package: any, pwd: string, status?: s
} }
if (!app.key) { if (!app.key) {
const randomSuffix = Math.random().toString(36).substring(2, 8); 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; app.path = pwd;
if (app.type === 'pm2-system-app' && !app.pm2Options) { if (app.type === 'pm2-system-app' && !app.pm2Options) {

View File

@@ -5,6 +5,7 @@ import './command/asst-server/index.ts';
import './command/app/index.ts'; import './command/app/index.ts';
import './command/run-scripts/index.ts'; import './command/run-scripts/index.ts';
import './command/ai/index.ts'; import './command/ai/index.ts';
import './command/plugins/install.ts';
/** /**
* 通过命令行解析器解析参数 * 通过命令行解析器解析参数

View 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;
};

View File

@@ -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'));
}

View File

@@ -5,17 +5,16 @@ import { checkFileExists, createDir } from '../file/index.ts';
import { ProxyInfo } from '../proxy/proxy.ts'; import { ProxyInfo } from '../proxy/proxy.ts';
import dotenv from 'dotenv'; import dotenv from 'dotenv';
import { logger } from '@/module/logger.ts'; 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 pageConfigPath = path.join(configDir, 'assistant-page-config.json');
const pagesDir = createDir(path.join(configDir, 'pages')); const pagesDir = createDir(path.join(configDir, 'pages'));
const appsDir = createDir(path.join(configDir, 'apps')); 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 appsConfigPath = path.join(configDir, 'assistant-apps-config.json');
const appPidPath = path.join(configDir, 'assistant-app.pid'); const appPidPath = path.join(configDir, 'assistant-app.pid');
const envConfigPath = path.join(configDir, '.env'); const envConfigPath = path.join(configDir, '.env');
return { return {
/** /**
* 助手配置文件路径 * 助手配置文件路径, assistant-app 目录
*/ */
configDir, configDir,
/** /**
@@ -41,7 +43,7 @@ export const initConfig = (configRootPath: string) => {
*/ */
configPath, configPath,
/** /**
* 服务目录, 后端服务目录 * 服务目录, 后端服务目录, apps 目录
*/ */
appsDir, appsDir,
/** /**
@@ -49,7 +51,7 @@ export const initConfig = (configRootPath: string) => {
*/ */
appsConfigPath, appsConfigPath,
/** /**
* 应用目录, 前端应用目录 * 应用目录, 前端应用目录 pages 目录
*/ */
pagesDir, pagesDir,
/** /**
@@ -64,6 +66,14 @@ export const initConfig = (configRootPath: string) => {
* 环境变量配置文件路径 * 环境变量配置文件路径
*/ */
envConfigPath, envConfigPath,
/**
* 技能目录,配置给 opencode 去用的
*/
skillsDir,
/**
* 插件目录, 给 cli 用的,动态加载插件,每一个都是独立的
*/
pluginsDir,
}; };
}; };
export type ReturnInitConfigType = ReturnType<typeof initConfig>; export type ReturnInitConfigType = ReturnType<typeof initConfig>;
@@ -73,6 +83,7 @@ type AuthPermission = {
username?: string; // 用户名 username?: string; // 用户名
admin?: string[]; admin?: string[];
}; };
type AssistantRoutes = { type: "npm" | "file", path: string } | string
export type AssistantConfigData = { export type AssistantConfigData = {
app?: { app?: {
/** /**
@@ -99,12 +110,15 @@ export type AssistantConfigData = {
* 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] } * 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] }
* base: 是否使用 /api/router的基础路径默认false * base: 是否使用 /api/router的基础路径默认false
* lightcode: 是否启用lightcode路由默认false * lightcode: 是否启用lightcode路由默认false
* livecode: 是否启用livecode路由实时的注册和销毁默认false
*/ */
router?: { router?: {
proxy: ProxyInfo[]; proxy: ProxyInfo[];
base?: boolean; base?: boolean;
lightcode?: boolean; lightcode?: boolean;
livecode?: boolean;
} }
routes?: AssistantRoutes[],
/** /**
* API 代理配置, 比如api开头的v1开头的等等 * API 代理配置, 比如api开头的v1开头的等等
*/ */
@@ -225,10 +239,11 @@ export class AssistantConfig {
proxy: [], proxy: [],
}; };
} }
assistantConfig = JSON.parse(fs.readFileSync(this.configPath.configPath, 'utf8')); assistantConfig = getFileConfig(this.configPath.configPath);
return assistantConfig; return assistantConfig;
} catch (error) { } catch (error) {
console.error('file read', error.message); console.error('file read', error.message);
process.exit(1);
return { return {
app: { app: {
url: 'https://kevisual.cn', url: 'https://kevisual.cn',
@@ -247,6 +262,21 @@ export class AssistantConfig {
const config = this.getCacheAssistantConfig(); const config = this.getCacheAssistantConfig();
return config?.registry || config?.app?.url || 'https://kevisual.cn'; 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 配置 * 设置 assistant-config.json 配置
* @param config * @param config
@@ -368,55 +398,6 @@ export class AssistantConfig {
type AppConfig = { type AppConfig = {
list: any[]; 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) => { export const parseIfJson = (content: string) => {
try { try {
@@ -426,3 +407,5 @@ export const parseIfJson = (content: string) => {
return {}; return {};
} }
}; };
export * from './args.ts';

View File

@@ -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();
}
}

View File

@@ -11,13 +11,16 @@ import { getEnvToken } from '@/module/http-token.ts';
import { initApi } from '@kevisual/api/proxy' import { initApi } from '@kevisual/api/proxy'
import { Query } from '@kevisual/query'; import { Query } from '@kevisual/query';
import { initLightCode } from '@/module/light-code/index.ts'; import { initLightCode } from '@/module/light-code/index.ts';
import { ModuleResolver } from './assistant-app-resolve.ts';
import z from 'zod';
export class AssistantApp extends Manager { export class AssistantApp extends Manager {
config: AssistantConfig; config: AssistantConfig;
pagesPath: string; pagesPath: string;
remoteIsConnected = false; remoteIsConnected = false;
attemptedConnectTimes = 0;
remoteApp: RemoteApp | null = null; remoteApp: RemoteApp | null = null;
remoteUrl: string | null = null; remoteUrl: string | null = null;
private resolver: ModuleResolver;
constructor(config: AssistantConfig, mainApp?: App) { constructor(config: AssistantConfig, mainApp?: App) {
config.checkMounted(); config.checkMounted();
const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps'); const appsPath = config?.configPath?.appsDir || path.join(process.cwd(), 'apps');
@@ -31,6 +34,7 @@ export class AssistantApp extends Manager {
}); });
this.pagesPath = pagesPath; this.pagesPath = pagesPath;
this.config = config; this.config = config;
this.resolver = new ModuleResolver(config.configPath.configDir);
} }
async pageList() { async pageList() {
const pages = await glob(['*/*/package.json'], { const pages = await glob(['*/*/package.json'], {
@@ -82,7 +86,7 @@ export class AssistantApp extends Manager {
const enabled = opts?.enabled ?? share?.enabled ?? false; const enabled = opts?.enabled ?? share?.enabled ?? false;
if (share && enabled !== false) { if (share && enabled !== false) {
if (this.remoteApp) { if (this.remoteApp) {
this.remoteApp.ws?.close(); this.remoteApp.disconnect();
this.remoteApp = null; this.remoteApp = null;
this.remoteIsConnected = false; this.remoteIsConnected = false;
} }
@@ -95,31 +99,33 @@ export class AssistantApp extends Manager {
token, token,
id, id,
app: this.mainApp, app: this.mainApp,
// 使用 RemoteApp 内置的自动重连机制
autoReconnect: true,
reconnectDelay: 5000, // 首次重连延迟 5 秒
maxReconnectAttempts: Infinity, // 无限重连
enableBackoff: true, // 启用指数退避
}); });
const isConnect = await remoteApp.isConnect(); remoteApp.isConnect();
if (isConnect) {
remoteApp.listenProxy(); this.remoteIsConnected = true;
// 监听连接成功和关闭事件
remoteApp.on('open', () => {
this.remoteIsConnected = true; 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 appId = id;
const username = config?.auth.username || 'unknown'; const username = config?.auth.username || 'unknown';
const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/'); const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/');
this.remoteUrl = url.toString(); this.remoteUrl = url.toString();
console.log('远程地址', this.remoteUrl); logger.info('[remote-app] 远程地址', this.remoteUrl);
} else { logger.debug('链接到了远程应用服务器');
console.log('Not connected to remote app server'); remoteApp.listenProxy()
} });
remoteApp.on('close', () => {
this.remoteIsConnected = false;
logger.info('[remote-app] 远程连接已关闭,自动重连机制正在处理...');
});
remoteApp.on('maxReconnectAttemptsReached', () => {
logger.error('远程应用重连达到最大次数,停止重连');
});
this.remoteApp = remoteApp; this.remoteApp = remoteApp;
} else { } else {
if (!token) { if (!token) {
@@ -145,7 +151,9 @@ export class AssistantApp extends Manager {
routerProxy.push({ routerProxy.push({
type: 'lightcode', type: 'lightcode',
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; continue;
} }
if (proxyInfo.type === 'lightcode') { 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({ initLightCode({
router: this.mainApp, router: this.mainApp,
config: this.config config: this.config,
sync: lightcodeConfig.sync,
rootPath: lightcodeConfig.rootPath,
}); });
continue; continue;
} }
@@ -188,26 +209,16 @@ export class AssistantApp extends Manager {
} }
} }
} }
async reconnectRemoteApp() { async initRoutes() {
console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes); const routes = this.config.getConfig().routes || [];
const remoteApp = this.remoteApp;; for (const route of routes) {
if (remoteApp) { try {
// 先关闭旧的 WebSocket防止竞态条件 const routeStr = typeof route === 'string' ? route : route.path;
if (remoteApp.ws) { const resolvedPath = this.resolver.resolve(routeStr);
remoteApp.ws.close(); await import(resolvedPath);
} console.log('[routes] 路由已初始化', route, resolvedPath);
remoteApp.init(); } catch (err) {
this.attemptedConnectTimes += 1; console.error('初始化路由失败', route, err);
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秒
} }
} }
} }

View File

@@ -1,4 +1,4 @@
import { ChildProcess, fork, ForkOptions } from 'child_process'; import { ChildProcess, fork, ForkOptions } from 'node:child_process';
class BaseProcess { class BaseProcess {
private process: ChildProcess; private process: ChildProcess;
status: 'running' | 'stopped' | 'error' = 'stopped'; status: 'running' | 'stopped' | 'error' = 'stopped';

View File

@@ -48,10 +48,8 @@ export type ProxyInfo = {
}, },
lightcode?: { lightcode?: {
id?: string; id?: string;
/** sync?: 'remote' | 'local' | 'both';
* 是否检测远程服务更新 rootPath?: string;
*/
check?: boolean;
} }
}; };

View 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 });
});
});
}

View 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);

View File

@@ -1,8 +1,7 @@
import { App, QueryRouterServer } from '@kevisual/router'; import { App, QueryRouterServer } from '@kevisual/router';
import { AssistantInit } from '../../services/init/index.ts'; import { AssistantInit } from '../../services/init/index.ts';
import path from 'node:path'; import path from 'node:path';
import fs, { write } from 'node:fs'; import fs from 'node:fs';
import os from 'node:os';
import glob from 'fast-glob'; import glob from 'fast-glob';
import { runCode } from './run.ts'; import { runCode } from './run.ts';
const codeDemoId = '0e700dc8-90dd-41b7-91dd-336ea51de3d2' 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')); // writeCodeDemo(path.join(os.homedir(), 'kevisual', 'assistant-app', 'apps'));
type opts = { type Opts = {
router: QueryRouterServer | App router: QueryRouterServer | App
config: AssistantConfig | AssistantInit config: AssistantConfig | AssistantInit
sync?: boolean sync?: 'remote' | 'local' | 'both'
rootPath?: string
} }
type LightCodeFile = { type LightCodeFile = {
id?: string, code?: string, hash?: string, filepath: string id?: string, code?: string, hash?: string, filepath: string
} }
export const initLightCode = async (opts: opts) => { export const initLightCode = async (opts: Opts) => {
// 注册 light-code 路由 // 注册 light-code 路由
console.log('初始化 light-code 路由'); console.log('初始化 light-code 路由');
const config = opts.config as AssistantInit; const config = opts.config as AssistantInit;
const app = opts.router; const app = opts.router;
const token = config.getConfig()?.token || ''; const token = config.getConfig()?.token || '';
const query = config.query; const query = config.query;
const sync = opts.sync ?? true; const sync = opts.sync ?? 'remote';
if (!config || !app) { if (!config || !app) {
console.error('initLightCode 缺少必要参数, config 或 app'); console.error('initLightCode 缺少必要参数, config 或 app');
return; return;
} }
const appDir = config.configPath.appsDir; const lightcodeDir = opts.rootPath;
const lightcodeDir = path.join(appDir, 'light-code', 'code');
if (!fs.existsSync(lightcodeDir)) { if (!fs.existsSync(lightcodeDir)) {
fs.mkdirSync(lightcodeDir, { recursive: true }); fs.mkdirSync(lightcodeDir, { recursive: true });
} }
let diffList: LightCodeFile[] = []; let diffList: LightCodeFile[] = [];
const codeFiles = glob.sync(['**/*.ts', '**/*.js'], { const findGlob = (opts: { cwd: string }) => {
cwd: lightcodeDir, return glob.sync(['**/*.ts', '**/*.js'], {
onlyFiles: true, cwd: opts.cwd,
}).map(file => { onlyFiles: true,
return { }).map(file => {
filepath: path.join(lightcodeDir, file), return {
// hash: getHash(path.join(lightcodeDir, file)) 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({ const queryRes = await query.post({
path: 'light-code', path: 'light-code',
key: 'list', key: 'list',
@@ -100,13 +102,6 @@ export const initLightCode = async (opts: opts) => {
fs.writeFileSync(item.filepath, item.code, 'utf-8'); fs.writeFileSync(item.filepath, item.code, 'utf-8');
// console.log(`新增 light-code 文件: ${item.filepath}`); // 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) { for (const item of toUpdate) {
fs.writeFileSync(item.filepath, item.code, 'utf-8'); fs.writeFileSync(item.filepath, item.code, 'utf-8');
@@ -117,23 +112,38 @@ export const initLightCode = async (opts: opts) => {
// filepath: d.filepath, // filepath: d.filepath,
// hash: d.hash // 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 { } else {
console.error('light-code 同步失败', queryRes.message); console.error('light-code 同步失败', queryRes.message);
diffList = codeFiles; diffList = codeFiles;
} }
} else { } else if (sync === 'local') {
diffList = codeFiles; diffList = codeFiles;
} }
for (const file of diffList) { for (const file of diffList) {
const tsPath = file.filepath; 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) { if (runRes.success) {
const res = runRes.data; const res = runRes.data;
if (res.code === 200) { if (res.code === 200) {
const list = res.data?.list || []; const list = res.data?.list || [];
for (const routerItem of 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')) { if (routerItem.path?.includes('auth') || routerItem.path?.includes('router') || routerItem.path?.includes('call')) {
continue; continue;
} }
@@ -144,6 +154,10 @@ export const initLightCode = async (opts: opts) => {
} else { } else {
metadata.tags = ['light-code']; metadata.tags = ['light-code'];
} }
metadata.source = 'light-code';
metadata['light-code'] = {
id: file.id
}
app.route({ app.route({
id: routerItem.id, id: routerItem.id,
path: `${routerItem.id}__${routerItem.path}`, path: `${routerItem.id}__${routerItem.path}`,
@@ -153,8 +167,13 @@ export const initLightCode = async (opts: opts) => {
middleware: ['auth'], middleware: ['auth'],
}).define(async (ctx) => { }).define(async (ctx) => {
const tokenUser = ctx.state?.tokenUser || {}; const tokenUser = ctx.state?.tokenUser || {};
const query = { ...ctx.query, tokenUser } const query = { ...ctx.query }
const runRes2 = await runCode(tsPath, query, { timeout: 30000 }); const runRes2 = await runCode(tsPath, {
message: query,
context: {
state: { tokenUser, user: tokenUser },
}
}, { timeout: 30000 });
if (runRes2.success) { if (runRes2.success) {
const res2 = runRes2.data; const res2 = runRes2.data;
if (res2.code === 200) { if (res2.code === 200) {
@@ -165,8 +184,10 @@ export const initLightCode = async (opts: opts) => {
} else { } else {
ctx.throw(runRes2.error || 'Lightcode 路由执行失败'); ctx.throw(runRes2.error || 'Lightcode 路由执行失败');
} }
}).addTo(app); }).addTo(app, {
overwrite: false
});// 不允许覆盖已存在的路由
// console.log(`light-code 路由注册成功: [${routerItem.path}] ${routerItem.id} 来自文件: ${file.filepath}`);
} }
} }
} else { } else {
@@ -175,3 +196,18 @@ export const initLightCode = async (opts: opts) => {
} }
console.log(`light-code 路由注册成功`, `注册${diffList.length}个路由`); 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);
}
}
}

View File

@@ -1,6 +1,6 @@
import { fork } from 'child_process' import { fork } from 'node:child_process'
import fs from 'fs'; import fs from 'node:fs';
import { ListenProcessParams, ListenProcessResponse } from '@kevisual/router';
export const fileExists = (path: string): boolean => { export const fileExists = (path: string): boolean => {
try { try {
fs.accessSync(path, fs.constants.F_OK); fs.accessSync(path, fs.constants.F_OK);
@@ -10,30 +10,12 @@ export const fileExists = (path: string): boolean => {
} }
} }
export type RunCodeParams = { export type RunCodeParams = ListenProcessParams
path?: string;
key?: string;
payload?: string;
[key: string]: any
}
type RunCodeOptions = { type RunCodeOptions = {
timeout?: number; // 超时时间,单位毫秒 timeout?: number; // 超时时间,单位毫秒
[key: string]: any [key: string]: any
} }
type RunCode = { type RunCode = ListenProcessResponse & { output?: string }
// 调用进程的功能
success?: boolean
data?: {
// 调用router的结果
code?: number
data?: any
message?: string
[key: string]: any
};
error?: any
timestamp?: string
output?: string
}
export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?: RunCodeOptions): Promise<RunCode> => { export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?: RunCodeOptions): Promise<RunCode> => {
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
if (fileExists(tsPath) === false) { if (fileExists(tsPath) === false) {
@@ -81,7 +63,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
silent: true, // 启用 stdio 重定向 silent: true, // 启用 stdio 重定向
env: { env: {
...process.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) { } catch (error) {
console.error('启动子进程失败:', error)
resolveOnce({ resolveOnce({
success: false, success: false,
error: `启动子进程失败: ${error instanceof Error ? error.message : '未知错误'}` error: `启动子进程失败: ${error instanceof Error ? error.message : '未知错误'}`

View 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);
}
}
}

View 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());
}
}

View 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;
}
}

View File

@@ -32,7 +32,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return fileProxy(req, res, { return fileProxy(req, res, {
path: localProxyProxy.path, path: localProxyProxy.path,
rootPath: localProxy.pagesDir, rootPath: localProxy.pagesDir,
indexPath: localProxyProxy.indexPath, indexPath: localProxyProxy.file?.indexPath,
}); });
} }
res.statusCode = 404; res.statusCode = 404;

View File

@@ -34,3 +34,13 @@ export const installDeps = async (opts: InstallDepsOptions) => {
syncSpawn('npm', params, { cwd: appPath, stdio: 'inherit', env: process.env }); 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,
});
};

View 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

View 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"
}
}
}

View File

@@ -1,20 +1,28 @@
{ {
"name": "@kevisual/remote-app", "name": "@kevisual/remote-app",
"version": "0.0.1", "version": "0.0.4",
"description": "", "description": "",
"main": "remote-app.ts", "main": "dist/app.js",
"scripts": { "scripts": {
"test": "echo \"Error: no test specified\" && exit 1" "build": "code-builder build -e remote-app.ts --dts"
}, },
"keywords": [], "keywords": [],
"files": [ "files": [
"dist",
"remote-app.ts" "remote-app.ts"
], ],
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"exports": {
".": "./dist/app.js"
},
"devDependencies": {
"eventemitter3": "^5.0",
"@kevisual/router": "^0.0.70"
},
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.26.0", "packageManager": "pnpm@10.28.2",
"type": "module" "type": "module"
} }

View File

@@ -1,4 +1,4 @@
import type { App } from '@kevisual/router'; import type { App, ListenProcessParams } from '@kevisual/router';
import { EventEmitter } from 'eventemitter3'; import { EventEmitter } from 'eventemitter3';
type RemoteAppOptions = { type RemoteAppOptions = {
app?: App; app?: App;
@@ -6,7 +6,20 @@ type RemoteAppOptions = {
token?: string; token?: string;
emitter?: EventEmitter; emitter?: EventEmitter;
id?: string; 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 { export class RemoteApp {
mainApp: App; mainApp: App;
url: string; url: string;
@@ -16,6 +29,15 @@ export class RemoteApp {
ws: WebSocket; ws: WebSocket;
remoteIsConnected: boolean; remoteIsConnected: boolean;
isError: boolean = false; 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) { constructor(opts?: RemoteAppOptions) {
this.mainApp = opts?.app; this.mainApp = opts?.app;
const token = opts.token; const token = opts.token;
@@ -29,6 +51,12 @@ export class RemoteApp {
_url.searchParams.set('id', id); _url.searchParams.set('id', id);
this.url = _url.toString(); this.url = _url.toString();
this.id = id; 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(); this.init();
} }
async isConnect(): Promise<boolean> { async isConnect(): Promise<boolean> {
@@ -36,6 +64,11 @@ export class RemoteApp {
if (this.isConnected) { if (this.isConnected) {
return true; return true;
} }
// 如果正在进行重连,等待连接成功
if (this.reconnectTimer !== null) {
console.log(`远程应用 ${this.id} 正在重连中...`);
}
// 等待连接成功(支持初次连接和重连场景)
return new Promise((resolve) => { return new Promise((resolve) => {
const timeout = setTimeout(() => { const timeout = setTimeout(() => {
resolve(false); resolve(false);
@@ -52,6 +85,9 @@ export class RemoteApp {
} }
getWsURL(url: string) { getWsURL(url: string) {
const { protocol } = new URL(url); const { protocol } = new URL(url);
if (protocol.startsWith('ws')) {
return url.toString()
}
const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:'; const wsProtocol = protocol === 'https:' ? 'wss:' : 'ws:';
const wsURL = url.toString().replace(protocol, wsProtocol); const wsURL = url.toString().replace(protocol, wsProtocol);
return wsURL; return wsURL;
@@ -73,6 +109,7 @@ export class RemoteApp {
ws.onopen = function () { ws.onopen = function () {
that.isConnected = true; that.isConnected = true;
that.onOpen(); that.onOpen();
console.log('[remote-app] WebSocket connection opened');
}; };
ws.onclose = function () { ws.onclose = function () {
that.isConnected = false; that.isConnected = false;
@@ -87,12 +124,84 @@ export class RemoteApp {
this.ws = ws; this.ws = ws;
} }
onOpen() { onOpen() {
this.isError = false;
this.reconnectAttempts = 0;
// 清除可能存在的重连定时器
if (this.reconnectTimer) {
clearTimeout(this.reconnectTimer);
this.reconnectTimer = null;
}
this.emitter.emit('open', this.id); this.emitter.emit('open', this.id);
} }
onClose() { onClose() {
console.log('远程应用关闭:', this.id); console.log('远程应用关闭:', this.id);
this.emitter.emit('close', this.id);
this.isConnected = false; 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) { onMessage(data: any) {
this.emitter.emit('message', data); this.emitter.emit('message', data);
@@ -102,14 +211,12 @@ export class RemoteApp {
this.isError = true; this.isError = true;
this.emitter.emit('error', error); 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); this.emitter.on(event, listener);
return () => { return () => {
this.emitter.off(event, listener); this.emitter.off(event, listener);
}; };
} }
sendData(data: any) { }
json(data: any) { json(data: any) {
this.ws.send(JSON.stringify(data)); this.ws.send(JSON.stringify(data));
} }
@@ -119,8 +226,10 @@ export class RemoteApp {
const listenFn = async (event: any) => { const listenFn = async (event: any) => {
try { try {
const data = event.toString(); const data = event.toString();
const body = JSON.parse(data); const body = JSON.parse(data)
const message = body.data || {}; const bodyData = body?.data as ListenProcessParams;
const message = bodyData?.message || {};
const context = bodyData?.context || {};
if (body?.code === 401) { if (body?.code === 401) {
console.error('远程应用认证失败,请检查 token 是否正确'); console.error('远程应用认证失败,请检查 token 是否正确');
this.isError = true; this.isError = true;
@@ -138,12 +247,12 @@ export class RemoteApp {
return; return;
} }
const res = await app.call(message); const res = await app.run(message, context);
remoteApp.json({ remoteApp.json({
id: body.id, id: body.id,
data: { data: {
code: res.code, code: res.code,
data: res.body, data: res.data,
message: res.message, message: res.message,
}, },
}); });

View File

@@ -29,4 +29,6 @@ app.route({
...ctx ...ctx
}); });
ctx.forward(res); ctx.forward(res);
}).addTo(app) }).addTo(app, {
overwrite: false
})

View File

@@ -0,0 +1,2 @@
import './ip.ts';
import './system.ts'

View 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);

View 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);

View File

@@ -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);
}
}
}

View File

@@ -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);

View File

@@ -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;
}

View File

@@ -1,20 +1,17 @@
import { app, assistantConfig } from '../app.ts'; import { app, assistantConfig } from '../app.ts';
import './config/index.ts'; import './config/index.ts';
import './client/index.ts';
import './shop-install/index.ts'; import './shop-install/index.ts';
import './ai/index.ts'; import './ai/index.ts';
// TODO:
// import './light-code/index.ts';
import './user/index.ts'; import './user/index.ts';
import './call/index.ts' import './call/index.ts'
// TODO: 移除
// import './hot-api/key-sender/index.ts';
import './opencode/index.ts'; import './opencode/index.ts';
import './remote/index.ts'; import './remote/index.ts';
// import './kevisual/index.ts'
import os from 'node:os';
import { authCache } from '@/module/cache/auth.ts'; import { authCache } from '@/module/cache/auth.ts';
import { createSkill } from '@kevisual/router';
import { logger } from '@/module/logger.ts'; import { logger } from '@/module/logger.ts';
const getTokenUser = async (token: string) => { const getTokenUser = async (token: string) => {
const query = assistantConfig.query const query = assistantConfig.query
@@ -64,18 +61,16 @@ export const checkAuth = async (ctx: any, isAdmin = false) => {
} }
authCache.set(token, tokenUser); authCache.set(token, tokenUser);
} }
if (ctx.state) { ctx.state = {
ctx.state = { ...ctx.state,
...ctx.state, token,
token, tokenUser,
tokenUser, };
};
}
const { username } = tokenUser; const { username } = tokenUser;
if (!auth.username) { if (!auth.username) {
// 初始管理员账号 // 初始管理员账号
auth.username = username; auth.username = username;
assistantConfig.setConfig({ auth }); assistantConfig.setConfig({ auth, token: token });
} }
if (isAdmin && auth.username) { if (isAdmin && auth.username) {
const admins = config.auth?.admin || []; const admins = config.auth?.admin || [];
@@ -83,6 +78,12 @@ export const checkAuth = async (ctx: any, isAdmin = false) => {
const admin = auth.username; const admin = auth.username;
if (admin === username) { if (admin === username) {
isCheckAdmin = true; 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)) { if (!isCheckAdmin && admins.length > 0 && admins.includes(username)) {
isCheckAdmin = true; isCheckAdmin = true;
@@ -106,7 +107,7 @@ app
description: '获取当前登录用户信息, 第一个登录的用户为管理员用户', description: '获取当前登录用户信息, 第一个登录的用户为管理员用户',
}) })
.define(async (ctx) => { .define(async (ctx) => {
if (!ctx.query?.token && ctx.appId === app.appId) { if (!ctx.query?.token && ctx.appId === app.appId) {
return; return;
} }
const authResult = await checkAuth(ctx); const authResult = await checkAuth(ctx);
@@ -122,7 +123,7 @@ app
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。', description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
}) })
.define(async (ctx) => { .define(async (ctx) => {
logger.debug('query', ctx.query); // logger.debug('query', ctx.query);
if (!ctx.query?.token && ctx.appId === app.appId) { if (!ctx.query?.token && ctx.appId === app.appId) {
return; return;
} }
@@ -132,52 +133,3 @@ app
} }
}) })
.addTo(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);

View 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);

View 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);

View File

@@ -0,0 +1,2 @@
import './auth.ts'
import './deploy.ts'

View File

@@ -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)

View File

@@ -1,3 +0,0 @@
import './call.ts'
import './upload.ts'

View File

@@ -0,0 +1,5 @@
// TODO: 重载 light-code
import { initLightCode } from "@/module/light-code/index.ts";
// 下载最新代码,覆盖本地文件
// 重新启动 light-code 相关服务

View File

@@ -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);

View File

@@ -0,0 +1,2 @@
import { AssistantApp } from '../../module/assistant/local-app-manager/assistant-app.ts';
// AssistantApp

View File

@@ -1,8 +1,6 @@
import { app } from '@/app.ts' import { app } from '@/app.ts'
import { createSkill, tool } from "@kevisual/router"; import { createSkill, tool } from "@kevisual/router";
import { opencodeManager } from './module/open.ts' import { opencodeManager } from './module/open.ts'
import path from "node:path";
import { execSync } from "node:child_process";
import { useKey } from '@kevisual/use-config'; import { useKey } from '@kevisual/use-config';
// 创建一个opencode 客户端 // 创建一个opencode 客户端
@@ -18,16 +16,16 @@ app.route({
title: '创建 OpenCode 客户端', title: '创建 OpenCode 客户端',
summary: '创建 OpenCode 客户端,如果存在则复用', summary: '创建 OpenCode 客户端,如果存在则复用',
args: { args: {
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
} }
}) })
}, },
}).define(async (ctx) => { }).define(async (ctx) => {
const client = await opencodeManager.getClient(); const client = await opencodeManager.getClient({ port: ctx.query.port });
ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已就绪` }; ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已就绪` };
}).addTo(app); }).addTo(app);
// 关闭 opencode 客户端 // 关闭 opencode 客户端 5000
app.route({ app.route({
path: 'opencode', path: 'opencode',
key: 'close', key: 'close',
@@ -38,17 +36,39 @@ app.route({
...createSkill({ ...createSkill({
skill: 'close-opencode-client', skill: 'close-opencode-client',
title: '关闭 OpenCode 客户端', title: '关闭 OpenCode 客户端',
summary: '关闭 OpenCode 客户端', summary: '关闭 OpenCode 客户端, 未提供端口则关闭默认端口',
args: { args: {
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
} }
}) })
}, },
}).define(async (ctx) => { }).define(async (ctx) => {
await opencodeManager.close(); const port = ctx.query.port;
await opencodeManager.close({ port });
ctx.body = { content: 'OpenCode 客户端已关闭' }; ctx.body = { content: 'OpenCode 客户端已关闭' };
}).addTo(app); }).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 // 调用 path: opencode key: getUrl
app.route({ app.route({
path: 'opencode', path: 'opencode',
@@ -62,12 +82,12 @@ app.route({
title: '获取 OpenCode 服务 URL', title: '获取 OpenCode 服务 URL',
summary: '获取当前 OpenCode 服务的 URL 地址', summary: '获取当前 OpenCode 服务的 URL 地址',
args: { args: {
port: tool.schema.number().optional().describe('OpenCode 服务端口,默认为 5000')
} }
}) })
}, },
}).define(async (ctx) => { }).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; const cnbURL = useKey('CNB_VSCODE_PROXY_URI') as string | undefined;
let content = `本地访问地址: ${url}` let content = `本地访问地址: ${url}`
if (cnbURL) { if (cnbURL) {
@@ -75,6 +95,7 @@ app.route({
} }
ctx.body = { content }; ctx.body = { content };
}).addTo(app); }).addTo(app);
// 调用 path: opencode key: ls-projects // 调用 path: opencode key: ls-projects
app.route({ app.route({
path: 'opencode', path: 'opencode',

View File

@@ -1,18 +1,15 @@
import { createOpencode, createOpencodeClient, OpencodeClient, } from "@opencode-ai/sdk"; import { createOpencode, createOpencodeClient, OpencodeClient, } from "@opencode-ai/sdk";
import { randomInt } from "es-toolkit";
import getPort from "get-port"; import getPort from "get-port";
import os from "node:os"; import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process"; import { execSync } from "node:child_process";
const DEFAULT_PORT = 5000;
export class OpencodeManager { export class OpencodeManager {
private static instance: OpencodeManager | null = null; private static instance: OpencodeManager | null = null;
private client: OpencodeClient | null = null; private client: OpencodeClient | null = null;
private server: { url: string; close(): void } | null = null; private server: { url: string; close(): void } | null = null;
private isInitializing: boolean = false; private isInitializing: boolean = false;
private currentPort: number | null = null;
public url: string = ''; public url: string = '';
private constructor() { } private constructor() { }
@@ -24,23 +21,21 @@ export class OpencodeManager {
return OpencodeManager.instance; return OpencodeManager.instance;
} }
async getClient(): Promise<OpencodeClient> { async getClient(opts?: { port?: number }): Promise<OpencodeClient> {
// 如果已经有 client直接返回 const port = opts?.port ?? DEFAULT_PORT;
if (this.client) { if (this.client) {
return this.client; return this.client;
} }
// 如果正在初始化,等待初始化完成
if (this.isInitializing) { if (this.isInitializing) {
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
return this.getClient(); return this.getClient(opts);
} }
// 开始初始化
this.isInitializing = true; this.isInitializing = true;
try { try {
const port = 5000; const currentPort = await getPort({ port });
const currentPort = await getPort({ port: port });
if (port === currentPort) { if (port === currentPort) {
const result = await createOpencode({ const result = await createOpencode({
hostname: '0.0.0.0', hostname: '0.0.0.0',
@@ -59,6 +54,7 @@ export class OpencodeManager {
this.isInitializing = false; this.isInitializing = false;
} }
} }
async createOpencodeProject({ async createOpencodeProject({
directory, directory,
port = 5000 port = 5000
@@ -69,11 +65,10 @@ export class OpencodeManager {
}); });
return client; return client;
} }
async killPort(port: number): Promise<void> { async killPort(port: number): Promise<void> {
try { try {
// 尝试 使用命令行去关闭 port为5000的服务
if (os.platform() === 'win32') { if (os.platform() === 'win32') {
// Windows 平台
execSync(`netstat -ano | findstr :${port} | findstr LISTENING`).toString().split('\n').forEach(line => { execSync(`netstat -ano | findstr :${port} | findstr LISTENING`).toString().split('\n').forEach(line => {
const parts = line.trim().split(/\s+/); const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1]; const pid = parts[parts.length - 1];
@@ -83,7 +78,6 @@ export class OpencodeManager {
} }
}); });
} else { } else {
// Unix-like 平台
const result = execSync(`lsof -i :${port} -t`).toString(); const result = execSync(`lsof -i :${port} -t`).toString();
result.split('\n').forEach(pid => { result.split('\n').forEach(pid => {
if (pid) { if (pid) {
@@ -96,14 +90,15 @@ export class OpencodeManager {
console.error('Failed to close OpenCode server:', error); console.error('Failed to close OpenCode server:', error);
} }
} }
async close(): Promise<void> {
async close(opts?: { port?: number }): Promise<void> {
if (this.server) { if (this.server) {
this.server.close(); this.server.close();
this.server = null; this.server = null;
return return
} }
const port = 5000; const port = opts?.port ?? DEFAULT_PORT;
const currentPort = await getPort({ port: port }); const currentPort = await getPort({ port });
if (port === currentPort) { if (port === currentPort) {
this.client = null; this.client = null;
return; return;
@@ -113,14 +108,21 @@ export class OpencodeManager {
this.client = null; this.client = null;
} }
async getUrl(): Promise<string> {
async getUrl(opts?: { port?: number }): Promise<string> {
const port = opts?.port ?? DEFAULT_PORT;
if (this.url) { if (this.url) {
return this.url; return this.url;
} }
if (!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 });
} }
} }

View File

@@ -1,6 +1,6 @@
import { useContextKey } from '@kevisual/context'; import { useContextKey } from '@kevisual/context';
import { app, assistantConfig } from './app.ts'; import { app, assistantConfig, runtime } from './app.ts';
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts'; import { proxyLivecodeWs, proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
import './routes/index.ts'; import './routes/index.ts';
import './routes-simple/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, func: proxyRoute as any,
}, },
...proxyWs(), ...proxyWs(),
...proxyLivecodeWs(),
qwenAsr, qwenAsr,
]); ]);
const manager = useContextKey('manager', new AssistantApp(assistantConfig, app)); 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.initRemoteApp()
manager.initRouterApp() manager.initRouterApp()
if (runtime.isServer) {
manager.initRoutes();
}
}, 1000); }, 1000);
return { return {

View File

@@ -1,7 +1,7 @@
import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts'; import { checkFileExists, AssistantConfig } from '@/module/assistant/index.ts';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import inquirer from 'inquirer'; import { confirm } from '@inquirer/prompts';
import { spawnSync } from 'child_process'; import { spawnSync } from 'child_process';
export const runCommand = (command: string, args: string[]) => { export const runCommand = (command: string, args: string[]) => {
@@ -91,15 +91,10 @@ export class AppDownload {
return runCommand(command, args); return runCommand(command, args);
} }
async confirm(message?: string) { async confirm(message?: string) {
const { confirm } = await inquirer.prompt([ return await confirm({
{ message: message || '是否继续删除应用?',
type: 'confirm', default: false,
name: 'confirm', });
message: message || '是否继续删除应用?',
default: false,
},
]);
return confirm;
} }
async deleteApp(opts: DeleteAppOptions) { async deleteApp(opts: DeleteAppOptions) {
const { id, type = 'web', yes = false } = opts; const { id, type = 'web', yes = false } = opts;

View File

@@ -1,7 +1,6 @@
import { QwenAsrRelatime } from "@kevisual/video-tools/src/asr/index.ts"; import { QwenAsrRelatime } from "@kevisual/video-tools/src/asr/index.ts";
import { Listener, WebSocketListenerFun, WebSocketReq } from "@kevisual/router"; import { Listener, WebSocketListenerFun, WebSocketReq } from "@kevisual/router";
import { callText } from "@/routes/ha-api/ha.ts";
import { assistantConfig } from "@/app.ts"; import { assistantConfig } from "@/app.ts";
const func: WebSocketListenerFun = async (req: WebSocketReq<{ asr: QwenAsrRelatime, msgId: string, startTime?: number, loading?: boolean }>, res) => { 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(), time: Date.now(),
text, text,
})); }));
if (!text) return;
await callText(text);
console.log('toogle light time', Date.now() - endTime); console.log('toogle light time', Date.now() - endTime);
}); });
asr.start(); asr.start();

View File

@@ -136,11 +136,11 @@ export class AssistantInit extends AssistantConfig {
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.978.0",
"@kevisual/oss": "^0.0.16", "@kevisual/oss": "^0.0.16",
"@kevisual/query": "^0.0.38", "@kevisual/query": "^0.0.38",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"@kevisual/router": "^0.0.62", "@kevisual/router": "^0.0.64",
"@kevisual/use-config": "^1.0.28", "@kevisual/use-config": "^1.0.28",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"minio": "^8.0.6", "minio": "^8.0.6",
@@ -157,16 +157,18 @@ export class AssistantInit extends AssistantConfig {
}, },
"devDependencies": { "devDependencies": {
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.8",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^25.0.10" "@types/node": "^25.1.0"
} }
} }
`, `,
); );
console.log(chalk.green('助手 package.json 文件创建成功, 正在安装依赖...')); console.log(chalk.green('助手 package.json 文件创建成功, 正在安装依赖...'));
installDeps({ appPath: path.dirname(packagePath), isProduction: true }).then(() => { installDeps({ appPath: path.dirname(packagePath), isProduction: true }).then(() => {
console.log('------------------------------------------------');
console.log(chalk.green('助手依赖安装完成')); console.log(chalk.green('助手依赖安装完成'));
console.log('------------------------------------------------');
}); });
} }
return { return {

View File

@@ -12,15 +12,15 @@
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.975.0", "@aws-sdk/client-s3": "^3.981.0",
"@kevisual/oss": "^0.0.16", "@kevisual/oss": "^0.0.19",
"@kevisual/query": "^0.0.38", "@kevisual/query": "^0.0.39",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"@kevisual/router": "^0.0.62", "@kevisual/router": "^0.0.70",
"@kevisual/use-config": "^1.0.28", "@kevisual/use-config": "^1.0.30",
"ioredis": "^5.9.2", "ioredis": "^5.9.2",
"minio": "^8.0.6", "minio": "^8.0.6",
"pg": "^8.17.2", "pg": "^8.18.0",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"sequelize": "^6.37.7", "sequelize": "^6.37.7",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
@@ -33,8 +33,8 @@
}, },
"devDependencies": { "devDependencies": {
"@kevisual/types": "^0.0.12", "@kevisual/types": "^0.0.12",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.8",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/node": "^25.0.10" "@types/node": "^25.2.0"
} }
} }

View File

@@ -1,13 +1,14 @@
import { createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts'; import { createApiProxy, ProxyInfo, proxy } from '@/module/assistant/index.ts';
import http from 'node:http'; import http from 'node:http';
import { LocalProxy } from './local-proxy.ts'; 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 { log, logger } from '@/module/logger.ts';
import { getToken } from '@/module/http-token.ts'; import { getToken } from '@/module/http-token.ts';
import { getTokenUserCache } from '@/routes/index.ts'; import { getTokenUserCache } from '@/routes/index.ts';
import type { WebSocketListenerFun } from "@kevisual/router"; import type { WebSocketListenerFun } from "@kevisual/router";
import WebSocket from 'ws'; import WebSocket from 'ws';
import { renderNoAuthAndLogin } from '@/module/assistant/html/login.ts'; import { renderNoAuthAndLogin } from '@/module/assistant/html/login.ts';
import { LiveCode } from '@/module/livecode/index.ts';
const localProxy = new LocalProxy({}); const localProxy = new LocalProxy({});
localProxy.initFromAssistantConfig(assistantConfig); localProxy.initFromAssistantConfig(assistantConfig);
@@ -234,6 +235,27 @@ export const proxyWs = () => {
} }
return proxyApi.map(createProxyInfo); 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) => { export const createProxyInfo = (proxyApiItem: ProxyInfo) => {
const func: WebSocketListenerFun = async (req, res) => { const func: WebSocketListenerFun = async (req, res) => {
const { ws, emitter, id, data } = req; const { ws, emitter, id, data } = req;

View 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');
});

View 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');
});

View File

@@ -1,14 +1,14 @@
{ {
"name": "@kevisual/cli", "name": "@kevisual/cli-docs",
"version": "0.0.3", "version": "0.0.3",
"description": "", "description": "",
"main": "index.js", "main": "index.js",
"basename": "/root/cli", "basename": "/root/cli-docs",
"scripts": { "scripts": {
"dev": "astro dev", "dev": "astro dev",
"build": "astro build", "build": "astro build",
"preview": "astro preview", "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:dev": "slidev --open slides/index.md",
"slide:build": "slidev build slides/index.md --base /root/cli-slide/", "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", "slide:pub": "envision deploy ./slides/dist -k cli-slide -v 0.0.4 -u",
@@ -23,46 +23,46 @@
"@astrojs/react": "^4.4.2", "@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.7.0", "@astrojs/sitemap": "^3.7.0",
"@astrojs/vue": "^5.1.4", "@astrojs/vue": "^5.1.4",
"@kevisual/api": "^0.0.17", "@kevisual/api": "^0.0.28",
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/kv-code": "^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/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1", "@kevisual/registry": "^0.0.1",
"@radix-ui/react-slot": "^1.2.4", "@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18", "@tailwindcss/vite": "^4.1.18",
"@uiw/react-md-editor": "^4.0.11", "@uiw/react-md-editor": "^4.0.11",
"antd": "^6.2.0", "antd": "^6.2.2",
"astro": "^5.16.11", "astro": "^5.16.15",
"class-variance-authority": "^0.7.1", "class-variance-authority": "^0.7.1",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dayjs": "^1.11.19", "dayjs": "^1.11.19",
"es-toolkit": "^1.44.0", "es-toolkit": "^1.44.0",
"github-markdown-css": "^5.8.1", "github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1", "highlight.js": "^11.11.1",
"lucide-react": "^0.562.0", "lucide-react": "^0.563.0",
"marked": "^17.0.1", "marked": "^17.0.1",
"marked-highlight": "^2.2.3", "marked-highlight": "^2.2.3",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"react": "^19.2.3", "react": "^19.2.4",
"react-dom": "^19.2.3", "react-dom": "^19.2.4",
"react-toastify": "^11.0.5", "react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0", "tailwind-merge": "^3.4.0",
"vue": "^3.5.26", "vue": "^3.5.27",
"zustand": "^5.0.10" "zustand": "^5.0.10"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"@kevisual/types": "^0.0.11", "@kevisual/types": "^0.0.12",
"@types/react": "^19.2.8", "@types/react": "^19.2.10",
"@types/react-dom": "^19.2.3", "@types/react-dom": "^19.2.3",
"dotenv": "^17.2.3", "dotenv": "^17.2.3",
"tailwindcss": "^4.1.18", "tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0" "tw-animate-css": "^1.4.0"
}, },
"packageManager": "pnpm@10.28.0", "packageManager": "pnpm@10.28.2",
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
"esbuild", "esbuild",

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kevisual/cli", "name": "@kevisual/cli",
"version": "0.0.94", "version": "0.1.2",
"description": "envision 命令行工具", "description": "envision 命令行工具",
"type": "module", "type": "module",
"basename": "/root/cli", "basename": "/root/cli",
@@ -48,33 +48,34 @@
"@kevisual/app": "^0.0.2", "@kevisual/app": "^0.0.2",
"@kevisual/auth": "^2.0.3", "@kevisual/auth": "^2.0.3",
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/use-config": "^1.0.28", "@kevisual/use-config": "^1.0.30",
"@opencode-ai/sdk": "^1.1.36", "@opencode-ai/sdk": "^1.1.50",
"@types/busboy": "^1.5.4", "@types/busboy": "^1.5.4",
"busboy": "^1.6.0", "busboy": "^1.6.0",
"eventemitter3": "^5.0.4", "eventemitter3": "^5.0.4",
"jose": "^6.1.3", "jose": "^6.1.3",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.5",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"pm2": "latest", "pm2": "latest",
"semver": "^7.7.3", "semver": "^7.7.3",
"unstorage": "^1.17.4" "unstorage": "^1.17.4"
}, },
"devDependencies": { "devDependencies": {
"@kevisual/api": "^0.0.44",
"@kevisual/dts": "^0.0.3", "@kevisual/dts": "^0.0.3",
"@kevisual/load": "^0.0.6", "@kevisual/load": "^0.0.6",
"@kevisual/logger": "^0.0.4", "@kevisual/logger": "^0.0.4",
"@kevisual/query": "0.0.38", "@kevisual/query": "0.0.39",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.8",
"@types/crypto-js": "^4.2.2", "@types/crypto-js": "^4.2.2",
"@types/jsonwebtoken": "^9.0.10", "@types/jsonwebtoken": "^9.0.10",
"@types/micromatch": "^4.0.10", "@types/micromatch": "^4.0.10",
"@types/node": "^25.0.10", "@types/node": "^25.2.0",
"@types/semver": "^7.7.1", "@types/semver": "^7.7.1",
"chalk": "^5.6.2", "chalk": "^5.6.2",
"commander": "^14.0.2", "commander": "^14.0.3",
"crypto-js": "^4.2.0", "crypto-js": "^4.2.0",
"fast-glob": "^3.3.3", "fast-glob": "^3.3.3",
"filesize": "^11.0.13", "filesize": "^11.0.13",
@@ -82,8 +83,8 @@
"ignore": "^7.0.5", "ignore": "^7.0.5",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"tar": "^7.5.6", "tar": "^7.5.7",
"zustand": "^5.0.10" "zustand": "^5.0.11"
}, },
"engines": { "engines": {
"node": ">=22.0.0" "node": ">=22.0.0"

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