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.
This commit is contained in:
2026-02-04 13:20:12 +08:00
parent 6212194f95
commit 5d6bd4f429
21 changed files with 363 additions and 256 deletions

View File

@@ -44,16 +44,16 @@
"devDependencies": {
"@inquirer/prompts": "^8.2.0",
"@kevisual/ai": "^0.0.24",
"@kevisual/api": "^0.0.42",
"@kevisual/api": "^0.0.44",
"@kevisual/load": "^0.0.6",
"@kevisual/local-app-manager": "^0.1.32",
"@kevisual/logger": "^0.0.4",
"@kevisual/query": "0.0.39",
"@kevisual/query-login": "0.0.7",
"@kevisual/router": "^0.0.67",
"@kevisual/router": "^0.0.70",
"@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.30",
"@opencode-ai/plugin": "^1.1.48",
"@opencode-ai/plugin": "^1.1.49",
"@types/bun": "^1.3.8",
"@types/node": "^25.2.0",
"@types/send": "^1.2.1",
@@ -77,11 +77,11 @@
"access": "public"
},
"dependencies": {
"@aws-sdk/client-s3": "^3.980.0",
"@aws-sdk/client-s3": "^3.981.0",
"@kevisual/js-filter": "^0.0.5",
"@kevisual/oss": "^0.0.19",
"@kevisual/video-tools": "^0.0.13",
"@opencode-ai/sdk": "^1.1.48",
"@opencode-ai/sdk": "^1.1.49",
"es-toolkit": "^1.44.0",
"eventemitter3": "^5.0.4",
"lowdb": "^7.0.1",

View File

@@ -60,8 +60,9 @@ app.route({
description: '获取路由列表',
}).define(async (ctx) => {
const list = ctx.app.getList((item) => {
if (item?.path?.includes('auth') || item?.id?.includes('auth')) return false;
if (item?.path?.includes?.('auth') || item?.id?.includes?.('auth')) return false;
return true;
})
console.log('路由列表:', list.length);
ctx.body = { list }
}).addTo(app);

View File

@@ -12,6 +12,7 @@ import { initApi } from '@kevisual/api/proxy'
import { Query } from '@kevisual/query';
import { initLightCode } from '@/module/light-code/index.ts';
import { ModuleResolver } from './assistant-app-resolve.ts';
import z from 'zod';
export class AssistantApp extends Manager {
config: AssistantConfig;
pagesPath: string;
@@ -149,7 +150,9 @@ export class AssistantApp extends Manager {
routerProxy.push({
type: 'lightcode',
lightcode: {
check: true,
id: 'main',
sync: 'remote',
rootPath: path.join(this.config.configPath.appsDir, 'light-code', 'code'),
}
})
}
@@ -162,9 +165,22 @@ export class AssistantApp extends Manager {
continue;
}
if (proxyInfo.type === 'lightcode') {
const schema = z.object({
rootPath: z.string().describe('light-code 代码存放路径'),
sync: z.enum(['remote', 'local', 'both']).describe('同步方式remote: 仅从远程拉取local: 仅上传本地代码both: 双向同步').default('remote'),
});
const parseRes = schema.safeParse(proxyInfo.lightcode);
if (!parseRes.success) {
console.warn('lightcode 配置错误', parseRes.error);
continue;
}
const lightcodeConfig = parseRes.data;
initLightCode({
router: this.mainApp,
config: this.config
config: this.config,
sync: lightcodeConfig.sync,
rootPath: lightcodeConfig.rootPath,
});
continue;
}

View File

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

View File

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

View File

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

View File

@@ -1,17 +1,21 @@
import { WSSManager } from './wss.ts';
import { App, Route } from '@kevisual/router'
import { WebSocketReq } 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: 5000 });
this.wssManager = new WSSManager({ heartbeatInterval: 6 * 5000 });
this.app = app;
this.emitter = new EventEmitter();
console.log('[LiveCode] 模块已初始化');
@@ -46,9 +50,9 @@ export class LiveCode {
return this.wssManager.getConnection(id)
}
async init(id: string): Promise<{ code: number, message?: string, data?: any }> {
return this.sendData({ path: 'router', key: 'list', }, id);
return this.sendData({ message: { path: 'router', key: 'list', } }, id);
}
sendData(data: any, id: string): Promise<{ code: number, message?: string, data?: any }> {
sendData(data: ListenProcessParams, id: string): Promise<{ code: number, message?: string, data?: any }> {
const reqId = customId()
const wss = this.getWss(id);
if (!wss) {
@@ -102,6 +106,7 @@ export class LiveCode {
description: route.description,
metadata: {
...route.metadata,
source: 'livecode',
liveCodeId: wid
},
middleware: ['auth'],
@@ -109,9 +114,15 @@ export class LiveCode {
const { token, cookie, ...rest } = ctx.query;
const tokenUser = ctx.state.tokernUser;
const res = await this.sendData({
id: route.id,
tokenUser,
payload: rest,
message: {
id: route.id,
payload: rest,
},
context: {
state: {
tokenUser
}
}
}, wid);
// console.log('路由响应数据:', res);
ctx.forward(res)

View File

@@ -1,5 +1,6 @@
import { nanoid } from "nanoid";
import { WebSocketReq } from '@kevisual/router'
import { logger } from "../logger.ts";
type ConnectionInfo = {
id: string;
wsReq: WebSocketReq;
@@ -59,7 +60,7 @@ export class WSSManager {
const ws = connection.wsReq.ws;
ws.send(JSON.stringify({ type: 'heartbeat', timestamp: new Date().toISOString() }));
connection.lastHeartbeat = new Date();
console.log(`[LiveCode] 发送心跳给连接 ${connection.id}`);
logger.debug(`[LiveCode] 发送心跳给连接 ${connection.id}`);
}, this.heartbeatInterval);
}

View File

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

View File

@@ -1,84 +1,2 @@
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);
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,5 +1,6 @@
import { app, assistantConfig } from '../app.ts';
import './config/index.ts';
import './client/index.ts';
import './shop-install/index.ts';
import './ai/index.ts';
import './user/index.ts';
@@ -7,7 +8,7 @@ import './call/index.ts'
import './opencode/index.ts';
import './remote/index.ts';
import './kevisual/index.ts'
// import './kevisual/index.ts'
import { authCache } from '@/module/cache/auth.ts';

View File

View File

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

View File

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

View File

@@ -1,9 +1,6 @@
import { createOpencode, createOpencodeClient, OpencodeClient, } from "@opencode-ai/sdk";
import { randomInt } from "es-toolkit";
import getPort from "get-port";
import os from "node:os";
import path from "node:path";
import fs from "node:fs";
import { execSync } from "node:child_process";
const DEFAULT_PORT = 5000;

View File

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

View File

@@ -1,5 +1,5 @@
import { App } from '@kevisual/router'
import { App, ListenProcessResponse } from '@kevisual/router'
import { WebSocket } from 'ws'
import { ReconnectingWebSocket, handleCallApp } from '@kevisual/router/ws'
import net from 'net';
@@ -23,7 +23,7 @@ app.createRouteList();
await new Promise((resolve) => setTimeout(resolve, 1000));
// 创建支持断开重连的 WebSocket 客户端
const ws = new ReconnectingWebSocket('ws://localhost:51516/livecode/ws?id=test-live-app', {
const ws = new ReconnectingWebSocket('ws://localhost:51515/livecode/ws?id=test-live-app', {
maxRetries: Infinity, // 无限重试
retryDelay: 1000, // 初始重试延迟 1 秒
maxDelay: 30000, // 最大延迟 30 秒
@@ -33,7 +33,7 @@ ws.onMessage(async (message) => {
console.log('收到消息:', message);
if (message.type === 'router' && message.id) {
console.log('收到路由响应:', message);
const data = message?.data;
const data = message?.data as ListenProcessResponse;
if (!data) {
ws.send({
type: 'router',
@@ -42,7 +42,17 @@ ws.onMessage(async (message) => {
});
return;
}
const res = await app.run(message.data);
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',