Compare commits

..

35 Commits

Author SHA1 Message Date
14f2dad837 fix: 优化connect ws的模块 2026-01-26 18:51:59 +08:00
915f7aff6b 更新 @opencode-ai/sdk 版本至 1.1.36,调整类型定义以支持 AssistantConfig 2026-01-26 04:20:23 +08:00
15d81c4f68 更新依赖项版本,添加文件哈希功能,优化远程应用连接逻辑 2026-01-26 04:18:03 +08:00
48dafc6040 update pkgs fix lightcode 2026-01-26 01:13:49 +08:00
2ccda9f008 update 2026-01-26 01:13:10 +08:00
6cf3beae94 update 2026-01-25 02:56:56 +08:00
a583fdc211 update 2026-01-22 17:31:26 +08:00
59b3961ff9 temp 2026-01-22 12:51:44 +08:00
ddfdb63598 fix: 修复配置文档中的表格格式;添加路由配置示例 2026-01-22 01:53:51 +08:00
18a6fd4cfe fix doc 2026-01-21 23:25:45 +08:00
028a6ac726 feat: restructure command for Claude models and add new remote routes
- Deleted the old cc.ts command and created a new cc.ts under src/command/claude for better organization.
- Added support for a new model 'bailian' in the command.
- Implemented remote app connection status and connection routes in assistant/src/routes/remote/index.ts.
- Updated index.ts to reflect the new path for the cc command.
- Added a placeholder for future management of plugin operations in src/command/opencode/plugin.ts.
2026-01-21 23:22:58 +08:00
a911334459 fix: 调整重连逻辑以增加每次连接尝试的延迟时间;更新 mvFile 方法以支持 'app-file' 类型 2026-01-21 15:56:28 +08:00
3f899dbd5f update 2026-01-20 23:23:07 +08:00
bb2b129343 fix: 更新 pm2 依赖至最新版本 2026-01-20 21:12:47 +08:00
xiongxiao
2eeaf991b9 update 2026-01-20 20:05:45 +08:00
ddba845ce7 fix: 更新缓存文件路径的创建逻辑 2026-01-20 17:45:59 +08:00
a4e04e7afa chore: 更新版本号至 0.0.89 2026-01-20 17:35:28 +08:00
xiongxiao
398c41a512 update 2026-01-20 17:34:53 +08:00
xiongxiao
dbd044ec66 clear pnpmstore 2026-01-20 16:00:39 +08:00
2b55c2bd03 update 2026-01-20 15:57:21 +08:00
89470346be udpate 2026-01-20 15:39:46 +08:00
9f20e149a0 feat: 更新依赖项,添加 OpenCode 支持,重构代理和路由逻辑,新增 AGENTS 文档 2026-01-20 02:46:29 +08:00
26b4ffa3a2 feat: 移除fileProxy函数,更新ProxyInfo类型,修复文件代理逻辑 2026-01-19 03:52:16 +08:00
43992d896f feat: 重构代理功能,添加文件代理支持并优化相关逻辑 2026-01-18 13:41:47 +08:00
5e5f4f6543 update 2026-01-18 00:31:57 +08:00
fee5076e16 feat: 更新authFilter以允许对/root路径的公共访问并修改相关消息 2026-01-17 23:34:08 +08:00
91d4fed474 feat: update README with installation command and add debug logs in deploy command
- Added installation command for the CLI tool in README.
- Enhanced deploy command with debug logging for upload results and query app version.
- Integrated useKey for fetching KEVISUAL_TOKEN in get-config module.
- Added debug logging in queryAppVersion for better traceability.
- Updated temp.md with new dependency and example command for deployment.
2026-01-17 23:28:38 +08:00
5395449751 update 2026-01-17 20:19:07 +08:00
2cb12644ea update 2026-01-17 16:50:29 +08:00
e1b86aa809 udpate 2026-01-17 16:41:50 +08:00
2f79925e3d update 2026-01-17 16:19:49 +08:00
0b5a0557ee update 2026-01-17 14:48:49 +08:00
b9b4c993f4 add button 2026-01-17 01:15:04 +08:00
99f01e2b94 add cnb 2026-01-17 01:14:00 +08:00
cc043bfd7e update 2026-01-17 01:13:55 +08:00
78 changed files with 3338 additions and 3261 deletions

44
.cnb.yml Normal file
View File

@@ -0,0 +1,44 @@
# .cnb.yml
include:
- https://cnb.cool/kevisual/cnb/-/blob/main/.cnb/template.yml
.common_env: &common_env
env:
TO_REPO: kevisual/cli
TO_URL: git.xiongxiao.me
imports:
- https://cnb.cool/kevisual/env/-/blob/main/.env.development
$:
vscode:
- docker:
image: docker.cnb.cool/kevisual/dev-env:latest
services:
- vscode
- docker
imports: !reference [.common_env, imports]
# 开发环境启动后会执行的任务
# stages:
# - name: pnpm install
# script: pnpm install
stages: !reference [.dev_tempalte, stages]
.common_sync_to_gitea: &common_sync_to_gitea
- <<: *common_env
services: !reference [.common_sync_to_gitea_template, services]
stages: !reference [.common_sync_to_gitea_template, stages]
.common_sync_from_gitea: &common_sync_from_gitea
- <<: *common_env
services: !reference [.common_sync_from_gitea_template, services]
stages: !reference [.common_sync_from_gitea_template, stages]
main:
web_trigger_sync_to_gitea:
- <<: *common_sync_to_gitea
web_trigger_sync_from_gitea:
- <<: *common_sync_from_gitea
api_trigger_sync_to_gitea:
- <<: *common_sync_to_gitea
api_trigger_sync_from_gitea:
- <<: *common_sync_from_gitea

11
.cnb/web_trigger.yml Normal file
View File

@@ -0,0 +1,11 @@
# .cnb/web_trigger.yml
branch:
# 如下按钮在分支名以 release 开头的分支详情页面显示
- reg: "^main"
buttons:
- name: 同步代码到gitea
description: 同步代码到gitea
event: web_trigger_sync_to_gitea
- name: 同步gitea代码到当前仓库
description: 同步gitea代码到当前仓库
event: web_trigger_sync_from_gitea

3
.gitignore vendored
View File

@@ -8,3 +8,6 @@ pack-dist
assistant-app assistant-app
build build
.pnpm-store
jwt

1
.npmrc
View File

@@ -1,2 +1,3 @@
//npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN} //npm.xiongxiao.me/:_authToken=${ME_NPM_TOKEN}
//npm.cnb.cool/kevisual/registry/-/packages/:_authToken=${CNB_API_KEY}
//registry.npmjs.org/:_authToken=${NPM_TOKEN} //registry.npmjs.org/:_authToken=${NPM_TOKEN}

View File

@@ -0,0 +1 @@
export { AgentPlugin } from "../../assistant/src/main.ts";

2
apps/.gitignore vendored
View File

@@ -1,2 +0,0 @@
container
root

View File

@@ -1,13 +0,0 @@
{
"name": "apps",
"version": "1.0.0",
"description": "",
"main": "index.js",
"type": "module",
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1"
},
"keywords": [],
"author": "",
"license": "ISC"
}

View File

@@ -11,3 +11,5 @@ assistant-app
!.env*example !.env*example
libs libs
cache-file

View File

@@ -0,0 +1 @@
export { AgentPlugin } from "../../src/main.ts";

View File

@@ -0,0 +1,35 @@
---
name: kill-opencode
description: 自动查找并杀死所有opencode相关的进程确保系统资源释放。
tags:
- opencode
- process-management
- automation
---
```bash
#!/bin/bash
# kill_opencode.sh - 自动杀死所有opencode进程
echo "正在查找opencode进程..."
ps aux | grep opencode | grep -v grep
if [ $? -eq 0 ]; then
echo "正在杀死所有opencode进程..."
pkill -f opencode
sleep 2
# 检查是否还有残留进程
remaining=$(ps aux | grep opencode | grep -v grep | wc -l)
if [ $remaining -gt 0 ]; then
echo "发现 $remaining 个顽固进程,使用强制杀死模式..."
pkill -9 -f opencode
fi
echo "opencode进程清理完成"
else
echo "未找到opencode进程"
fi
# 验证是否已完全清理
echo "当前opencode进程状态"
ps aux | grep opencode | grep -v grep || echo "没有运行中的opencode进程"
```

204
assistant/AGENTS.md Normal file
View File

@@ -0,0 +1,204 @@
# AGENTS.md
## 构建、代码检查和测试命令
### 核心命令
```bash
# 开发
bun run dev # 运行主入口 (src/run.ts)
bun run dev:server # 启动服务器(热重载)
bun run dev:cnb # 使用自定义配置目录启动服务器
bun run dev:share # 测试远程应用
# 构建
bun run build # 完整生产构建(先清理 dist
bun run build:lib # 构建库文件
bun run postbuild:lib # 生成 TypeScript 类型定义
# 包管理
pnpm install # 安装依赖(使用 pnpm v10.28.0
```
### 环境要求
- **Node.js**: >=22.0.0
- **运行时**: Bun开发环境/ Node.js生产环境
- **包管理器**: pnpm@10.28.0(强制要求)
### 环境变量
- `ASSISTANT_CONFIG_DIR`: 覆盖助手配置文件目录路径
- `BUN_PATH`: 覆盖 Bun 可执行文件路径
---
## 代码风格规范
### TypeScript
- **严格模式**: 通过扩展配置启用(`@kevisual/types/json/backend.json`
- **模块格式**: 仅 ESM`package.json` 中的 `"type": "module"`
- **目标**: NodeNext 模块解析
- **路径别名**: 使用 `@/*` 作为本地导入别名(如 `import { Foo } from '@/module/foo'`
### 导入规则
```typescript
// Node.js 内置模块必须使用 node: 协议
import fs from 'node:fs';
import path from 'node:path';
import { execSync } from 'node:child_process';
// 外部包(不使用路径别名)
import chalk from 'chalk';
import { program } from 'commander';
// 本地模块(使用 @ 别名)
import { AssistantInit } from '@/services/init/index.ts';
// 类型导入
import type { AssistantConfig } from '@/module/assistant/index.ts';
```
### 命名规范
| 类型 | 规范 | 示例 |
|------|------|------|
| 类 | PascalCase | `AssistantInit`, `AssistantApp`, `AssistantQuery` |
| 接口 | PascalCase | `ProxyInfo`, `AssistantInitOptions` |
| 类型 | PascalCase | `AssistantConfigData` |
| 函数 | camelCase | `getBunPath`, `checkFileExists`, `parseHomeArg` |
| 变量 | camelCase | `configDir`, `assistantConfig`, `isPortAvailable` |
| 私有字段 | `#` 前缀 + camelCase | `#query`, `#config` |
| 常量 | UPPER_SNAKE_CASE 或 camelCase | `commonPaths`, `randomId` |
| 文件名 | kebab-case | `proxy-page-index.ts`, `query-login.ts` |
| 目录名 | kebab-case | `query-login`, `hot-api` |
### 代码模式
#### 错误处理
```typescript
// 同步操作包装
try {
const result = fs.readFileSync(path);
return result;
} catch (error) {
console.error('读取文件错误:', error.message);
continue; // 或 return/throw
}
// 异步操作包装
try {
const result = await someAsyncOperation();
return result;
} catch (error) {
console.error('操作失败:', error.message);
process.exit(1); // 或 throw/return undefined
}
```
#### 上下文/依赖注入
使用 `@kevisual/use-config` 进行上下文管理:
```typescript
export const assistantConfig = useContextKey<AssistantInit>('assistantConfig', () => {
return new AssistantInit({
path: configDir,
init: isInit,
});
});
```
#### 路由模式
使用 `@kevisual/router` 的 App 和 SimpleRouter
```typescript
app.route({
path: 'router',
key: 'list',
description: '获取路由列表',
}).define(async (ctx) => {
ctx.body = { list };
}).addTo(app);
```
### JSDoc 文档
为公共 API、复杂函数和类型添加文档
```typescript
/**
* 助手初始化类
* @class AssistantInit
*/
export class AssistantInit extends AssistantConfig {}
/**
* @param {string} p - 要解析的路径
* @returns {string} 解析后的绝对路径
*/
export const w = (p: string) => path.join(__dirname, p);
```
### 文件组织
- **路由**: `src/routes/``src/routes-simple/`
- **模块**: `src/module/`(功能模块)
- **服务**: `src/services/`(公共服务)
- **命令**: `src/command/`CLI 命令)
- **查询**: `src/query/`API/查询逻辑)
- **入口文件**:
- `src/index.ts` - 主 CLI 入口
- `src/server.ts` - 服务器入口
- `src/run.ts` - 开发运行器
- `src/run-server.ts` - 服务器运行器
### 控制台输出
使用 `chalk` 实现彩色输出:
```typescript
console.log(chalk.blue('助手路径不存在,正在创建...'));
console.log(chalk.yellow('助手路径已存在'), chalk.green(assistantConfig.configDir));
console.error(chalk.red('启动服务器错误:'), error.message);
```
### 系统检测
```typescript
const isWindows = process.platform === 'win32';
const bunExecutableName = isWindows ? 'bun.exe' : 'bun';
```
### 端口处理
```typescript
import getPort, { portNumbers } from 'get-port';
const port = await getPort({ port: 51515 });
const availablePort = await getPort({ port: portNumbers(51515, 52000) });
```
### 关键依赖
- `@kevisual/router` - 应用路由
- `@kevisual/query` - API 查询
- `@kevisual/use-config` - 配置/上下文
- `commander` - CLI 参数解析
- `chalk` - 终端颜色
- `ws` - WebSocket通过 `@kevisual/ws`
- `pm2` - 进程管理(生产环境)
### 重要说明
1. 开发时始终使用 `bun`,但构建输出以 Node.js 为目标
2. 打包时外部包必须在 `bun.config.mjs``external` 数组中声明
3. 库构建排除 `pm2`
4.`ENVISION_*` 为前缀的环境变量在构建时可用
5. 项目在业务逻辑中广泛使用中文注释
6. 私有类成员应使用 `#` 前缀语法
7. 操作前始终使用 `checkFileExists` 检查文件是否存在

View File

@@ -6,7 +6,7 @@ import fs from 'node:fs';
// bun run src/index.ts -- // bun run src/index.ts --
import { fileURLToPath } from 'node:url'; import { fileURLToPath } from 'node:url';
const __dirname = path.dirname(fileURLToPath(import.meta.url)); const __dirname = path.dirname(fileURLToPath(import.meta.url));
const external = ['pm2', '@kevisual/hot-api', '@nut-tree-fork/nut-js']; const external = ['pm2', '@kevisual/hot-api', '@nut-tree-fork/nut-js', 'bun'];
/** /**
* *
* @param {string} p * @param {string} p
@@ -40,6 +40,28 @@ await Bun.build({
}, },
external, external,
}); });
await Bun.build({
target: 'node',
format: 'esm',
entrypoints: [w('./src/main.ts')],
outdir: w('./dist'),
naming: {
entry: 'assistant-opencode.js',
},
define: {
ENVISION_VERSION: JSON.stringify(pkg.version),
},
external,
});
const dts = 'dts -i src/main.ts -o assistant-opencode.d.ts';
Bun.spawnSync({
cmd: ['sh', '-c', dts],
cwd: __dirname,
stdout: 'inherit',
stderr: 'inherit',
});
// const copyDist = ['dist', 'bin']; // const copyDist = ['dist', 'bin'];
const copyDist = ['dist']; const copyDist = ['dist'];
export const copyFileToEnvision = async () => { export const copyFileToEnvision = async () => {

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kevisual/assistant-cli", "name": "@kevisual/assistant-cli",
"version": "0.0.6", "version": "0.0.8",
"description": "", "description": "",
"main": "dist/assistant.mjs", "main": "dist/assistant.mjs",
"keywords": [ "keywords": [
@@ -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.26.2", "packageManager": "pnpm@10.28.1",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -41,18 +41,19 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@kevisual/ai": "^0.0.19", "@kevisual/ai": "^0.0.22",
"@kevisual/api": "^0.0.26",
"@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.33", "@kevisual/query": "0.0.38",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@kevisual/router": "^0.0.52", "@kevisual/router": "^0.0.62",
"@kevisual/types": "^0.0.10", "@kevisual/types": "^0.0.12",
"@kevisual/use-config": "^1.0.21", "@kevisual/use-config": "^1.0.28",
"@types/bun": "^1.3.5", "@opencode-ai/plugin": "^1.1.36",
"@types/lodash-es": "^4.17.12", "@types/bun": "^1.3.6",
"@types/node": "^25.0.3", "@types/node": "^25.0.10",
"@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",
@@ -61,8 +62,7 @@
"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.1.0", "inquirer": "^13.2.1",
"lodash-es": "^4.17.22",
"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,14 +76,17 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@aws-sdk/client-s3": "^3.969.0", "@aws-sdk/client-s3": "^3.975.0",
"@kevisual/ha-api": "^0.0.6", "@kevisual/ha-api": "^0.0.6",
"@kevisual/js-filter": "^0.0.5",
"@kevisual/oss": "^0.0.16", "@kevisual/oss": "^0.0.16",
"@kevisual/video-tools": "^0.0.13", "@kevisual/video-tools": "^0.0.13",
"eventemitter3": "^5.0.1", "@opencode-ai/sdk": "^1.1.36",
"es-toolkit": "^1.44.0",
"eventemitter3": "^5.0.4",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.4",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"unstorage": "^1.17.3" "unstorage": "^1.17.4"
} }
} }

View File

@@ -1,7 +1,19 @@
# assistant cli # assistant cli
## 初始化路径 ## 安装
## 启动服务 ```bash
npm i -g @kevisual/cli --registry=https://npm.cnb.cool/kevisual/registry/-/packages/
```
## 环境变量配置项
## 配置 - ASSISTANT_CONFIG_DIR
- 说明:指定 assistant 的配置文件目录
- 示例:`export ASSISTANT_CONFIG_DIR=/path/to/your/config/dir`
## 启动命令
```bash
asst server -s -p 8686
```

View File

@@ -1,11 +1,13 @@
import { App } from '@kevisual/router'; import { App } from '@kevisual/router';
import { SimpleRouter } from '@kevisual/router/simple'
// import { App } from '@kevisual/router/src/app.ts'; // import { App } from '@kevisual/router/src/app.ts';
import { HttpsPem } from '@/module/assistant/https/sign.ts';
// import { AssistantConfig } from '@/module/assistant/index.ts'; // import { AssistantConfig } from '@/module/assistant/index.ts';
import { AssistantInit, parseHomeArg } from '@/services/init/index.ts'; 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';
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);
@@ -14,6 +16,7 @@ export const assistantConfig = useContextKey<AssistantInit>('assistantConfig', (
return new AssistantInit({ return new AssistantInit({
path: configDir, path: configDir,
init: isInit, init: isInit,
initWorkspace: manualParse.isOpencode ? false : true,
}); });
}); });
@@ -21,8 +24,6 @@ export const assistantQuery = useContextKey('assistantQuery', () => {
return new AssistantQuery(assistantConfig); return new AssistantQuery(assistantConfig);
}); });
const httpsPem = new HttpsPem(assistantConfig);
export const runtime = useContextKey('runtime', () => { export const runtime = useContextKey('runtime', () => {
return { return {
type: 'client', type: 'client',
@@ -30,30 +31,10 @@ export const runtime = useContextKey('runtime', () => {
}); });
export const app: App = useContextKey<App>('app', () => { export const app: App = useContextKey<App>('app', () => {
const init = isInit;
if (init) {
const config = assistantConfig.getConfig();
if (config?.https?.type !== 'https') {
console.log('http模式', 'http');
return new App({
serverOptions: {
path: '/client/router',
httpType: 'http',
cors: {
origin: '*',
},
io: true
},
});
}
}
return new App({ return new App({
serverOptions: { serverOptions: {
path: '/client/router', path: '/client/router',
httpType: 'https', httpType: 'http',
httpsCert: httpsPem.cert,
httpsKey: httpsPem.key,
cors: { cors: {
origin: '*', origin: '*',
}, },
@@ -62,6 +43,9 @@ export const app: App = useContextKey<App>('app', () => {
}); });
}); });
export const simpleRouter = useContextKey('simpleRouter', () => {
return new SimpleRouter();
});
app.route({ app.route({
path: 'router', path: 'router',
@@ -69,7 +53,7 @@ app.route({
description: '获取路由列表', description: '获取路由列表',
}).define(async (ctx) => { }).define(async (ctx) => {
const list = ctx.app.getList((item) => { const list = ctx.app.getList((item) => {
if (item.id === 'auth') return false; if (item?.path?.includes('auth') || item?.id?.includes('auth')) return false;
return true; return true;
}) })
ctx.body = { list } ctx.body = { list }

View File

@@ -6,19 +6,19 @@ import inquirer from 'inquirer';
import chalk from 'chalk'; import chalk from 'chalk';
type InitCommandOptions = { type InitCommandOptions = {
path?: string; workspace?: string;
}; };
const Init = new Command('init') const Init = new Command('init')
.description('初始化一个助手客户端,生成配置文件。') .description('初始化一个助手客户端,生成配置文件。')
.option('-p --path <path>', '助手路径,默认为执行命令的目录,如果助手路径不存在则创建。') .option('-w --workspace <workspace>', '助手路径,默认为执行命令的目录,如果助手路径不存在则创建。')
.action((opts: InitCommandOptions) => { .action((opts: InitCommandOptions) => {
// 如果path参数存在检测path是否是相对路径,如果是相对路径,则转换为绝对路径 // 如果workspace参数存在检测workspace是否是相对路径,如果是相对路径,则转换为绝对路径
if (opts.path && !opts.path.startsWith('/')) { if (opts.workspace && !opts.workspace.startsWith('/')) {
opts.path = path.join(process.cwd(), opts.path); opts.workspace = path.join(process.cwd(), opts.workspace);
} else if (opts.path) { } else if (opts.workspace) {
opts.path = path.resolve(opts.path); opts.workspace = path.resolve(opts.workspace);
} }
const configDir = AssistantInit.detectConfigDir(opts.path); const configDir = AssistantInit.detectConfigDir(opts.workspace);
console.log('configDir', configDir); console.log('configDir', configDir);
const assistantInit = new AssistantInit({ const assistantInit = new AssistantInit({
path: configDir, path: configDir,

View File

@@ -6,87 +6,6 @@ import { AssistantApp, checkFileExists } from '@/lib.ts';
import { logger } from '@/module/logger.ts'; import { logger } from '@/module/logger.ts';
import { LoadApp, StopApp } from '@/module/local-apps/src/modules/manager.ts'; import { LoadApp, StopApp } from '@/module/local-apps/src/modules/manager.ts';
const runScriptsCommand = new Command('run-scripts')
.alias('run')
.arguments('<cmd> [env]')
.option('-l --local', '使用当前文件夹的package.json中的scripts', false)
.description('运行脚本在assistant.config.json中配置的脚本')
.action(async (cmd, env, opts) => {
const useLocal = opts.local;
const showScripts = cmd === 'show';
const showScriptFunc = (scripts: any) => {
console.log('可用的本地脚本:');
let has = false;
Object.keys(scripts).forEach((key) => {
console.log(`- ${key}: ${scripts[key]}`);
has = true;
});
if (!has) {
console.log('当前未定义任何脚本。');
}
}
if (useLocal) {
const pkgPath = path.join(process.cwd(), 'package.json');
if (checkFileExists(pkgPath) === false) {
console.error('当前目录下未找到 package.json 文件。');
return;
}
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf-8'));
const scripts = pkg.scripts || {};
if (showScripts) {
showScriptFunc(scripts);
return;
}
const script = scripts[cmd];
if (!script) {
console.error(`Script "${cmd}" not found in local package.json.`);
return;
}
const command = [script, ...(env ? [env] : [])].join(' ');
const res = spawnSync(command, { shell: true, stdio: 'inherit', cwd: assistantConfig.configDir });
console.log(`执行 "[${command}]"...`);
if (res.error) {
console.error(`执行失败 "${cmd}":`, res.error);
return;
}
if (res.status !== 0) {
console.error(`本地脚本 "${cmd}" 以代码 ${res.status} 退出`);
return;
}
return;
}
assistantConfig.checkMounted();
const configs = assistantConfig.getCacheAssistantConfig();
const scripts = configs?.scripts || {};
try {
const script = scripts[cmd];
if (showScripts) {
showScriptFunc(scripts);
return;
}
if (!script) {
console.error(`Script "${cmd}" not found.`);
return;
}
// console.log(`Running script "${script}"...`);
const command = [script, ...(env ? [env] : [])].join(' ');
// console.log(`Command: ${command}`, env);
const res = spawnSync(command, { shell: true, stdio: 'inherit', cwd: assistantConfig.configDir });
if (res.error) {
console.error(`Error running script "${cmd}":`, res.error);
return;
}
if (res.status !== 0) {
console.error(`Script "${cmd}" exited with code ${res.status}`);
return;
}
console.log(`Script "${cmd}" run successfully.`);
} catch (error) {
console.error(`Failed to run script "${cmd}":`, error);
}
});
program.addCommand(runScriptsCommand);
const createRandomApp = (opts: { app: any, package: any, pwd: string, status?: string }) => { const createRandomApp = (opts: { app: any, package: any, pwd: string, status?: string }) => {
const { app, package: packageJson, pwd } = opts; const { app, package: packageJson, pwd } = opts;
if (!app.status) { if (!app.status) {

9
assistant/src/main.ts Normal file
View File

@@ -0,0 +1,9 @@
import { app } from './app.ts';
import { type Plugin } from "@opencode-ai/plugin"
import { createRouterAgentPluginFn } from '@kevisual/router/opencode';
import './routes/index.ts';
import './routes-simple/index.ts';
export const AgentPlugin: Plugin = createRouterAgentPluginFn({
router: app,
})

View File

@@ -4,12 +4,13 @@ import fs from 'fs';
import { checkFileExists, createDir } from '../file/index.ts'; 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';
let kevisualDir = path.join(homedir(), 'kevisual'); let kevisualDir = path.join(homedir(), 'kevisual');
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
if (envKevisualDir) { if (envKevisualDir) {
kevisualDir = envKevisualDir; kevisualDir = envKevisualDir;
console.log('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir); logger.debug('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
} }
/** /**
* 助手配置文件路径, 全局配置文件目录 * 助手配置文件路径, 全局配置文件目录
@@ -93,6 +94,17 @@ export type AssistantConfigData = {
* 例子: { path: '/root/home', target: 'https://kevisual.cn', pathname: '/root/home' } * 例子: { path: '/root/home', target: 'https://kevisual.cn', pathname: '/root/home' }
*/ */
proxy?: ProxyInfo[]; proxy?: ProxyInfo[];
/**
* Router代理, 会自动获取 {path: 'router', key: 'list'}的路由信息,然后注入到整个router应用当中.
* 例子: { proxy: [ { type: 'router', api: 'https://localhost:50002/api/router' } ] }
* base: 是否使用 /api/router的基础路径默认false
* lightcode: 是否启用lightcode路由默认false
*/
router?: {
proxy: ProxyInfo[];
base?: boolean;
lightcode?: boolean;
}
/** /**
* API 代理配置, 比如api开头的v1开头的等等 * API 代理配置, 比如api开头的v1开头的等等
*/ */
@@ -143,28 +155,29 @@ export type AssistantConfigData = {
enabled?: boolean; enabled?: boolean;
token?: string; token?: string;
} }
/**
* 自定义脚本, asst 启动时会执行这些脚本
*/
scripts?: {
[key: string]: string;
};
/** /**
* 认证和权限配置 * 认证和权限配置
* share: protected 需要认证代理访问(默认) public 公开访问, private 私有访问 * share: protected 需要认证代理访问(默认) public 公开访问, private 私有访问
* share 是对外共享 pages 目录下的页面 * share 是对外共享 pages 目录下的页面
*/ */
auth?: AuthPermission; auth?: AuthPermission;
/** storage?: AssistantStorage[];
* HTTPS 证书配置, 启用后,助手服务会启用 HTTPS 服务, 默认 HTTP
* 理论上也不需要https因为可以通过反向代理实现https
*/
https?: {
type?: 'https' | 'http';
keyPath?: string; // 证书私钥路径
certPath?: string; // 证书路径
};
}; };
type AssistantStorage = FileStorage | S3Storage;
type FileStorage = {
id: string;
path: string;
type: 'local'
}
type S3Storage = {
id: string;
bucket: string;
region: string;
accessKeyId: string;
secretAccessKey: string;
endpoint?: string;
type: 's3' | 'minio';
}
let assistantConfig: AssistantConfigData; let assistantConfig: AssistantConfigData;
type AssistantConfigOptions = { type AssistantConfigOptions = {
configDir?: string; configDir?: string;
@@ -190,10 +203,11 @@ export class AssistantConfig {
} }
return this.#configPath; return this.#configPath;
} }
init() { init(configDir?: string) {
this.configPath = initConfig(this.configDir); this.configPath = initConfig(configDir || this.configDir);
this.isMountedConfig = true; this.isMountedConfig = true;
} }
checkMounted() { checkMounted() {
if (!this.isMountedConfig) { if (!this.isMountedConfig) {
this.init(); this.init();
@@ -355,7 +369,7 @@ type AppConfig = {
list: any[]; list: any[];
}; };
export function parseArgs(args: string[]) { export function parseArgs(args: string[]) {
const result: { root?: string; home?: boolean; help?: boolean } = {}; const result: { root?: string; home?: boolean; help?: boolean } = { home: true };
for (let i = 0; i < args.length; i++) { for (let i = 0; i < args.length; i++) {
const arg = args[i]; const arg = args[i];
// 处理 root 参数 // 处理 root 参数
@@ -365,14 +379,13 @@ export function parseArgs(args: string[]) {
i++; // 跳过下一个参数,因为它是值 i++; // 跳过下一个参数,因为它是值
} }
} }
// 处理 home 参数
// if (arg === '--home') {
result.home = true;
// }
if (arg === '--help' || arg === '-h') { if (arg === '--help' || arg === '-h') {
result.help = true; result.help = true;
} }
} }
if (result.root) {
result.home = false;
}
return result; return result;
} }
/** /**
@@ -382,6 +395,7 @@ export function parseArgs(args: string[]) {
*/ */
export const parseHomeArg = (homedir?: string) => { export const parseHomeArg = (homedir?: string) => {
const args = process.argv.slice(2); const args = process.argv.slice(2);
const execPath = process.execPath;
const options = parseArgs(args); const options = parseArgs(args);
let _configDir = undefined; let _configDir = undefined;
if (options.home && homedir) { if (options.home && homedir) {
@@ -389,7 +403,10 @@ export const parseHomeArg = (homedir?: string) => {
} else if (options.root) { } else if (options.root) {
_configDir = options.root; _configDir = options.root;
} }
const checkUrl = ['.opencode', 'bin/opencode', 'opencode.exe']
const isOpencode = checkUrl.some((item) => execPath.includes(item))
return { return {
isOpencode,
options, options,
configDir: _configDir, configDir: _configDir,
}; };

View File

@@ -35,7 +35,8 @@ export const checkFileDir = (filePath: string, create = true) => {
return exist; return exist;
}; };
export const createDir = (dirPath: string) => { export const createDir = (dirPath: string, isCreate = true) => {
if (!isCreate) return dirPath;
if (!checkFileExists(dirPath)) { if (!checkFileExists(dirPath)) {
fs.mkdirSync(dirPath, { recursive: true }); fs.mkdirSync(dirPath, { recursive: true });
} }

View File

@@ -1,180 +0,0 @@
import { createCert } from '@kevisual/router/sign';
import path from 'node:path';
import fs from 'node:fs';
import { AssistantConfig } from '../config/index.ts';
import { checkFileExists } from '../file/index.ts';
import { chalk } from '@/module/chalk.ts';
import dayjs from 'dayjs';
type Attributes = {
name: string;
value: string;
};
type AltNames = {
type: number;
value?: string;
ip?: string;
};
export class HttpsPem {
assistantConfig: AssistantConfig;
key: string;
cert: string;
isHttps = false;
constructor(assistantConfig: AssistantConfig) {
this.assistantConfig = assistantConfig;
this.#initKeyCert();
}
#initKeyCert() {
this.assistantConfig.checkMounted();
const config = this.assistantConfig.getConfig();
if (config.https) {
const httpsType = config.https?.type || 'https';
if (httpsType !== 'https') {
// console.log(chalk.yellow('当前配置文件 https.type 不是 https, 不使用证书'));
return;
}
this.isHttps = true;
if (config.https.keyPath) {
const keyPath = config.https.keyPath;
const certPath = config.https.certPath;
if (checkFileExists(keyPath) && checkFileExists(certPath)) {
this.key = fs.readFileSync(keyPath, 'utf-8');
this.cert = fs.readFileSync(certPath, 'utf-8');
console.log(chalk.green('使用配置文件 https.keyPath 和 https.certPath 的证书'), keyPath, certPath);
return;
} else {
console.log(chalk.red('证书路径不存在,请检查配置文件 https.keyPath 和 https.certPath 是否正确'));
}
}
}
if(!this.isHttps) return;
const { key, cert } = this.getCert();
this.key = key;
this.cert = cert;
}
getPemDir() {
const configDir = this.assistantConfig.configPath?.configDir || process.cwd();
const pemDir = path.join(configDir, 'pem');
if (!checkFileExists(pemDir)) {
fs.mkdirSync(pemDir, { recursive: true });
}
return pemDir;
}
getCert() {
if (!this.assistantConfig.init) return;
const pemDir = this.getPemDir();
const pemPath = {
key: path.join(pemDir, 'https-private-key.pem'),
cert: path.join(pemDir, 'https-cert.pem'),
config: path.join(pemDir, 'https-config.json'),
};
const writeCreate = (opts: { key: string; cert: string; data: { createTime: number; expireTime: number } }) => {
fs.writeFileSync(pemPath.key, opts.key);
fs.writeFileSync(pemPath.cert, opts.cert);
fs.writeFileSync(pemPath.config, JSON.stringify(opts.data, null, 2));
};
if (!checkFileExists(pemPath.key) || !checkFileExists(pemPath.cert)) {
const { key, cert, data } = this.createCert();
writeCreate({ key, cert, data });
console.log(chalk.green('证书创建成功,浏览器需要导入当前证书'));
return {
key,
cert,
};
}
if (!checkFileExists(pemPath.config)) {
const data = this.createExpireData();
fs.writeFileSync(pemPath.config, JSON.stringify(data, null, 2));
}
const key = fs.readFileSync(pemPath.key, 'utf-8');
const cert = fs.readFileSync(pemPath.cert, 'utf-8');
const config = fs.readFileSync(pemPath.config, 'utf-8');
let expireTime = 0;
try {
const data = JSON.parse(config);
expireTime = data.expireTime;
if (typeof expireTime !== 'number') {
throw new Error('expireTime is not a number');
}
} catch (error) {
console.log(chalk.red('证书配置文件损坏,重新生成证书'));
}
const now = new Date().getTime();
if (now > expireTime) {
this.removeCert();
const { key, cert, data } = this.createCert();
writeCreate({ key, cert, data });
console.log(chalk.green('证书更新成功, 浏览器需要重新导入当前证书'));
return {
key,
cert,
};
}
return {
key,
cert,
};
}
createExpireData() {
const expireTime = new Date().getTime() + 365 * 24 * 60 * 60 * 1000;
const expireDate = dayjs(expireTime).format('YYYY-MM-DD HH:mm:ss');
return {
description: '手动导入证书到浏览器, https-cert.pem文件, 具体使用教程访问 https://kevisual.cn/root/pem-docs/',
createTime: new Date().getTime(),
expireDate,
expireTime,
};
}
/*
* 重新生成证书
*/
removeCert() {
const pemDir = this.getPemDir();
const pemPath = {
key: path.join(pemDir, 'https-private-key.pem'),
cert: path.join(pemDir, 'https-cert.pem'),
};
const oldPath = {
key: path.join(pemDir, 'https-private-key.pem.bak'),
cert: path.join(pemDir, 'https-cert.pem.bak'),
};
if (checkFileExists(pemPath.key)) {
fs.renameSync(pemPath.key, oldPath.key);
}
if (checkFileExists(pemPath.cert)) {
fs.renameSync(pemPath.cert, oldPath.cert);
}
}
/*
* 创建证书
* @param attrs 证书属性
* @param altNames 证书备用名称
*/
createCert(attrs?: Attributes[], altNames?: AltNames[]) {
const attributes = attrs || [];
const altNamesList = altNames || [];
const { key, cert } = createCert(
[
{
name: 'commonName',
value: 'localhost',
},
{
name: 'organizationName',
value: 'kevisual',
},
...attributes,
],
altNamesList,
);
const data = this.createExpireData();
return {
key,
cert,
data: {
...data,
},
};
}
}

View File

@@ -7,12 +7,17 @@ import glob from 'fast-glob';
import type { App } from '@kevisual/router'; import type { App } from '@kevisual/router';
import { RemoteApp } from '@/module/remote-app/remote-app.ts'; import { RemoteApp } from '@/module/remote-app/remote-app.ts';
import { logger } from '@/module/logger.ts'; import { logger } from '@/module/logger.ts';
import { getEnvToken } from '@/module/http-token.ts';
import { initApi } from '@kevisual/api/proxy'
import { Query } from '@kevisual/query';
import { initLightCode } from '@/module/light-code/index.ts';
export class AssistantApp extends Manager { export class AssistantApp extends Manager {
config: AssistantConfig; config: AssistantConfig;
pagesPath: string; pagesPath: string;
remoteIsConnected = false; remoteIsConnected = false;
attemptedConnectTimes = 0; attemptedConnectTimes = 0;
remoteApp: RemoteApp | null = null; remoteApp: RemoteApp | null = null;
remoteUrl: string | null = null;
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');
@@ -71,11 +76,17 @@ export class AssistantApp extends Manager {
return pagesParse; return pagesParse;
} }
async initRemoteApp() { async initRemoteApp(opts?: { token?: string, enabled?: boolean }) {
const config = this.config.getConfig(); const config = this.config.getConfig();
const share = config?.share; const share = config?.share;
if (share && share.enabled !== false) { const enabled = opts?.enabled ?? share?.enabled ?? false;
const token = config?.token; if (share && enabled !== false) {
if (this.remoteApp) {
this.remoteApp.ws?.close();
this.remoteApp = null;
this.remoteIsConnected = false;
}
const token = config?.token || opts?.token || getEnvToken() as string;
const url = new URL(share.url || 'https://kevisual.cn/ws/proxy'); const url = new URL(share.url || 'https://kevisual.cn/ws/proxy');
const id = config?.app?.id; const id = config?.app?.id;
if (token && url && id) { if (token && url && id) {
@@ -89,7 +100,9 @@ export class AssistantApp extends Manager {
if (isConnect) { if (isConnect) {
remoteApp.listenProxy(); remoteApp.listenProxy();
this.remoteIsConnected = true; this.remoteIsConnected = true;
remoteApp.emitter.once('close', () => { // 清理已有的 close 事件监听器,防止多重绑定
remoteApp.emitter.removeAllListeners('close');
remoteApp.emitter.on('close', () => {
setTimeout(() => { setTimeout(() => {
if (remoteApp.isError) { if (remoteApp.isError) {
console.error('远程应用发生错误,不重连'); console.error('远程应用发生错误,不重连');
@@ -99,12 +112,79 @@ export class AssistantApp extends Manager {
}, 5 * 1000); // 第一次断开5秒后重连 }, 5 * 1000); // 第一次断开5秒后重连
}); });
logger.debug('链接到了远程应用服务器'); logger.debug('链接到了远程应用服务器');
const appId = id;
const username = config?.auth.username || 'unknown';
const url = new URL(`/${username}/v1/${appId}`, config?.registry || 'https://kevisual.cn/');
this.remoteUrl = url.toString();
console.log('远程地址', this.remoteUrl);
} else { } else {
console.log('Not connected to remote app server'); console.log('Not connected to remote app server');
} }
this.remoteApp = remoteApp; this.remoteApp = remoteApp;
} else { } else {
// if (!token) {
logger.error('Token是远程应用连接必须的参数');
}
}
}
}
async initRouterApp() {
const config = this.config.getConfig();
const routerProxy = config?.router?.proxy || [];
const base = config.router?.base ?? false;
const lightcode = config.router?.lightcode ?? true;
if (base) {
routerProxy.push({
type: 'router',
router: {
url: `${this.config.getRegistry()}/api/router`,
}
})
}
if (lightcode) {
routerProxy.push({
type: 'lightcode',
lightcode: {
check: true,
}
})
}
if (routerProxy.length === 0) {
return
}
for (const proxyInfo of routerProxy) {
if (proxyInfo.type !== 'router' && proxyInfo.type !== 'lightcode') {
console.warn('路由的type必须是"router", 或者lightcode');
continue;
}
if (proxyInfo.type === 'lightcode') {
initLightCode({
router: this.mainApp,
config: this.config
});
continue;
}
const url = proxyInfo.router!.url;
if (!url) {
console.warn('路由的api地址不能为空', proxyInfo.router);
continue;
}
const query = new Query({ url });
try {
initApi({
router: this.mainApp,
item: {
type: 'api',
api: {
url,
query: query as any,
}
},
exclude: "WHERE path = 'auth' OR path = 'router' OR path = 'call'",
})
console.log('Router API 已初始化', url.toString());
} catch (err) {
console.error('Router API 初始化失败', url.toString(), err);
} }
} }
} }
@@ -112,6 +192,10 @@ export class AssistantApp extends Manager {
console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes); console.log('重新连接到远程应用服务器...', this.attemptedConnectTimes);
const remoteApp = this.remoteApp;; const remoteApp = this.remoteApp;;
if (remoteApp) { if (remoteApp) {
// 先关闭旧的 WebSocket防止竞态条件
if (remoteApp.ws) {
remoteApp.ws.close();
}
remoteApp.init(); remoteApp.init();
this.attemptedConnectTimes += 1; this.attemptedConnectTimes += 1;
const isConnect = await remoteApp.isConnect(); const isConnect = await remoteApp.isConnect();
@@ -120,9 +204,10 @@ export class AssistantApp extends Manager {
this.attemptedConnectTimes = 0; this.attemptedConnectTimes = 0;
console.log('重新连接到了远程应用服务器'); console.log('重新连接到了远程应用服务器');
} else { } else {
this.reconnectRemoteApp();
setTimeout(() => { setTimeout(() => {
this.reconnectRemoteApp(); this.initRouterApp()
}, 30 * 1000); // 30秒后重连 }, 30 * 1000 + this.attemptedConnectTimes * 10 * 1000); // 30秒后重连 + 每次增加10秒
} }
} }
} }

View File

@@ -5,49 +5,39 @@ import path from 'node:path';
import { ProxyInfo } from './proxy.ts'; import { ProxyInfo } from './proxy.ts';
import { checkFileExists } from '../file/index.ts'; import { checkFileExists } from '../file/index.ts';
import { log } from '@/module/logger.ts'; import { log } from '@/module/logger.ts';
import { pipeFileStream } from './pipe.ts';
import { getContentType } from './module/mime.ts';
export const fileProxy = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => { export const fileProxy2 = (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
// url开头的文件 res.statusCode = 501;
const url = new URL(req.url, 'http://localhost'); const url = new URL(req.url, 'http://localhost');
const [user, key, _info] = url.pathname.split('/'); const { rootPath, indexPath = '' } = proxyApi?.file || {}
const pathname = url.pathname.slice(1); if (!rootPath) {
const { indexPath = '', target = '', rootPath = process.cwd() } = proxyApi; res.end(`系统未配置根路径 rootPath id:[${proxyApi?.file?.id}]`);
if (!indexPath) {
return res.end('Not Found indexPath');
}
try {
// 检测文件是否存在如果文件不存在则返回404
let filePath = '';
let exist = false;
if (_info) {
filePath = path.join(rootPath, target, pathname);
exist = checkFileExists(filePath, true);
}
if (!exist) {
filePath = path.join(rootPath, target, indexPath);
exist = checkFileExists(filePath, true);
}
log.debug('filePath', { filePath, exist });
if (!exist) {
res.statusCode = 404;
res.end('Not Found File');
return;
}
const ext = path.extname(filePath);
let maxAge = 24 * 60 * 60 * 1000; // 24小时
if (ext === '.html') {
maxAge = 0;
}
let sendFilePath = path.relative(rootPath, filePath);
const file = send(req, sendFilePath, {
root: rootPath,
maxAge,
});
file.pipe(res);
} catch (error) {
res.statusCode = 404;
res.end('Error:Not Found File');
return; return;
} }
const pathname = url.pathname;
let targetFilepath = pathname.replace(proxyApi.path || '', '');
if (targetFilepath.endsWith('/')) {
// 没有指定文件访问index.html
targetFilepath += 'index.html';
}
const filePath = path.join(rootPath || process.cwd(), targetFilepath);
const indexTargetPath = path.join(rootPath || process.cwd(), indexPath);
let sendPath = filePath;
if (!checkFileExists(filePath)) {
res.setHeader('X-Proxy-File', 'false');
if (indexPath && checkFileExists(indexTargetPath)) {
sendPath = indexTargetPath;
} else {
res.statusCode = 404;
res.end(`文件不存在, 路径: ${filePath}`);
return;
}
} else {
res.setHeader('X-Proxy-File', 'true');
}
const contentType = getContentType(sendPath);
res.setHeader('Content-Type', contentType);
pipeFileStream(sendPath, res);
}; };

View File

@@ -23,7 +23,7 @@ export const defaultApiProxy = [
* @param paths ['/api/router', '/v1' ] * @param paths ['/api/router', '/v1' ]
* @returns * @returns
*/ */
export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']) => { export const createApiProxy = (api: string, paths: string[] = ['/api', '/v1']): ProxyInfo[] => {
const pathList = paths.map((item) => { const pathList = paths.map((item) => {
return { return {
path: item, path: item,

View File

@@ -0,0 +1,50 @@
import path from 'path';
// 获取文件的 content-type
export const getContentType = (filePath: string) => {
const extname = path.extname(filePath);
const contentType = {
'.html': 'text/html; charset=utf-8',
'.js': 'text/javascript; charset=utf-8',
'.mjs': 'text/javascript; charset=utf-8',
'.css': 'text/css; charset=utf-8',
'.txt': 'text/plain; charset=utf-8',
'.json': 'application/json; charset=utf-8',
'.png': 'image/png',
'.jpg': 'image/jpg',
'.gif': 'image/gif',
'.svg': 'image/svg+xml',
'.wav': 'audio/wav',
'.mp4': 'video/mp4',
'.md': 'text/markdown; charset=utf-8', // utf-8配置
'.ico': 'image/x-icon', // Favicon 图标
'.webp': 'image/webp', // WebP 图像格式
'.webm': 'video/webm', // WebM 视频格式
'.ogg': 'audio/ogg', // Ogg 音频格式
'.mp3': 'audio/mpeg', // MP3 音频格式
'.m4a': 'audio/mp4', // M4A 音频格式
'.m3u8': 'application/vnd.apple.mpegurl', // HLS 播放列表
'.ts': 'video/mp2t', // MPEG Transport Stream
'.pdf': 'application/pdf', // PDF 文档
'.doc': 'application/msword', // Word 文档
'.docx': 'application/vnd.openxmlformats-officedocument.wordprocessingml.document', // Word 文档 (新版)
'.ppt': 'application/vnd.ms-powerpoint', // PowerPoint 演示文稿
'.pptx': 'application/vnd.openxmlformats-officedocument.presentationml.presentation', // PowerPoint (新版)
'.xls': 'application/vnd.ms-excel', // Excel 表格
'.xlsx': 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', // Excel 表格 (新版)
'.csv': 'text/csv; charset=utf-8', // CSV 文件
'.xml': 'application/xml; charset=utf-8', // XML 文件
'.rtf': 'application/rtf', // RTF 文本文件
'.eot': 'application/vnd.ms-fontobject', // Embedded OpenType 字体
'.ttf': 'font/ttf', // TrueType 字体
'.woff': 'font/woff', // Web Open Font Format 1.0
'.woff2': 'font/woff2', // Web Open Font Format 2.0
'.otf': 'font/otf', // OpenType 字体
'.wasm': 'application/wasm', // WebAssembly 文件
'.pem': 'application/x-pem-file', // PEM 证书文件
'.crt': 'application/x-x509-ca-cert', // CRT 证书文件
'.yaml': 'application/x-yaml; charset=utf-8', // YAML 文件
'.yml': 'application/x-yaml; charset=utf-8', // YAML 文件(别名)
'.zip': 'application/octet-stream',
};
return contentType[extname] || 'application/octet-stream';
};

View File

@@ -2,7 +2,7 @@ import * as http from 'http';
import * as fs from 'fs'; import * as fs from 'fs';
import { isBun } from './utils.ts'; import { isBun } from './utils.ts';
import { Readable } from 'stream'; import { Readable } from 'stream';
import { logger } from '@/module/logger.ts';
/** /**
* 文件流管道传输函数 * 文件流管道传输函数
* 将指定文件的内容通过流的方式传输给客户端响应 * 将指定文件的内容通过流的方式传输给客户端响应
@@ -100,7 +100,7 @@ export const pipeProxyReq = async (req: http.IncomingMessage, proxyReq: http.Cli
proxyReq.end(); proxyReq.end();
return; return;
} }
console.log('Bun pipeProxyReq content-type', contentType); logger.debug('Bun pipeProxyReq content-type', contentType);
// @ts-ignore // @ts-ignore
const bodyString = req.body; const bodyString = req.body;
bodyString && proxyReq.write(bodyString); bodyString && proxyReq.write(bodyString);
@@ -113,3 +113,16 @@ export const pipeProxyReq = async (req: http.IncomingMessage, proxyReq: http.Cli
req.pipe(proxyReq, { end: true }); req.pipe(proxyReq, { end: true });
} }
} }
export const pipeBusboy = async (req: http.IncomingMessage, res: http.ServerResponse, busboy: any) => {
if (isBun) {
// @ts-ignore
const bunRequest = req.bun.request;
const arrayBuffer = await bunRequest.arrayBuffer();
const buffer = Buffer.from(arrayBuffer);
busboy.end(buffer);
} else {
req.pipe(busboy);
}
}

View File

@@ -1,6 +1,7 @@
import http from 'node:http'; import http from 'node:http';
import { httpProxy } from './http-proxy.ts'; import { httpProxy } from './http-proxy.ts';
import { s3Proxy } from './s3.ts'; import { s3Proxy } from './s3.ts';
import { fileProxy2 } from './file-proxy.ts';
export type ProxyInfo = { export type ProxyInfo = {
/** /**
* 代理路径, 比如/root/home, 匹配的路径 * 代理路径, 比如/root/home, 匹配的路径
@@ -13,7 +14,7 @@ export type ProxyInfo = {
/** /**
* 类型 * 类型
*/ */
type?: 'file' | 'dynamic' | 'minio' | 'http' | 's3'; type?: 'file' | 'dynamic' | 'minio' | 'http' | 's3' | 'router' | 'lightcode';
/** /**
* 目标的 pathname 默认为请求的url.pathname, 设置了pathname则会使用pathname作为请求的url.pathname * 目标的 pathname 默认为请求的url.pathname, 设置了pathname则会使用pathname作为请求的url.pathname
* @default undefined * @default undefined
@@ -25,22 +26,32 @@ export type ProxyInfo = {
* @default false * @default false
*/ */
ws?: boolean; ws?: boolean;
/**
* type为file时有效
* 索引文件比如index.html type为fileProxy代理有用 设置了索引文件,如果文件不存在,则访问索引文件
*/
indexPath?: string;
/**
* type为file时有效
* 根路径, 默认是process.cwd(), type为fileProxy代理有用必须为绝对路径
*/
rootPath?: string;
s3?: { s3?: {
bucket: string; /**
region: string; * 如何id存在使用assistan-config的storage配置
accessKeyId: string; */
secretAccessKey: string; id?: string;
bucket?: string;
region?: string;
accessKeyId?: string;
secretAccessKey?: string;
endpoint?: string; endpoint?: string;
},
file?: {
id?: string;
indexPath?: string;
rootPath?: string;
},
router?: {
id?: string;
url?: string;
},
lightcode?: {
id?: string;
/**
* 是否检测远程服务更新
*/
check?: boolean;
} }
}; };
@@ -52,4 +63,7 @@ export const proxy = (req: http.IncomingMessage, res: http.ServerResponse, proxy
if (proxyApi.type === 's3') { if (proxyApi.type === 's3') {
return s3Proxy(req, res, proxyApi); return s3Proxy(req, res, proxyApi);
} }
if (proxyApi.type === 'file') {
return fileProxy2(req, res, proxyApi);
}
} }

View File

@@ -39,6 +39,14 @@ export const s3Proxy = async (req: http.IncomingMessage, res: http.ServerRespons
if (objectPath.startsWith(s3.bucket + '/')) { if (objectPath.startsWith(s3.bucket + '/')) {
objectPath = objectPath.replace(s3.bucket + '/', ''); objectPath = objectPath.replace(s3.bucket + '/', '');
} }
if (objectPath.endsWith('/')) {
// 获取目录列表
res.statusCode = 200;
res.setHeader('Content-Type', 'application/json; charset=utf-8');
const list = await oss.listObjects(objectPath);
res.end(JSON.stringify(list, null, 2));
return;
}
oss.getObject(objectPath).then((response) => { oss.getObject(objectPath).then((response) => {
if (!response.Body) { if (!response.Body) {
res.statusCode = 404; res.statusCode = 404;

View File

@@ -0,0 +1,16 @@
import crypto from 'node:crypto';
import fs from 'node:fs';
export const getHash = (file: string) => {
if (!fs.existsSync(file)) return '';
const buffer = fs.readFileSync(file); // 不指定编码,返回 Buffer
return crypto.createHash('md5').update(buffer).digest('hex');
};
export const getBufferHash = (buffer: Buffer) => {
return crypto.createHash('md5').update(buffer).digest('hex');
};
export const getStringHash = (str: string) => {
return crypto.createHash('md5').update(str).digest('hex');
}

View File

@@ -0,0 +1,22 @@
import http from 'http';
import * as cookie from '@kevisual/router/src/server/cookie.ts';
export const getTokenFromRequest = (req: http.IncomingMessage) => {
let token = (req.headers?.['authorization'] as string) || (req.headers?.['Authorization'] as string) || '';
const url = new URL(req.url || '', 'http://localhost');
if (!token) {
token = url.searchParams.get('token') || '';
}
if (!token) {
const parsedCookies = cookie.parse(req.headers.cookie || '');
token = parsedCookies.token || '';
}
if (token) {
token = token.replace('Bearer ', '');
}
return token;
}
export const getTokenFromContext = (ctx: any) => {
return ctx.query.token;
}

View File

@@ -1,3 +1,4 @@
import { useKey } from '@kevisual/use-config';
import http from 'node:http'; import http from 'node:http';
export const error = (msg: string, code = 500) => { export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg }); return JSON.stringify({ code, message: msg });
@@ -32,3 +33,7 @@ export const getToken = async (req: http.IncomingMessage) => {
return { token }; return { token };
}; };
export const getEnvToken = () => {
const envTokne = useKey('KEVISUAL_TOKEN') || '';
return envTokne;
}

View File

@@ -0,0 +1,177 @@
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 glob from 'fast-glob';
import { runCode } from './run.ts';
const codeDemoId = '0e700dc8-90dd-41b7-91dd-336ea51de3d2'
import { filter } from "@kevisual/js-filter";
import { getHash, getStringHash } from '../file-hash.ts';
import { AssistantConfig } from '@/lib.ts';
const codeDemo = `// 这是一个示例代码文件
import {App} from '@kevisual/router';
const app = new App();
app.route({
path: 'hello',
description: 'LightCode 示例路由',
metadata: {
tags: ['light-code', 'example'],
},
}).define(async (ctx) => {
console.log('tokenUser:', ctx.query?.tokenUser);
ctx.body = 'Hello from LightCode!';
}).addTo(app);
app.wait();
`;
const writeCodeDemo = async (appDir: string) => {
const lightcodeDir = path.join(appDir, 'light-code', 'code');
const demoPath = path.join(lightcodeDir, `${codeDemoId}.ts`);
fs.writeFileSync(demoPath, codeDemo, 'utf-8');
}
// writeCodeDemo(path.join(os.homedir(), 'kevisual', 'assistant-app', 'apps'));
type opts = {
router: QueryRouterServer | App
config: AssistantConfig | AssistantInit
sync?: boolean
}
type LightCodeFile = {
id?: string, code?: string, hash?: string, filepath: string
}
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;
if (!config || !app) {
console.error('initLightCode 缺少必要参数, config 或 app');
return;
}
const appDir = config.configPath.appsDir;
const lightcodeDir = path.join(appDir, 'light-code', 'code');
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))
}
});
if (sync) {
const queryRes = await query.post({
path: 'light-code',
key: 'list',
token,
});
if (queryRes.code === 200) {
const lightQueryList = queryRes.data?.list || [];
for (const item of lightQueryList) {
const codeHash = getStringHash(item.code || '');
diffList.push({ id: item.id!, code: item.code || '', hash: codeHash, filepath: path.join(lightcodeDir, `${item.id}.ts`) });
}
const codeFileSet = new Set(codeFiles.map(f => f.filepath));
// 需要新增的文件 (在 diffList 中但不在 codeFiles 中)
const toAdd = diffList.filter(d => !codeFileSet.has(d.filepath));
// 需要删除的文件 (在 codeFiles 中但不在 diffList 中)
const toDelete = codeFiles.filter(f => !diffList.some(d => d.filepath === f.filepath));
// 需要更新的文件 (两边都有但 hash 不同)
const toUpdate = diffList.filter(d => codeFileSet.has(d.filepath) && d.hash !== getHash(d.filepath));
const unchanged = diffList.filter(d => codeFileSet.has(d.filepath) && d.hash === getHash(d.filepath));
// 执行新增
for (const item of toAdd) {
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');
// console.log(`更新 light-code 文件: ${item.filepath}`);
}
// 记录未更新的文件
// const lightCodeList = [...toAdd, ...unchanged].map(d => ({
// filepath: d.filepath,
// hash: d.hash
// }));
} else {
console.error('light-code 同步失败', queryRes.message);
diffList = codeFiles;
}
} else {
diffList = codeFiles;
}
for (const file of diffList) {
const tsPath = file.filepath;
const runRes = await runCode(tsPath, { path: 'router', key: 'list' }, { timeout: 10000 });
if (runRes.success) {
const res = runRes.data;
if (res.code === 200) {
const list = res.data?.list || [];
for (const routerItem of list) {
if (routerItem.path?.includes('auth') || routerItem.path?.includes('router') || routerItem.path?.includes('call')) {
continue;
}
// console.log(`注册 light-code 路由: [${routerItem.path}] ${routerItem.id} 来自文件: ${file.filepath}`);
const metadata = routerItem.metadata || {};
if (metadata.tags && Array.isArray(metadata.tags)) {
metadata.tags.push('light-code');
} else {
metadata.tags = ['light-code'];
}
app.route({
id: routerItem.id,
path: `${routerItem.id}__${routerItem.path}`,
key: routerItem.key,
description: routerItem.description || '',
metadata,
middleware: ['auth'],
}).define(async (ctx) => {
const tokenUser = ctx.state?.tokenUser || {};
const query = { ...ctx.query, tokenUser }
const runRes2 = await runCode(tsPath, query, { timeout: 30000 });
if (runRes2.success) {
const res2 = runRes2.data;
if (res2.code === 200) {
ctx.body = res2.data;
} else {
ctx.throw(res2.code, res2.message || 'Lightcode 路由执行失败');
}
} else {
ctx.throw(runRes2.error || 'Lightcode 路由执行失败');
}
}).addTo(app);
}
}
} else {
console.error('light-code 路由执行失败', runRes.error);
}
}
console.log(`light-code 路由注册成功`, `注册${diffList.length}个路由`);
}

View File

@@ -32,7 +32,7 @@ type RunCode = {
}; };
error?: any error?: any
timestamp?: string timestamp?: string
[key: string]: any 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) => {
@@ -43,7 +43,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
}) })
return return
} }
let output = ''
const timeoutMs = opts?.timeout || 30000; // 默认30秒超时 const timeoutMs = opts?.timeout || 30000; // 默认30秒超时
let child let child
@@ -70,6 +70,7 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
if (!resolved) { if (!resolved) {
resolved = true resolved = true
cleanup() cleanup()
result.output = output
resolve(result) resolve(result)
} }
} }
@@ -77,7 +78,11 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
try { try {
// 使用 Bun 的 fork 模式启动子进程 // 使用 Bun 的 fork 模式启动子进程
child = fork(tsPath, [], { child = fork(tsPath, [], {
silent: true // 启用 stdio 重定向 silent: true, // 启用 stdio 重定向
env: {
...process.env,
BUN_CHILD_PROCESS: 'true' // 标记为子进程
}
}) })
// 监听来自子进程的消息 // 监听来自子进程的消息
child.on('message', (msg: RunCode) => { child.on('message', (msg: RunCode) => {
@@ -94,6 +99,11 @@ export const runCode = async (tsPath: string, params: RunCodeParams = {}, opts?:
}) })
}) })
} }
if (child.stdout) {
child.stdout.on('data', (data) => {
output += data.toString()
})
}
// 监听子进程退出事件 // 监听子进程退出事件
child.on('exit', (code, signal) => { child.on('exit', (code, signal) => {

View File

@@ -1,7 +1,7 @@
import type { App } from '@kevisual/router'; import type { App } from '@kevisual/router';
import { loadAppInfo, AppInfoConfig, saveAppInfo, getAppsPath } from './app-file.ts'; import { loadAppInfo, AppInfoConfig, saveAppInfo, getAppsPath } from './app-file.ts';
import { fork } from 'node:child_process'; import { fork } from 'node:child_process';
import { merge } from 'lodash-es'; import { merge } from 'es-toolkit';
import { deleteFileAppInfo } from './app-file.ts'; import { deleteFileAppInfo } from './app-file.ts';
import { fileIsExist } from '@kevisual/use-config/env'; import { fileIsExist } from '@kevisual/use-config/env';
import path from 'node:path'; import path from 'node:path';

View File

@@ -17,8 +17,11 @@ type ProxyType = {
user: string; user: string;
key: string; key: string;
path: string; path: string;
indexPath: string; type?: 'file';
absolutePath?: string; file: {
indexPath: string;
absolutePath: string;
};
}; };
export type LocalProxyOpts = { export type LocalProxyOpts = {
watch?: boolean; // 是否监听文件变化 watch?: boolean; // 是否监听文件变化
@@ -79,8 +82,10 @@ export class LocalProxy {
user: user, user: user,
key: app, key: app,
path: `/${user}/${app}/`, path: `/${user}/${app}/`,
indexPath: `${user}/${app}/index.html`, file: {
absolutePath: appPath, indexPath: `${user}/${app}/index.html`,
absolutePath: appPath,
}
}); });
} }
}); });

View File

@@ -64,6 +64,10 @@ export class RemoteApp {
throw new Error('No id provided for remote app'); throw new Error('No id provided for remote app');
} }
this.isError = false; this.isError = false;
// 关闭已有连接
if (this.ws) {
this.ws.close();
}
const ws = new WebSocket(this.getWsURL(this.url)); const ws = new WebSocket(this.getWsURL(this.url));
const that = this; const that = this;
ws.onopen = function () { ws.onopen = function () {

View File

@@ -0,0 +1,34 @@
import type { AssistantConfig } from '@/module/assistant/index.ts';
import fs from 'node:fs';
import path from 'node:path';
export class UploadManager {
config: AssistantConfig;
constructor(config: AssistantConfig) {
this.config = config;
}
mvFile(opts: {
temppath: string;
type: 'file' | 's3' | 'app-file';
targetPath: string;
}) {
const { type = 'file', temppath, targetPath } = opts;
const pageDir = this.config.configPath?.pagesDir!;
const appsDir = this.config.configPath?.appsDir!;
let dir = type === 'app-file' ? appsDir : pageDir;
if (type === 'file' || type === 'app-file') {
const fullTargetPath = targetPath.startsWith('/')
? targetPath
: path.join(dir, targetPath);
const targetDir = fullTargetPath.substring(0, fullTargetPath.lastIndexOf('/'));
if (!fs.existsSync(targetDir)) {
fs.mkdirSync(targetDir, { recursive: true });
}
fs.renameSync(temppath, fullTargetPath);
return fullTargetPath;
}
}
}
export const uploadManager = (config: AssistantConfig) => {
return new UploadManager(config);
}

View File

@@ -0,0 +1,32 @@
import { simpleRouter, clients, getTaskId, writeEvents, deleteOldClients, error } from './router.ts';
simpleRouter.get('/client/events', async (req, res) => {
res.writeHead(200, {
'Content-Type': 'text/event-stream',
'Cache-Control': 'no-cache',
Connection: 'keep-alive',
});
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
// 将客户端连接推送到 clients 数组
clients.set(taskId, { client: res, createTime: Date.now() });
writeEvents(req, { progress: 0, message: 'start' })
// 移除客户端连接
req.on('close', () => {
clients.delete(taskId);
});
});
simpleRouter.get('/client/events/close', async (req, res) => {
const taskId = getTaskId(req);
if (!taskId) {
res.end(error('task-id is required'));
return;
}
deleteOldClients();
clients.delete(taskId);
res.end('ok');
});

View File

@@ -0,0 +1 @@
export * from './upload.ts'

View File

@@ -0,0 +1,95 @@
import { simpleRouter } from '@/app.ts';
import http from 'http';
import { useContextKey } from '@kevisual/context';
export { simpleRouter };
import os from 'node:os';
import path from 'node:path';
import fs from 'node:fs';
/**
* 缓存文件路径
*/
const defaultCachePath = path.join(os.homedir(), '.envision', 'cache-file');
if (!fs.existsSync(defaultCachePath)) {
fs.mkdirSync(defaultCachePath, { recursive: true });
}
export const cacheFilePath = defaultCachePath;
/**
* 事件客户端
*/
const eventClientsInit = () => {
const clients = new Map<string, { client?: http.ServerResponse; createTime?: number;[key: string]: any }>();
return clients;
};
export const clients = useContextKey('event-clients', () => eventClientsInit());
/**
* 获取 task-id
* @param req
* @returns
*/
export const getTaskId = (req: http.IncomingMessage) => {
const url = new URL(req.url || '', 'http://localhost');
const taskId = url.searchParams.get('taskId');
if (taskId) {
return taskId;
}
return req.headers['task-id'] as string;
};
type EventData = {
progress: number | string;
message: string;
};
/**
* 写入事件
* @param req
* @param data
*/
export const writeEvents = (req: http.IncomingMessage, data: EventData) => {
const taskId = getTaskId(req);
if (taskId) {
const client = clients.get(taskId)?.client;
if (client) {
client.write(`data: ${JSON.stringify(data)}\n\n`);
}
if (Number(data.progress) === 100) {
clients.delete(taskId);
}
} else {
console.log('taskId is remove.', taskId);
}
};
/**
* 查找超出2个小时的clients都删除了
*/
export const deleteOldClients = () => {
const now = Date.now();
for (const [taskId, client] of clients) {
// 如果创建时间超过2个小时则删除
if (now - client.createTime > 1000 * 60 * 60 * 2) {
clients.delete(taskId);
}
}
};
/**
* 解析表单数据, 如果表单数据是数组, 则取第一个appKey, version, username 等
* @param fields 表单数据
* @param parseKeys 需要解析的键
* @returns 解析后的数据
*/
export const getKey = (fields: Record<string, any>, parseKeys: string[]) => {
let value: Record<string, any> = {};
for (const key of parseKeys) {
const v = fields[key];
if (Array.isArray(v)) {
value[key] = v[0];
} else {
value[key] = v;
}
}
return value;
};
export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg });
};

View File

@@ -0,0 +1,179 @@
import Busboy from 'busboy';
import { assistantConfig, simpleRouter } from '../app.ts'
import http from 'http';
import path from 'path';
import fs from 'fs';
import { checkAuth } from '@/routes/index.ts';
import { getTokenFromRequest } from '@/module/get-header-token.ts';
import { pipeBusboy } from '@/module/assistant/proxy/pipe.ts';
import { logger } from '@/module/logger.ts';
import { cacheFilePath, getKey, writeEvents } from './router.ts';
import { getContentType } from '@kevisual/oss/services';
import { validateDirectory } from './utils.ts';
import { UploadManager } from '@/module/upload/mv.ts';
simpleRouter.get('/client/upload', async (req, res) => {
if (res.headersSent) return; // 如果响应已发送,不再处理
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(JSON.stringify({ message: 'Upload endpoint reached' }));
})
export const error = (msg: string, code = 500) => {
return JSON.stringify({ code, message: msg });
};
export const parseIfJson = (data = '{}') => {
try {
const _data = JSON.parse(data);
if (typeof _data === 'object') return _data;
return {};
} catch (error) {
return {};
}
};
const uploadManager = new UploadManager(assistantConfig)
export const uploadResources = async (req: http.IncomingMessage, res: http.ServerResponse) => {
// const { tokenUser, token } = await checkAuth(req, res);
const token = getTokenFromRequest(req);
let tokenUser: any = null;
const authResult = await checkAuth({ query: { token } });
if (authResult.code === 200) {
tokenUser = authResult.data?.tokenUser;
} else {
res.end(error(authResult.message, authResult.code));
return;
}
if (!tokenUser) {
res.end(error('Token is invalid.'));
return;
}
const url = new URL(req.url || '', 'http://localhost');
const share = !!url.searchParams.get('public');
const meta = parseIfJson(url.searchParams.get('meta'));
// 使用 busboy 解析 multipart/form-data
const busboy = Busboy({ headers: req.headers, preservePath: true });
const fields: any = {};
const files: any[] = [];
const filePromises: Promise<void>[] = [];
let bytesReceived = 0;
let bytesExpected = parseInt(req.headers['content-length'] || '0');
busboy.on('field', (fieldname, value) => {
fields[fieldname] = value;
});
busboy.on('file', (fieldname, fileStream, info) => {
const { filename, encoding, mimeType } = info;
const tempPath = path.join(cacheFilePath, `${Date.now()}-${Math.random().toString(36).substring(7)}`);
const writeStream = fs.createWriteStream(tempPath);
const filePromise = new Promise<void>((resolve, reject) => {
fileStream.on('data', (chunk) => {
bytesReceived += chunk.length;
if (bytesExpected > 0) {
const progress = (bytesReceived / bytesExpected) * 100;
const data = {
progress: progress.toFixed(2),
message: `Upload progress: ${progress.toFixed(2)}%`,
};
console.log('progress-upload', JSON.stringify(data, null, 2));
writeEvents(req, data);
}
});
fileStream.pipe(writeStream);
writeStream.on('finish', () => {
files.push({
filepath: tempPath,
originalFilename: filename,
mimetype: mimeType,
});
resolve();
});
writeStream.on('error', (err) => {
reject(err);
});
});
filePromises.push(filePromise);
});
busboy.on('finish', async () => {
// 等待所有文件写入完成
try {
await Promise.all(filePromises);
} catch (err) {
logger.error(`File write error: ${err.message}`);
res.end(error(`File write error: ${err.message}`));
return;
}
const clearFiles = () => {
files.forEach((file) => {
if (file?.filepath && fs.existsSync(file.filepath)) {
fs.unlinkSync(file.filepath);
}
});
};
// 检查是否有文件上传
if (files.length === 0) {
res.end(error('files is required'));
return;
}
console.log('fields', fields);
let { appKey, version, username, directory } = getKey(fields, ['appKey', 'version', 'username', 'directory']);
if (!appKey || !version) {
res.end(error('appKey or version is not found, please check the upload config.'));
clearFiles();
return;
}
const { code, message } = validateDirectory(directory);
if (code !== 200) {
res.end(error(message));
clearFiles();
return;
}
// 逐个处理每个上传的文件
const uploadedFiles = files;
const uploadResults = [];
for (let i = 0; i < uploadedFiles.length; i++) {
const file = uploadedFiles[i];
// @ts-ignore
const tempPath = file.filepath; // 文件上传时的临时路径
const relativePath = file.originalFilename; // 保留表单中上传的文件名 (包含文件夹结构)
// 比如 child2/b.txt
const showVersion = false;
const _version = showVersion ? `${version ? '/' + version : ''}` : '';
const _directory = directory ? `/${directory}` : '';
const minioPath = `${username || tokenUser.username || 'unknown'}/${appKey}${_version}${_directory}/${relativePath}`;
uploadResults.push({
name: relativePath,
path: minioPath,
});
const type = 'file';
uploadManager.mvFile({
type: 'file',
temppath: tempPath,
targetPath: minioPath,
})
if (type !== 'file') {
fs.unlinkSync(tempPath); // 删除临时文件
}
}
res.writeHead(200, { 'Content-Type': 'application/json' });
res.end(
JSON.stringify({
code: 200,
data: {
detect: [],
upload: uploadResults,
},
}),
);
});
pipeBusboy(req, res, busboy);
}
simpleRouter.post('/client/upload', uploadResources);

View File

@@ -0,0 +1,30 @@
/**
* 校验directory是否合法, 合法返回200, 不合法返回500
*
* directory 不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
* 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
* @param directory 目录
* @returns
*/
export const validateDirectory = (directory?: string) => {
// 对directory进行校验不能以/开头,不能以/结尾。不能以.开头,不能以.结尾。
if (directory && (directory.startsWith('/') || directory.endsWith('/') || directory.startsWith('..') || directory.endsWith('..'))) {
return {
code: 500,
message: 'directory is invalid',
};
}
// 把directory的/替换掉后,只能包含数字、字母、下划线、中划线
// 可以包含.
let _directory = directory?.replace(/\//g, '');
if (_directory && !/^[a-zA-Z0-9_.-]+$/.test(_directory)) {
return {
code: 500,
message: 'directory is invalid, only number, letter, underline and hyphen are allowed',
};
}
return {
code: 200,
message: 'directory is valid',
};
};

View File

@@ -0,0 +1,32 @@
import { app } from '../../app.ts';
import { createSkill, tool } from '@kevisual/router';
app.route({
path: 'call',
key: '',
description: '调用',
middleware: ['auth'],
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'call-app',
title: '调用app应用',
summary: '调用router的应用, 参数path, key, payload',
args: {
path: tool.schema.string().describe('应用路径,例如 cnb'),
key: tool.schema.string().optional().describe('应用key例如 list-repos'),
payload: tool.schema.object({}).optional().describe('调用参数'),
}
})
},
}).define(async (ctx) => {
const { path, key = '' } = ctx.query;
if (!path) {
ctx.throw('路径path不能为空');
}
const res = await ctx.run({ path, key, payload: ctx.query.payload || {} }, {
...ctx
});
ctx.forward(res);
}).addTo(app)

View File

@@ -1,2 +1,37 @@
import { LightHA } from "@kevisual/ha-api"; import { LightHA } from "@kevisual/ha-api";
export const lightHA = new LightHA({ token: process.env.HAAS_TOKEN || '', homeassistantURL: process.env.HAAS_URL }); 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

@@ -5,12 +5,17 @@ import './ai/index.ts';
// TODO: // TODO:
// import './light-code/index.ts'; // import './light-code/index.ts';
import './user/index.ts'; import './user/index.ts';
import './call/index.ts'
// TODO: 移除 // TODO: 移除
import './hot-api/key-sender/index.ts'; // import './hot-api/key-sender/index.ts';
import './opencode/index.ts';
import './remote/index.ts';
import os from 'node:os'; 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';
const getTokenUser = async (token: string) => { const getTokenUser = async (token: string) => {
const query = assistantConfig.query const query = assistantConfig.query
const res = await query.post({ const res = await query.post({
@@ -34,30 +39,38 @@ export const getTokenUserCache = async (token: string) => {
} }
return res; return res;
} }
const checkAuth = async (ctx: any, isAdmin = false) => { export const checkAuth = async (ctx: any, isAdmin = false) => {
const config = assistantConfig.getConfig(); const config = assistantConfig.getConfig();
const { auth = {} } = config; const { auth = {} } = config;
const token = ctx.query.token; const token = ctx.query.token;
console.log('checkAuth', ctx.query, { token }); logger.debug('checkAuth', ctx.query, { token });
if (!token) { if (!token) {
return ctx.throw(401, 'not login'); return {
code: 401,
message: '未登录',
}
} }
// 鉴权代理 // 鉴权代理
let tokenUser = await authCache.get(token); let tokenUser = await authCache.get(token);
if (!tokenUser) { if (!tokenUser) {
const tokenUserRes = await getTokenUser(token); const tokenUserRes = await getTokenUser(token);
if (tokenUserRes.code !== 200) { if (tokenUserRes.code !== 200) {
return ctx.throw(tokenUserRes.code, 'not login'); return {
code: tokenUserRes.code,
message: '验证失败' + tokenUserRes.message,
}
} else { } else {
tokenUser = tokenUserRes.data; tokenUser = tokenUserRes.data;
} }
authCache.set(token, tokenUser); authCache.set(token, tokenUser);
} }
ctx.state = { if (ctx.state) {
...ctx.state, ctx.state = {
token, ...ctx.state,
tokenUser, token,
}; tokenUser,
};
}
const { username } = tokenUser; const { username } = tokenUser;
if (!auth.username) { if (!auth.username) {
// 初始管理员账号 // 初始管理员账号
@@ -75,9 +88,16 @@ const checkAuth = async (ctx: any, isAdmin = false) => {
isCheckAdmin = true; isCheckAdmin = true;
} }
if (!isCheckAdmin) { if (!isCheckAdmin) {
return ctx.throw(403, 'not admin user'); return {
code: 403,
message: '非管理员用户',
}
} }
} }
return {
code: 200,
data: { tokenUser, token }
}
}; };
app app
.route({ .route({
@@ -86,7 +106,13 @@ app
description: '获取当前登录用户信息, 第一个登录的用户为管理员用户', description: '获取当前登录用户信息, 第一个登录的用户为管理员用户',
}) })
.define(async (ctx) => { .define(async (ctx) => {
await checkAuth(ctx); if (!ctx.query?.token && ctx.appId === app.appId) {
return;
}
const authResult = await checkAuth(ctx);
if (authResult.code !== 200) {
ctx.throw(authResult.code, authResult.message);
}
}) })
.addTo(app); .addTo(app);
app app
@@ -96,8 +122,14 @@ app
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。', description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
}) })
.define(async (ctx) => { .define(async (ctx) => {
console.log('query', ctx.query); logger.debug('query', ctx.query);
await checkAuth(ctx, true); if (!ctx.query?.token && ctx.appId === app.appId) {
return;
}
const authResult = await checkAuth(ctx, true);
if (authResult.code !== 200) {
ctx.throw(authResult.code, authResult.message);
}
}) })
.addTo(app); .addTo(app);
@@ -131,6 +163,14 @@ app
path: 'client', path: 'client',
key: 'system', key: 'system',
description: '获取系统信息', description: '获取系统信息',
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'view-system-info',
title: '查看系统信息',
summary: '获取服务器操作系统平台、架构和版本信息',
})
}
}) })
.define(async (ctx) => { .define(async (ctx) => {
const { platform, arch, release } = os; const { platform, arch, release } = os;

View File

@@ -4,7 +4,8 @@ import { runCode } from '../../module/light-code/run.ts'
// http://localhost:4005/api/router?path=call // http://localhost:4005/api/router?path=call
app.route({ app.route({
path: 'call', path: 'light-code',
key: 'call',
// middleware: ['auth'] // middleware: ['auth']
}).define(async (ctx) => { }).define(async (ctx) => {
const filename = ctx.query?.filename || 'root/light-code-demo/demo-router.ts' const filename = ctx.query?.filename || 'root/light-code-demo/demo-router.ts'

View File

@@ -0,0 +1 @@
import './ls.ts'

View File

@@ -0,0 +1,127 @@
import { app } from '@/app.ts'
import { createSkill, tool } from "@kevisual/router";
import { opencodeManager } from './module/open.ts'
import path from "node:path";
import { execSync } from "node:child_process";
import { useKey } from '@kevisual/use-config';
// 创建一个opencode 客户端
app.route({
path: 'opencode',
key: 'create',
middleware: ['auth'],
description: '创建 OpenCode 客户端',
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'create-opencode-client',
title: '创建 OpenCode 客户端',
summary: '创建 OpenCode 客户端,如果存在则复用',
args: {
}
})
},
}).define(async (ctx) => {
const client = await opencodeManager.getClient();
ctx.body = { content: `${opencodeManager.url} OpenCode 客户端已就绪` };
}).addTo(app);
// 关闭 opencode 客户端
app.route({
path: 'opencode',
key: 'close',
middleware: ['auth'],
description: '关闭 OpenCode 客户端',
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'close-opencode-client',
title: '关闭 OpenCode 客户端',
summary: '关闭 OpenCode 客户端',
args: {
}
})
},
}).define(async (ctx) => {
await opencodeManager.close();
ctx.body = { content: 'OpenCode 客户端已关闭' };
}).addTo(app);
// 调用 path: opencode key: getUrl
app.route({
path: 'opencode',
key: 'getUrl',
middleware: ['auth'],
description: '获取 OpenCode 服务 URL',
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'get-opencode-url',
title: '获取 OpenCode 服务 URL',
summary: '获取当前 OpenCode 服务的 URL 地址',
args: {
}
})
},
}).define(async (ctx) => {
const url = await opencodeManager.getUrl();
const cnbURL = useKey('CNB_VSCODE_PROXY_URI') as string | undefined;
let content = `本地访问地址: ${url}`
if (cnbURL) {
content += `\n云端访问地址: ${cnbURL.replace('{{port}}', '5000')}`;
}
ctx.body = { content };
}).addTo(app);
// 调用 path: opencode key: ls-projects
app.route({
path: 'opencode',
key: 'ls-projects'
}).define(async (ctx) => {
const client = await opencodeManager.getClient();
const projects = await client.project.list();
const currentProject = await client.project.current();
ctx.body = {
projects,
currentProject
};
}).addTo(app);
// 调用 path: opencode key: runProject 参数 /home/ubuntu/cli/assistant
app.route({
path: 'opencode',
key: 'runProject',
middleware: ['auth'],
metadata: {
tags: ['opencode'],
...createSkill({
skill: 'run-opencode-project',
title: '运行 OpenCode 项目',
summary: '运行一个已有的 OpenCode 项目',
args: {
projectPath: tool.schema.string().optional().describe('OpenCode 项目的路径, 默认为 /workspace')
}
})
}
}).define(async (ctx) => {
const { projectPath = '/workspace' } = ctx.query;
try {
// const directory = path.resolve(projectPath);
// const runOpencodeCli = 'opencode run hello';
// execSync(runOpencodeCli, { cwd: directory, stdio: 'inherit' });
// ctx.body = { content: `OpenCode 项目已在路径 ${directory} 运行` };
const client = await opencodeManager.getClient();
const session = await client.session.create({
query: {
directory: projectPath
}
})
console.log('Created session:', session.data.id);
ctx.body = { content: `OpenCode 项目已在路径 ${projectPath} 运行` };
} catch (error) {
ctx.body = { content: `运行 OpenCode 项目失败, 请手动运行命令初始化: opencode run hello` };
}
}).addTo(app);

View File

@@ -0,0 +1,127 @@
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";
export class OpencodeManager {
private static instance: OpencodeManager | null = null;
private client: OpencodeClient | null = null;
private server: { url: string; close(): void } | null = null;
private isInitializing: boolean = false;
private currentPort: number | null = null;
public url: string = '';
private constructor() { }
static getInstance(): OpencodeManager {
if (!OpencodeManager.instance) {
OpencodeManager.instance = new OpencodeManager();
}
return OpencodeManager.instance;
}
async getClient(): Promise<OpencodeClient> {
// 如果已经有 client直接返回
if (this.client) {
return this.client;
}
// 如果正在初始化,等待初始化完成
if (this.isInitializing) {
await new Promise(resolve => setTimeout(resolve, 100));
return this.getClient();
}
// 开始初始化
this.isInitializing = true;
try {
const port = 5000;
const currentPort = await getPort({ port: port });
if (port === currentPort) {
const result = await createOpencode({
hostname: '0.0.0.0',
port: port
});
this.url = result.server.url;
this.client = result.client;
this.server = result.server;
return this.client;
} else {
this.client = await this.createOpencodeProject({ port });
this.url = `http://localhost:${port}`;
return this.client;
}
} finally {
this.isInitializing = false;
}
}
async createOpencodeProject({
directory,
port = 5000
}: { directory?: string, port?: number }): Promise<OpencodeClient> {
const client = createOpencodeClient({
baseUrl: `http://localhost:${port}`,
directory
});
return client;
}
async killPort(port: number): Promise<void> {
try {
// 尝试 使用命令行去关闭 port为5000的服务
if (os.platform() === 'win32') {
// Windows 平台
execSync(`netstat -ano | findstr :${port} | findstr LISTENING`).toString().split('\n').forEach(line => {
const parts = line.trim().split(/\s+/);
const pid = parts[parts.length - 1];
if (pid) {
execSync(`taskkill /PID ${pid} /F`);
console.log(`OpencodeManager: 已关闭占用端口 ${port} 的进程 PID ${pid}`);
}
});
} else {
// Unix-like 平台
const result = execSync(`lsof -i :${port} -t`).toString();
result.split('\n').forEach(pid => {
if (pid) {
execSync(`kill -9 ${pid}`);
console.log(`OpencodeManager: 已关闭占用端口 ${port} 的进程 PID ${pid}`);
}
});
}
} catch (error) {
console.error('Failed to close OpenCode server:', error);
}
}
async close(): Promise<void> {
if (this.server) {
this.server.close();
this.server = null;
return
}
const port = 5000;
const currentPort = await getPort({ port: port });
if (port === currentPort) {
this.client = null;
return;
} else {
await this.killPort(port);
}
this.client = null;
}
async getUrl(): Promise<string> {
if (this.url) {
return this.url;
}
if (!this.url) {
await this.getClient();
}
return 'http://localhost:5000';
}
}
export const opencodeManager = OpencodeManager.getInstance();

View File

@@ -0,0 +1,54 @@
import { useContextKey } from "@kevisual/context";
import { app } from "../../app.ts";
import { AssistantApp } from "@/lib.ts";
app.route({
path: 'remote',
key: 'status',
middleware: ['admin-auth'],
description: '获取远程app连接状态',
}).define(async (ctx) => {
const manager = useContextKey('manager') as AssistantApp;
if (manager?.remoteApp?.isConnect()) {
const url = manager.remoteUrl || ''
ctx.body = {
content: `远程app已经链接, 访问地址:${url}`,
}
} else {
ctx.body = {
content: '远程app未连接',
}
}
}).addTo(app);
app.route({
path: 'remote',
key: 'connect',
middleware: ['admin-auth'],
description: '连接远程app',
}).define(async (ctx) => {
const manager = useContextKey('manager') as AssistantApp;
if (!manager) {
ctx.body = {
content: '远程app管理器未初始化',
}
return;
}
if (manager?.remoteApp?.isConnect()) {
const url = manager.remoteUrl || ''
ctx.body = {
content: `远程app已经链接, 访问地址:${url}`,
}
return;
}
await manager.initRemoteApp({ enabled: true, token: ctx.query?.token }).then(() => {
ctx.body = {
content: '远程app连接成功',
}
}).catch((err) => {
ctx.body = {
content: `远程app连接失败: ${err.message}`,
}
});
}).addTo(app);

View File

@@ -1,13 +1,15 @@
import { useContextKey } from '@kevisual/context';
import { app, assistantConfig } from './app.ts'; import { app, assistantConfig } from './app.ts';
import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts'; import { proxyRoute, proxyWs } from './services/proxy/proxy-page-index.ts';
import './routes/index.ts'; import './routes/index.ts';
import './routes-simple/index.ts';
import getPort, { portNumbers } from 'get-port'; import getPort, { portNumbers } from 'get-port';
import { program } from 'commander'; import { program } from 'commander';
import { spawnSync } from 'node:child_process'; import { spawnSync } from 'node:child_process';
import path from 'node:path' import path from 'node:path'
import chalk from 'chalk'; import chalk from 'chalk';
import { AssistantApp } from './lib.ts'; import { AssistantApp, checkFileExists } from './lib.ts';
import { getBunPath } from './module/get-bun-path.ts'; import { getBunPath } from './module/get-bun-path.ts';
import { qwenAsr } from './services/asr/qwen-asr.ts'; import { qwenAsr } from './services/asr/qwen-asr.ts';
export const runServer = async (port: number = 51515, listenPath = '127.0.0.1') => { export const runServer = async (port: number = 51515, listenPath = '127.0.0.1') => {
@@ -35,12 +37,11 @@ export const runServer = async (port: number = 51515, listenPath = '127.0.0.1')
}); });
} else { } else {
app.listen(_port, listenPath, () => { app.listen(_port, listenPath, () => {
const protocol = assistantConfig.getHttps().protocol;
let showListenPath = listenPath; let showListenPath = listenPath;
if (listenPath === '::') { if (listenPath === '::') {
showListenPath = 'localhost'; showListenPath = 'localhost';
} }
console.log(`Server is running on ${protocol}://${showListenPath}:${_port}`); console.log(`Server is running on ${'http'}://${showListenPath}:${_port}`);
}); });
} }
app.server.on([{ app.server.on([{
@@ -50,12 +51,13 @@ export const runServer = async (port: number = 51515, listenPath = '127.0.0.1')
...proxyWs(), ...proxyWs(),
qwenAsr, qwenAsr,
]); ]);
const manager = new AssistantApp(assistantConfig, app); const manager = useContextKey('manager', new AssistantApp(assistantConfig, app));
setTimeout(() => { setTimeout(() => {
manager.load({ runtime: 'client' }).then(() => { manager.load({ runtime: 'client' }).then(() => {
console.log('Assistant App Loaded'); console.log('Assistant App Loaded');
}); });
manager.initRemoteApp() manager.initRemoteApp()
manager.initRouterApp()
}, 1000); }, 1000);
return { return {
@@ -69,6 +71,8 @@ program
.option('-n, --name <name>', '服务名称', 'assistant-server') .option('-n, --name <name>', '服务名称', 'assistant-server')
.option('-p, --port <port>', '服务端口') .option('-p, --port <port>', '服务端口')
.option('-s, --start', '是否启动服务') .option('-s, --start', '是否启动服务')
.option('-r, --root <root>', 'assistant app的根目录路径')
.option('-i, --input <input>', '启动的输入文件,例如/workspace/src/main.ts')
.option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun') .option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun')
.action(async (options) => { .action(async (options) => {
// console.log('当前执行路径:', execPath, inte); // console.log('当前执行路径:', execPath, inte);
@@ -99,7 +103,12 @@ program
if (port) { if (port) {
pm2Command += ` -p ${port}`; pm2Command += ` -p ${port}`;
} }
if (options.root) {
pm2Command += ` --root ${options.root}`;
}
if (options.input) {
pm2Command += ` --input ${options.input}`;
}
console.log(chalk.gray('执行命令:'), pm2Command); console.log(chalk.gray('执行命令:'), pm2Command);
console.log(chalk.gray('脚本路径:'), runPath); console.log(chalk.gray('脚本路径:'), runPath);
@@ -143,9 +152,15 @@ program
} else if (options.start) { } else if (options.start) {
console.log('启动服务', chalk.green(assistantConfig.configDir)); console.log('启动服务', chalk.green(assistantConfig.configDir));
const config = assistantConfig.getCacheAssistantConfig(); const config = assistantConfig.getCacheAssistantConfig();
const listenPort = options.port || config?.server?.port; const listenPort = parseInt(options.port || config?.server?.port);
const listenPath = config?.server?.path || '::'; const listenPath = config?.server?.path || '::';
const server = await runServer(listenPort, listenPath); const server = await runServer(listenPort, listenPath);
if (options.input) {
const _input = path.resolve(options.input);
if (checkFileExists(_input)) {
await import(_input);
}
}
} else { } else {
console.log('请使用 -s 参数启动服务'); console.log('请使用 -s 参数启动服务');
} }

View File

@@ -1,7 +1,7 @@
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 { lightHA } from "@/routes/ha-api/ha.ts"; 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) => {
@@ -61,37 +61,7 @@ const func: WebSocketListenerFun = async (req: WebSocketReq<{ asr: QwenAsrRelati
text, text,
})); }));
if (!text) return; if (!text) return;
const command = text?.trim().slice(0, 20); await callText(text);
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 = '关闭';
}
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);
}
}
console.log('toogle light time', Date.now() - endTime); console.log('toogle light time', Date.now() - endTime);
}); });
asr.start(); asr.start();

View File

@@ -2,13 +2,13 @@ import fs from 'node:fs';
import path from 'node:path'; import path from 'node:path';
import { checkFileExists, AssistantConfig, AssistantConfigData, parseHomeArg, parseHelpArg } from '@/module/assistant/index.ts'; import { checkFileExists, AssistantConfig, AssistantConfigData, parseHomeArg, parseHelpArg } from '@/module/assistant/index.ts';
import { chalk } from '@/module/chalk.ts'; import { chalk } from '@/module/chalk.ts';
import { HttpsPem } from '@/module/assistant/https/sign.ts';
import { Query } from '@kevisual/query/query'; import { Query } from '@kevisual/query/query';
import { installDeps } from '@/module/npm-install.ts' import { installDeps } from '@/module/npm-install.ts'
export { parseHomeArg, parseHelpArg }; export { parseHomeArg, parseHelpArg };
export type AssistantInitOptions = { export type AssistantInitOptions = {
path?: string; path?: string;
init?: boolean; init?: boolean;
initWorkspace?: boolean;
}; };
const randomId = () => Math.random().toString(36).substring(2, 8); const randomId = () => Math.random().toString(36).substring(2, 8);
/** /**
@@ -17,24 +17,31 @@ const randomId = () => Math.random().toString(36).substring(2, 8);
*/ */
export class AssistantInit extends AssistantConfig { export class AssistantInit extends AssistantConfig {
#query: Query #query: Query
initWorkspace: boolean = false;
constructor(opts?: AssistantInitOptions) { constructor(opts?: AssistantInitOptions) {
const configDir = opts?.path || process.cwd(); const configDir = opts?.path || process.cwd();
super({ super({
configDir, configDir,
init: false, init: false,
}); });
this.initWorkspace = opts?.initWorkspace ?? true;
if (opts?.init) { if (opts?.init) {
this.init(); this.init();
} }
} }
async init() { async init(configDir?: string) {
if (configDir) {
this.configDir = configDir;
}
// 1. 检查助手路径是否存在 // 1. 检查助手路径是否存在
if (!this.checkConfigPath()) { if (!this.checkConfigPath()) {
super.init(configDir);
if (!this.initWorkspace) { return }
console.log(chalk.blue('助手路径不存在,正在创建...')); console.log(chalk.blue('助手路径不存在,正在创建...'));
super.init();
} else { } else {
super.init(); super.init(configDir);
if (!this.initWorkspace) { return }
const assistantConfig = this; const assistantConfig = this;
console.log(chalk.yellow('助手路径已存在'), chalk.green(assistantConfig.configDir)); console.log(chalk.yellow('助手路径已存在'), chalk.green(assistantConfig.configDir));
} }
@@ -77,14 +84,6 @@ export class AssistantInit extends AssistantConfig {
fs.writeFileSync(appsConfig, JSON.stringify({ description: 'apps manager.', list: [] })); fs.writeFileSync(appsConfig, JSON.stringify({ description: 'apps manager.', list: [] }));
console.log(chalk.green('助手应用配置文件 apps.json 创建成功')); console.log(chalk.green('助手应用配置文件 apps.json 创建成功'));
} }
// create pem dir //
const pemDir = path.join(this.configPath?.configDir, 'pem');
const httpsPem = new HttpsPem(this);
if (httpsPem.isHttps) {
if (!checkFileExists(pemDir)) {
console.log(chalk.green('助手证书目录创建成功'));
}
}
} }
createAssistantConfig() { createAssistantConfig() {
const assistantPath = this.configPath?.configPath; const assistantPath = this.configPath?.configPath;
@@ -129,32 +128,38 @@ export class AssistantInit extends AssistantConfig {
"description": "assistant-app package pnpm, node pkgs projects", "description": "assistant-app package pnpm, node pkgs projects",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "pm2 start apps/root/code-center/app.mjs --name root/code-center", "start": "pm2 start apps/code-center/dist/app.mjs --name code-center",
"cnb": "ASSISTANT_CONFIG_DIR=/workspace asst server -s -p 7878" "proxy": "pm2 start apps/page-proxy/dist/app.mjs --name page-proxy",
"preview": "pnpm i && ASSISTANT_CONFIG_DIR=/workspace asst server -s -p 8686"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@kevisual/router": "latest", "@aws-sdk/client-s3": "^3.975.0",
"@kevisual/use-config": "latest", "@kevisual/oss": "^0.0.16",
"@kevisual/query": "latest", "@kevisual/query": "^0.0.38",
"ioredis": "latest", "eventemitter3": "^5.0.4",
"minio": "latest", "@kevisual/router": "^0.0.62",
"pg": "latest", "@kevisual/use-config": "^1.0.28",
"pm2": "latest", "ioredis": "^5.9.2",
"sequelize": "latest", "minio": "^8.0.6",
"crypto-js": "latest", "pg": "^8.17.2",
"better-sqlite3": "latest", "pm2": "^6.0.14",
"unstorage": "latest", "sequelize": "^6.37.7",
"dayjs": "latest", "crypto-js": "^4.2.0",
"es-toolkit": "latest", "better-sqlite3": "^12.6.2",
"node-cron": "latest", "unstorage": "^1.17.4",
"dotenv": "latest" "dayjs": "^1.11.19",
"es-toolkit": "^1.44.0",
"node-cron": "^4.2.1",
"dotenv": "^17.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "latest", "@kevisual/types": "^0.0.12",
"@types/crypto-js": "latest" "@types/bun": "^1.3.6",
"@types/crypto-js": "^4.2.2",
"@types/node": "^25.0.10"
} }
} }
`, `,
@@ -218,11 +223,4 @@ export class AssistantInit extends AssistantConfig {
}, },
} as AssistantConfigData; } as AssistantConfigData;
} }
getHttps() {
const https = this.getConfig()?.https || {};
return {
https,
protocol: https?.type === 'https' ? 'https' : 'http',
};
}
} }

View File

@@ -4,31 +4,37 @@
"description": "assistant-app package pnpm, node pkgs projects", "description": "assistant-app package pnpm, node pkgs projects",
"type": "module", "type": "module",
"scripts": { "scripts": {
"start": "pm2 start apps/root/code-center/app.mjs --name root/code-center", "start": "pm2 start apps/code-center/dist/app.mjs --name code-center",
"proxy": "pm2 start apps/root/page-proxy/app.mjs --name root/page-proxy" "proxy": "pm2 start apps/page-proxy/dist/app.mjs --name page-proxy",
"preview": "pnpm i && ASSISTANT_CONFIG_DIR=/workspace asst server -s -p 8686"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
"license": "ISC", "license": "ISC",
"dependencies": { "dependencies": {
"@kevisual/router": "latest", "@aws-sdk/client-s3": "^3.975.0",
"@kevisual/use-config": "latest", "@kevisual/oss": "^0.0.16",
"@kevisual/query": "latest", "@kevisual/query": "^0.0.38",
"ioredis": "latest", "eventemitter3": "^5.0.4",
"minio": "latest", "@kevisual/router": "^0.0.62",
"pg": "latest", "@kevisual/use-config": "^1.0.28",
"pm2": "latest", "ioredis": "^5.9.2",
"sequelize": "latest", "minio": "^8.0.6",
"crypto-js": "latest", "pg": "^8.17.2",
"better-sqlite3": "latest", "pm2": "^6.0.14",
"unstorage": "latest", "sequelize": "^6.37.7",
"dayjs": "latest", "crypto-js": "^4.2.0",
"es-toolkit": "latest", "better-sqlite3": "^12.6.2",
"node-cron": "latest", "unstorage": "^1.17.4",
"dotenv": "latest" "dayjs": "^1.11.19",
"es-toolkit": "^1.44.0",
"node-cron": "^4.2.1",
"dotenv": "^17.2.3"
}, },
"devDependencies": { "devDependencies": {
"@types/node": "latest", "@kevisual/types": "^0.0.12",
"@types/crypto-js": "latest" "@types/bun": "^1.3.6",
"@types/crypto-js": "^4.2.2",
"@types/node": "^25.0.10"
} }
} }

View File

@@ -1,7 +1,7 @@
import { fileProxy, httpProxy, 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, app } from '@/app.ts'; import { assistantConfig, simpleRouter } 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';
@@ -12,7 +12,7 @@ const localProxy = new LocalProxy({});
localProxy.initFromAssistantConfig(assistantConfig); localProxy.initFromAssistantConfig(assistantConfig);
const isOpenPath = (pathname: string): boolean => { const isOpenPath = (pathname: string): boolean => {
const openPaths = ['/root/home', '/root/cli']; const openPaths = ['/root/home', '/root/cli', '/root/login'];
for (const openPath of openPaths) { for (const openPath of openPaths) {
if (pathname.startsWith(openPath)) { if (pathname.startsWith(openPath)) {
return true; return true;
@@ -31,7 +31,7 @@ const authFilter = async (req: http.IncomingMessage, res: http.ServerResponse) =
const auth = _assistantConfig?.auth || {}; const auth = _assistantConfig?.auth || {};
const share = auth.share || 'protected'; const share = auth.share || 'protected';
const noAdmin = !auth.username; const noAdmin = !auth.username;
if (noAdmin) return false; if (noAdmin) return { code: 200, message: '没有管理员, 直接放过, 让管理登录和自己设置' };
const admin = auth.username; const admin = auth.username;
const admins = auth.admin || []; const admins = auth.admin || [];
if (admin) { if (admin) {
@@ -41,42 +41,42 @@ const authFilter = async (req: http.IncomingMessage, res: http.ServerResponse) =
const pathname = decodeURIComponent(url.pathname); const pathname = decodeURIComponent(url.pathname);
// 放开 / // 放开 /
if (pathname === '/' || pathname === '/favicon.ico') { if (pathname === '/' || pathname === '/favicon.ico') {
return false; return { code: 200, message: '允许访问根路径' };
} }
// 放开首页 // 放开首页
if (pathname.startsWith('/root/home') || pathname === '/root/cli') { if (pathname.startsWith('/root/home') || pathname === '/root/cli/docs/') {
return false; return { code: 200, message: '允许访问首页' };
} }
// 放开api 以 /api /v1, /client, /serve 开头的请求 // 放开api 以 /api /v1, /client, /serve 开头的请求
const openApiPaths = ['/api', '/v1', '/client', '/serve']; const openApiPaths = ['/api', '/v1', '/client', '/serve', '/proxy', '/root'];
for (const openPath of openApiPaths) { for (const openPath of openApiPaths) {
if (pathname.startsWith(openPath)) { if (pathname.startsWith(openPath)) {
return false; return { code: 200, message: '允许公共访问模块' };
} }
} }
if (share === 'public') { if (share === 'public') {
return false; return { code: 200, message: '公开模式允许访问' };
} }
const { token } = await getToken(req) const { token } = await getToken(req)
if (!token) { if (!token) {
// no token 转到登录页面 // no token 转到登录页面
res.writeHead(302, { Location: `/root/home/` }); res.writeHead(302, { Location: `/root/home/` });
res.end(); res.end();
return false; return { code: 500, message: '未登录' };
} }
const tokenUser = await getTokenUserCache(token); const tokenUser = await getTokenUserCache(token);
if (share === 'protected' && tokenUser?.code === 200) { if (share === 'protected' && tokenUser?.code === 200) {
return false; return { code: 200, message: '受保护模式已登录允许访问' };
} }
if (share === 'private') { if (share === 'private') {
if (tokenUser?.code === 200) { if (tokenUser?.code === 200) {
const username = tokenUser?.data?.username; const username = tokenUser?.data?.username;
if (admins.includes(username)) { if (admins.includes(username)) {
return false; return { code: 200, message: '私有模式管理员允许访问' };
} }
} }
} }
return true; return { code: 500, message: '没有权限访问' };
} }
export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => { export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResponse) => {
const _assistantConfig = assistantConfig.getCacheAssistantConfig(); const _assistantConfig = assistantConfig.getCacheAssistantConfig();
@@ -103,28 +103,54 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
res.end('Not Found Favicon'); res.end('Not Found Favicon');
return; return;
} }
if (pathname.startsWith('/client/upload')) {
simpleRouter.parse(req, res);
return;
}
if (pathname.startsWith('/client')) { if (pathname.startsWith('/client')) {
logger.debug('handle by router', { url: req.url }); logger.debug('handle by router', { url: req.url });
return; return;
} }
if (pathname.startsWith('/router') || pathname.startsWith('/opencode')) {
logger.debug('handle by router (opencode/router)', { url: req.url });
return;
}
// client, api, v1, serve 开头的拦截 // client, api, v1, serve 开头的拦截
const apiProxy = _assistantConfig?.api?.proxy || []; const apiProxy = _assistantConfig?.api?.proxy || [];
const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn'); const defaultApiProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn');
const allProxy = [...apiProxy, ...defaultApiProxy]; const allProxy = [...apiProxy, ...defaultApiProxy];
const apiBackendProxy = allProxy.find((item) => pathname.startsWith(item.path)); const apiBackendProxy = allProxy.find((item) => pathname.startsWith(item.path));
// console.log('apiBackendProxy', allProxy, apiBackendProxy, pathname, apiProxy[0].path); const proxyFn = async (req: http.IncomingMessage, res: http.ServerResponse, proxyApi: ProxyInfo) => {
if (apiBackendProxy) { log.debug('proxyApi', { proxyApi, url: req.url });
log.debug('apiBackendProxy', { apiBackendProxy, url: req.url });
// 设置 CORS 头 // 设置 CORS 头
// res.setHeader('Access-Control-Allow-Origin', '*'); // res.setHeader('Access-Control-Allow-Origin', '*');
// res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS'); // res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
// res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With'); // res.setHeader('Access-Control-Allow-Headers', 'Content-Type, Authorization, X-Requested-With');
if (proxyApi.s3?.id) {
const storage = _assistantConfig?.storage || []
const storageConfig = storage.find((item) => item.id === proxyApi.s3?.id);
proxyApi.s3 = {
...storageConfig,
...proxyApi.s3,
}
}
if (proxyApi.file?.id) {
const storage = _assistantConfig?.storage || []
const storageConfig = storage.find((item) => item.id === proxyApi.file?.id);
proxyApi.file = {
...storageConfig,
...proxyApi.file,
}
}
return proxy(req, res, { return proxy(req, res, {
path: apiBackendProxy.path, path: proxyApi.path,
target: apiBackendProxy.target, target: proxyApi.target,
...apiBackendProxy ...proxyApi,
}); });
} }
if (apiBackendProxy) {
return proxyFn(req, res, apiBackendProxy);
}
logger.debug('proxyRoute handle by router', { url: req.url }, noAdmin); logger.debug('proxyRoute handle by router', { url: req.url }, noAdmin);
const urls = pathname.split('/'); const urls = pathname.split('/');
const [_, _user, _app] = urls; const [_, _user, _app] = urls;
@@ -134,7 +160,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return; return;
} }
const isOpen = isOpenPath(pathname) const isOpen = isOpenPath(pathname)
log.debug('proxyRoute', { _user, _app, pathname, noAdmin, isOpen }); logger.debug('proxyRoute', { _user, _app, pathname, noAdmin, isOpen });
if (noAdmin && !isOpen) { if (noAdmin && !isOpen) {
return toSetting(); return toSetting();
} }
@@ -145,53 +171,40 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
} }
const proxyApiList = _assistantConfig?.proxy || []; const proxyApiList = _assistantConfig?.proxy || [];
const proxyApi = proxyApiList.find((item) => pathname.startsWith(item.path)); const proxyApi = proxyApiList.find((item) => pathname.startsWith(item.path));
if (proxyApi && proxyApi.type === 'file') { if (proxyApi) {
log.debug('proxyApi', { proxyApi, pathname }); logger.debug('proxyPage', { proxyApi, pathname });
const _indexPath = proxyApi.indexPath || `${_user}/${_app}/index.html`; return proxyFn(req, res, proxyApi);
const _rootPath = proxyApi.rootPath;
if (!_rootPath) {
log.error('Not Found rootPath', { proxyApi, pathname });
return res.end(`Not Found [${proxyApi.path}] rootPath`);
}
return fileProxy(req, res, {
path: proxyApi.path, // 代理路径, 比如/root/home
rootPath: proxyApi.rootPath,
...proxyApi,
indexPath: _indexPath, // 首页路径
});
} else if (proxyApi && proxyApi.type === 'http') {
log.debug('proxyApi http', { proxyApi, pathname });
return httpProxy(req, res, {
path: proxyApi.path,
target: proxyApi.target,
type: 'http',
});
} }
const filter = await authFilter(req, res); const filter = await authFilter(req, res);
if (filter) { if (filter.code !== 200) {
logger.debug('auth filter deny', filter);
res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' }); res.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
// TODO: 这里可以做成可配置的登录页面
return res.end(renderNoAuthAndLogin('Not Authorized Proxy')); return res.end(renderNoAuthAndLogin('Not Authorized Proxy'));
} }
const localProxyProxyList = localProxy.getLocalProxyList(); const localProxyProxyList = localProxy.getLocalProxyList();
const localProxyProxy = localProxyProxyList.find((item) => pathname.startsWith(item.path)); const localProxyProxy = localProxyProxyList.find((item) => pathname.startsWith(item.path));
if (localProxyProxy) { if (localProxyProxy) {
log.log('localProxyProxy', { localProxyProxy, url: req.url }); logger.debug('localProxyProxy', { localProxyProxy, url: req.url });
return fileProxy(req, res, { return proxyFn(req, res, {
path: localProxyProxy.path, path: localProxyProxy.path,
rootPath: localProxy.pagesDir, "type": 'file',
indexPath: localProxyProxy.indexPath, file: {
rootPath: localProxy.pagesDir,
indexPath: localProxyProxy.file.indexPath,
}
}); });
} }
const creatCenterProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn', ['/root', '/' + _user]); const creatCenterProxy = createApiProxy(_assistantConfig?.app?.url || 'https://kevisual.cn', ['/root', '/' + _user]);
const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path)); const centerProxy = creatCenterProxy.find((item) => pathname.startsWith(item.path));
if (centerProxy) { if (centerProxy) {
return httpProxy(req, res, { return proxyFn(req, res, {
path: centerProxy.path, path: centerProxy.path,
target: centerProxy.target, target: centerProxy.target,
type: 'http', type: 'http',
}); });
} }
log.debug('handle by router 404', req.url); logger.debug('handle by router 404', req.url);
res.statusCode = 404; res.statusCode = 404;
res.end('Not Found Proxy'); res.end('Not Found Proxy');

View File

@@ -0,0 +1,178 @@
// 使用 fetch 和 FormData 上传文件的示例代码
// 示例 1: 使用 File 对象上传
async function uploadFileUsingFileObject(file: File) {
const formData = new FormData();
formData.append('file', file);
// 可以添加其他字段
formData.append('filename', file.name);
formData.append('description', '文件描述');
formData.append('appKey', 'test');
formData.append('version', '1.0.0');
try {
const response = await fetch('http://localhost:51516/client/router', {
method: 'POST',
body: formData,
// 注意:使用 FormData 时不需要手动设置 Content-Type
// 浏览器会自动设置正确的 multipart/form-data 边界
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('上传成功:', result);
return result;
} catch (error) {
console.error('上传出错:', error);
throw error;
}
}
// 示例 2: 使用 FileList如 input[type="file"])上传
async function uploadFilesUsingFileList(files: FileList) {
const formData = new FormData();
// 多个文件使用相同的字段名
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
try {
const response = await fetch('http://localhost:51516/client/router', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('上传成功:', result);
return result;
} catch (error) {
console.error('上传出错:', error);
throw error;
}
}
// 示例 3: 从路径读取文件上传Node.js 环境,使用 fs 和 Blob
import fs from 'fs';
async function uploadFileFromPath(fileList: string[]) {
const formData = new FormData();
for (let m of fileList) {
const buffer = fs.readFileSync(m);
const file = new File([buffer], m.split('/').pop()!, {
type: 'application/octet-stream',
});
formData.append('file', file);
}
formData.append('appKey', 'test');
formData.append('version', '1.0.0');
let token = 'st_n9ycynd4m7wdyw3lejb8plnkyi62uejd'; // 如果需要身份验证,添加令牌
try {
const response = await fetch('http://localhost:51516/client/upload' + `?token=${token}`, {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status} ${response.statusText}`);
}
const result = await response.json();
console.log('上传成功:', JSON.stringify(result, null, 2));
return result;
} catch (error) {
console.error('上传出错:', error);
throw error;
}
}
uploadFileFromPath(['./src/test/remote-app.ts', './src/test/upload-file.ts']);
// 示例 4: 完整的 HTML 使用示例(浏览器环境)
/*
// HTML
// <input type="file" id="fileInput" multiple>
// <button onclick="handleUpload()">上传</button>
async function handleUpload() {
const fileInput = document.getElementById('fileInput') as HTMLInputElement;
const files = fileInput.files;
if (!files || files.length === 0) {
alert('请选择文件');
return;
}
const formData = new FormData();
for (let i = 0; i < files.length; i++) {
formData.append('files', files[i]);
}
// 添加额外数据
formData.append('userId', '12345');
formData.append('timestamp', Date.now().toString());
try {
const response = await fetch('http://localhost:51516/client/router', {
method: 'POST',
body: formData,
});
if (!response.ok) {
throw new Error(`上传失败: ${response.status}`);
}
const result = await response.json();
alert('上传成功: ' + JSON.stringify(result));
} catch (error) {
alert('上传出错: ' + error);
}
}
*/
// 示例 5: 带进度监控的上传(浏览器环境)
/*
async function uploadWithProgress(file: File, onProgress: (percent: number) => void) {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
const formData = new FormData();
formData.append('file', file);
xhr.upload.addEventListener('progress', (e) => {
if (e.lengthComputable) {
const percent = (e.loaded / e.total) * 100;
onProgress(percent);
}
});
xhr.addEventListener('load', () => {
if (xhr.status === 200) {
resolve(JSON.parse(xhr.responseText));
} else {
reject(new Error(`上传失败: ${xhr.status}`));
}
});
xhr.addEventListener('error', () => reject(new Error('网络错误')));
xhr.addEventListener('abort', () => reject(new Error('上传被取消')));
xhr.open('POST', 'http://localhost:51516/client/router');
xhr.send(formData);
});
}
// 使用
// const file = fileInput.files[0];
// uploadWithProgress(file, (percent) => {
// console.log(`上传进度: ${percent.toFixed(1)}%`);
// }).then(result => console.log('完成', result));
*/

View File

@@ -14,5 +14,6 @@
}, },
"include": [ "include": [
"src/**/*", "src/**/*",
"agent/**/*"
], ],
} }

View File

@@ -3,6 +3,7 @@
// @ts-ignore // @ts-ignore
import pkg from './package.json'; import pkg from './package.json';
// bun run src/index.ts -- // bun run src/index.ts --
const external = ['bun'];
await Bun.build({ await Bun.build({
target: 'node', target: 'node',
format: 'esm', format: 'esm',
@@ -11,9 +12,8 @@ await Bun.build({
naming: { naming: {
entry: 'envision.js', entry: 'envision.js',
}, },
external: external,
define: { define: {
ENVISION_VERSION: JSON.stringify(pkg.version), ENVISION_VERSION: JSON.stringify(pkg.version),
}, },
env: 'ENVISION_*',
}); });

View File

@@ -19,6 +19,7 @@ let proxy = {
}; };
const basename = isDev ? undefined : `${pkgs.basename}`; const basename = isDev ? undefined : `${pkgs.basename}`;
console.log('Astro Config Basename:', basename);
export default defineConfig({ export default defineConfig({
base: basename, base: basename,
integrations: [ integrations: [

View File

@@ -8,10 +8,10 @@
"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.3 -u -y y", "pub": "envision deploy ./dist -k cli -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.3 -u", "slide:pub": "envision deploy ./slides/dist -k cli-slide -v 0.0.4 -u",
"ui": "pnpm dlx shadcn@latest add " "ui": "pnpm dlx shadcn@latest add "
}, },
"keywords": [], "keywords": [],
@@ -21,23 +21,23 @@
"dependencies": { "dependencies": {
"@astrojs/mdx": "^4.3.13", "@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2", "@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0", "@astrojs/sitemap": "^3.7.0",
"@astrojs/vue": "^5.1.3", "@astrojs/vue": "^5.1.4",
"@kevisual/api": "^0.0.5", "@kevisual/api": "^0.0.17",
"@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.33", "@kevisual/query": "^0.0.35",
"@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.1.1", "antd": "^6.2.0",
"astro": "^5.16.6", "astro": "^5.16.11",
"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.43.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.562.0",
@@ -49,20 +49,20 @@
"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.26",
"zustand": "^5.0.9" "zustand": "^5.0.10"
}, },
"publishConfig": { "publishConfig": {
"access": "public" "access": "public"
}, },
"devDependencies": { "devDependencies": {
"@kevisual/types": "^0.0.10", "@kevisual/types": "^0.0.11",
"@types/react": "^19.2.7", "@types/react": "^19.2.8",
"@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.26.1", "packageManager": "pnpm@10.28.0",
"onlyBuiltDependencies": [ "onlyBuiltDependencies": [
"@tailwindcss/oxide", "@tailwindcss/oxide",
"esbuild", "esbuild",

View File

@@ -1,4 +1,4 @@
import { query, queryLogin } from '@/modules/query'; import { clientQuery, queryLogin } from '@/modules/query';
import { create } from 'zustand'; import { create } from 'zustand';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
type SettingState = { type SettingState = {
@@ -12,7 +12,7 @@ export const useStore = create<SettingState>((set => ({
username: undefined, username: undefined,
config: undefined, config: undefined,
initAdmin: async () => { initAdmin: async () => {
const res = await query.post({ const res = await clientQuery.post({
path: 'config' path: 'config'
}) })
console.log('initAdmin', res); console.log('initAdmin', res);
@@ -25,7 +25,7 @@ export const useStore = create<SettingState>((set => ({
} }
}, },
login: async (username: string, password: string) => { login: async (username: string, password: string) => {
const res = await query.post({ const res = await clientQuery.post({
path: 'admin', path: 'admin',
key: 'login', key: 'login',
username, username,
@@ -40,7 +40,7 @@ export const useStore = create<SettingState>((set => ({
return res; return res;
}, },
saveConfig: async (config: any) => { saveConfig: async (config: any) => {
const res = await query.post({ const res = await clientQuery.post({
path: 'config', path: 'config',
key: 'set', key: 'set',
data: config data: config

View File

@@ -1,6 +1,6 @@
--- ---
title: '配置项介绍' title: '配置项介绍'
description: 'Assistant 应用配置项完整说明文档包括应用信息、代理、服务器、认证、AI等各项配置详解' description: 'Assistant 应用配置项完整说明文档包括应用信息、代理、服务器、认证、AI、存储等各项配置详解'
tags: ['config', 'configuration', 'settings', 'assistant'] tags: ['config', 'configuration', 'settings', 'assistant']
createdAt: '2025-12-18' createdAt: '2025-12-18'
--- ---
@@ -22,28 +22,38 @@ createdAt: '2025-12-18'
} }
``` ```
- **id**: `string` - 应用唯一标识符,用于识别具体设备或应用实例 | 字段 | 类型 | 说明 |
- **url**: `string` - 应用访问地址 | ---- | -------- | ------------------------------------------ |
| id | `string` | 应用唯一标识符,用于识别具体设备或应用实例 |
| url | `string` | 应用访问地址 |
## token - 访问令牌 ## token - 访问令牌
用于身份验证的访问令牌。
```json ```json
{ {
"token": "your-access-token" "token": "your-access-token"
} }
``` ```
- **token**: `string` - 用于身份验证的访问令牌 | 字段 | 类型 | 说明 |
| ----- | -------- | -------- |
| token | `string` | 访问令牌 |
## registry - 注册中心 ## registry - 注册中心
注册中心地址,默认为 `https://kevisual.cn`
```json ```json
{ {
"registry": "https://kevisual.cn" "registry": "https://kevisual.cn"
} }
``` ```
- **registry**: `string` - 注册中心地址,默认为 `https://kevisual.cn` | 字段 | 类型 | 说明 |
| -------- | -------- | ------------ |
| registry | `string` | 注册中心地址 |
## proxy - 前端代理配置 ## proxy - 前端代理配置
@@ -61,16 +71,18 @@ createdAt: '2025-12-18'
} }
``` ```
- **proxy**: `ProxyInfo[]` - 代理配置数组 | 字段 | 类型 | 说明 |
- **path**: `string` - 匹配的路径前缀 | ---------------- | ------------- | ---------------------- |
- **target**: `string` - 目标服务器地址 | proxy | `ProxyInfo[]` | 代理配置数组 |
- **pathname**: `string` - 转发到目标服务器的路径 | proxy[].path | `string` | 匹配的路径前缀 |
| proxy[].target | `string` | 目标服务器地址 |
| proxy[].pathname | `string` | 转发到目标服务器的路径 |
示例:访问 `/root/home` 会被转发到 `https://kevisual.cn/root/home` 示例:访问 `/root/home` 会被转发到 `https://kevisual.cn/root/home`
## api - API代理配置 ## api - API代理配置
专门用于API请求的代理配置。 专门用于 API 请求的代理配置,例如 `/api``/v1` 开头的请求
```json ```json
{ {
@@ -89,17 +101,48 @@ createdAt: '2025-12-18'
} }
``` ```
- **api.proxy**: `ProxyInfo[]` - API代理配置数组配置方式同 `proxy` | 字段 | 类型 | 说明 |
|------|------|{------|
| api.proxy | `ProxyInfo[]` | API代理配置数组 |
## router - 路由配置
配置应用的路由代理功能。
```json
{
"router": {
"proxy": [
{
"type": "router",
"router": {
"url": "https://kevisual.cn/api/router"
}
}
],
"base": true
}
}
```
| 字段 | 类型 | 说明 |
| ------------ | ------------- | ----------------------------------------------------------------- |
| router.proxy | `ProxyInfo[]` | 代理配置数组 |
| router.base | `boolean` | 是否注册基础路由,监听https://kevisual.cn/api/router默认 `false` |
## description - 应用描述 ## description - 应用描述
应用的描述信息。
```json ```json
{ {
"description": "我的助手应用" "description": "我的助手应用"
} }
``` ```
- **description**: `string` - 应用的描述信息 | 字段 | 类型 | 说明 |
| ----------- | -------- | ------------ |
| description | `string` | 应用描述信息 |
## server - 服务器配置 ## server - 服务器配置
@@ -114,8 +157,10 @@ createdAt: '2025-12-18'
} }
``` ```
- **server.path**: `string` - 服务器监听地址,默认 `127.0.0.1` | 字段 | 类型 | 说明 |
- **server.port**: `number` - 服务器监听端口号 | ----------- | --------- | -------------------------------- |
| server.path | `string`` | 服务器监听地址,默认 `127.0.0.1` |
| server.port | `number` | 服务器监听端口号 |
## share - 远程访问配置 ## share - 远程访问配置
@@ -130,8 +175,10 @@ createdAt: '2025-12-18'
} }
``` ```
- **share.url**: `string` - 远程应用代理地址 | 字段 | 类型 | 说明 |
- **share.enabled**: `boolean` - 是否启用远程访问功能 | ------------- | --------- | -------------------- |
| share.url | `string` | 远程应用代理地址 |
| share.enabled | `boolean` | 是否启用远程访问功能 |
## watch - 文件监听配置 ## watch - 文件监听配置
@@ -145,21 +192,27 @@ createdAt: '2025-12-18'
} }
``` ```
- **watch.enabled**: `boolean` - 是否启用文件监听 | 字段 | 类型 | 说明 |
| ------------- | --------- | ---------------- |
| watch.enabled | `boolean` | 是否启用文件监听 |
## home - 首页路径 ## home - 首页路径
访问根路径 `/` 时自动重定向的首页地址。
```json ```json
{ {
"home": "/root/home" "home": "/root/home"
} }
``` ```
- **home**: `string` - 访问根路径 `/` 时自动重定向的首页地址 | 字段 | 类型 | 说明 |
| ---- | -------- | -------- |
| home | `string` | 首页路径 |
## ai - AI功能配置 ## ai - AI功能配置
启用和配置本地AI代理功能。 启用和配置本地 AI 代理功能。
```json ```json
{ {
@@ -172,28 +225,32 @@ createdAt: '2025-12-18'
} }
``` ```
- **ai.enabled**: `boolean` - 是否启用AI功能 | 字段 | 类型 | 说明 |
- **ai.provider**: `string` - AI提供商可选 `'DeepSeek'` | `'Custom'` 或其他自定义值 | ----------- | --------- | ---------------------------- | ---------- |
- **ai.apiKey**: `string` - API密钥 | ai.enabled | `boolean` | 是否启用 AI 功能 |
- **ai.model**: `string` - 使用的模型名称 | ai.provider | `string` | AI 提供商,可选 `'DeepSeek'` | `'Custom'` |
| ai.apiKey | `string` | API 密钥 |
| ai.model | `string` | 使用的模型名称 |
## scripts - 自定义脚本 ## asr - 语音识别配置
定义自定义脚本命令,在应用启动时执行 配置阿里云语音识别服务
```json ```json
{ {
"scripts": { "asr": {
"start": "node server.js", "enabled": true,
"build": "npm run build", "token": "your-asr-token"
"custom": "echo 'Hello World'"
} }
} }
``` ```
- **scripts**: `Record<string, string>` - 键值对形式的脚本配置 | 字段 | 类型 | 说明 |
- key: 脚本名称 | ----------- | --------- | -------------------- |
- value: 要执行的命令 | asr.enabled | `boolean` | 是否启用语音识别功能 |
| asr.token | `string` | 阿里云 ASR 服务令牌 |
使用模型:`qwen3-asr-flash-realtime`
## auth - 认证和权限配置 ## auth - 认证和权限配置
@@ -207,33 +264,71 @@ createdAt: '2025-12-18'
} }
``` ```
- **auth**: `AuthPermission` - 认证权限配置对象 | 字段 | 类型 | 说明 |
- **share**: 共享访问模式 | ---------- | -------- | ------------------------------------------------- |
- `"protected"` - 需要认证才能访问(默认) | auth.share | `string` | 共享访问模式,影响 pages 目录下页面的对外共享权限 |
- `"public"` - 公开访问,无需认证
- `"private"` - 私有访问,完全禁止外部访问
> **说明**: `share` 配置影响 pages 目录下页面的对外共享权限 **share 可选值:**
## https - HTTPS证书配置 - `"protected"` - 需要认证才能访问(默认)
- `"public"` - 公开访问,无需认证
- `"private"` - 私有访问,完全禁止外部访问
配置HTTPS服务和证书。 ## storage - 存储配置
配置文件存储,支持本地文件系统和 S3/MinIO 两种存储方式。
### 本地文件存储 (FileStorage)
```json ```json
{ {
"https": { "storage": [
"type": "https", {
"keyPath": "/path/to/private.key", "id": "local-storage",
"certPath": "/path/to/certificate.crt" "path": "./uploads",
} "type": "local"
}
]
} }
``` ```
- **https.type**: `'https' | 'http'` - 服务协议类型,默认 `'http'` ### S3/MinIO 存储 (S3Storage)
- **https.keyPath**: `string` - SSL证书私钥文件路径
- **https.certPath**: `string` - SSL证书文件路径
> **注意**: 通常不需要配置HTTPS可以通过反向代理如Nginx实现HTTPS访问 ```json
{
"storage": [
{
"id": "s3-storage",
"bucket": "my-bucket",
"region": "us-east-1",
"accessKeyId": "AKIAXXXXXXXXXXXXXXXX",
"secretAccessKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"endpoint": "https://s3.amazonaws.com",
"type": "s3"
},
{
"id": "minio-storage",
"bucket": "my-bucket",
"region": "us-east-1",
"accessKeyId": "minioadmin",
"secretAccessKey": "minioadmin",
"endpoint": "http://localhost:9000",
"type": "minio"
}
]
}
```
| 字段 | 类型 | 说明 |
| ------------------------- | ---------------------------- | ------------------------------------------------------- |
| storage[].id | `string` | 存储标识符,唯一标识一个存储配置 |
| storage[].type | `'local' \| 's3' \| 'minio'` | 存储类型 |
| storage[].path | `string` | 本地存储路径(仅 type 为 `local` 时有效) |
| storage[].bucket | `string` | 存储桶名称(仅 type 为 `s3` 或 `minio` 时有效) |
| storage[].region | `string` | 存储区域(仅 type 为 `s3` 或 `minio` 时有效) |
| storage[].accessKeyId | `string` | 访问密钥 ID仅 type 为 `s3` 或 `minio` 时有效) |
| storage[].secretAccessKey | `string` | 访问密钥(仅 type 为 `s3` 或 `minio` 时有效) |
| storage[].endpoint | `string` | 服务端点地址(仅 type 为 `s3` 或 `minio` 时有效,可选) |
## 完整配置示例 ## 完整配置示例
@@ -260,6 +355,17 @@ createdAt: '2025-12-18'
} }
] ]
}, },
"router": {
"proxy": [
{
"type": "router",
"router": {
"url": "https://kevisual.cn/api/router"
}
}
],
"base": true
},
"description": "生产环境助手应用", "description": "生产环境助手应用",
"server": { "server": {
"path": "0.0.0.0", "path": "0.0.0.0",
@@ -279,13 +385,29 @@ createdAt: '2025-12-18'
"apiKey": "sk-xxx", "apiKey": "sk-xxx",
"model": "deepseek-chat" "model": "deepseek-chat"
}, },
"scripts": { "asr": {
"setup": "npm install", "enabled": true,
"dev": "npm run dev" "token": "your-asr-token"
}, },
"auth": { "auth": {
"share": "protected" "share": "protected"
} },
"storage": [
{
"id": "local-storage",
"path": "./uploads",
"type": "local"
},
{
"id": "s3-storage",
"bucket": "my-bucket",
"region": "us-east-1",
"accessKeyId": "AKIAXXXXXXXXXXXXXXXX",
"secretAccessKey": "XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX",
"endpoint": "https://s3.amazonaws.com",
"type": "s3"
}
]
} }
``` ```
@@ -295,8 +417,8 @@ createdAt: '2025-12-18'
## 最佳实践 ## 最佳实践
1. **安全性**: 不要在配置文件中硬编码敏感信息(如 token、apiKey建议使用环境变量 1. **安全性**: 不要在配置文件中硬编码敏感信息(如 token、apiKey、secretAccessKey),建议使用环境变量
2. **端口选择**: 确保选择的端口未被占用 2. **端口选择**: 确保选择的端口未被占用
3. **代理配置**: 合理配置代理路径,避免路径冲突 3. **代理配置**: 合理配置代理路径,避免路径冲突
4. **HTTPS**: 生产环境建议使用反向代理配置HTTPS而非直接在应用中配置 4. **权限控制**: 根据实际需求选择合适的 `auth.share` 模式
5. **权限控制**: 根据实际需求选择合适的 `auth.share` 模式 5. **存储配置**: 根据应用规模选择合适的存储方式本地存储适合开发和小型应用S3/MinIO 适合生产环境

View File

@@ -14,6 +14,10 @@ export const query = new QueryClient({
url: getUrl() url: getUrl()
}); });
export const clientQuery = new QueryClient({
url: '/client/router'
});
export const remoteQuery = new Query({ export const remoteQuery = new Query({
url: '/api/router' url: '/api/router'
}); });

View File

@@ -10,13 +10,13 @@ import Html from '@/components/html.astro';
</div> </div>
<div class="card-grid"> <div class="card-grid">
<a href="./docs/" class="card"> <a href={"/root/cli/docs/"} class="card">
<div class="card-icon">📚</div> <div class="card-icon">📚</div>
<h2>文档中心</h2> <h2>文档中心</h2>
<p>查看完整的使用文档和API参考</p> <p>查看完整的使用文档和API参考</p>
</a> </a>
<a href="./settings/" class="card"> <a href={"/root/cli/settings/"} class="card">
<div class="card-icon">⚙️</div> <div class="card-icon">⚙️</div>
<h2>设置中心</h2> <h2>设置中心</h2>
<p>配置和管理您的应用设置</p> <p>配置和管理您的应用设置</p>

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kevisual/cli", "name": "@kevisual/cli",
"version": "0.0.80", "version": "0.0.94",
"description": "envision 命令行工具", "description": "envision 命令行工具",
"type": "module", "type": "module",
"basename": "/root/cli", "basename": "/root/cli",
@@ -20,6 +20,9 @@
"asst": "bin/assistant.js", "asst": "bin/assistant.js",
"asst-server": "bin/assistant-server.js" "asst-server": "bin/assistant-server.js"
}, },
"exports": {
".": "./dist/assistant-opencode.js"
},
"files": [ "files": [
"dist", "dist",
"bin", "bin",
@@ -43,29 +46,32 @@
"dependencies": { "dependencies": {
"@inquirer/prompts": "^8.2.0", "@inquirer/prompts": "^8.2.0",
"@kevisual/app": "^0.0.2", "@kevisual/app": "^0.0.2",
"@kevisual/auth": "^2.0.3",
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/hot-api": "^0.0.3", "@kevisual/use-config": "^1.0.28",
"@kevisual/use-config": "^1.0.26", "@opencode-ai/sdk": "^1.1.36",
"@nut-tree-fork/nut-js": "^4.2.6", "@types/busboy": "^1.5.4",
"eventemitter3": "^5.0.1", "busboy": "^1.6.0",
"eventemitter3": "^5.0.4",
"jose": "^6.1.3",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.4",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"pm2": "^6.0.14", "pm2": "latest",
"semver": "^7.7.3", "semver": "^7.7.3",
"unstorage": "^1.17.3" "unstorage": "^1.17.4"
}, },
"devDependencies": { "devDependencies": {
"@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.35", "@kevisual/query": "0.0.38",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@types/bun": "^1.3.6", "@types/bun": "^1.3.6",
"@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.8", "@types/node": "^25.0.10",
"@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.2",
@@ -75,7 +81,8 @@
"form-data": "^4.0.5", "form-data": "^4.0.5",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"tar": "^7.5.2", "pm2": "^6.0.14",
"tar": "^7.5.6",
"zustand": "^5.0.10" "zustand": "^5.0.10"
}, },
"engines": { "engines": {

3493
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,8 +1,5 @@
# 可视化控制台工具 # 一个简单的cli工具
## 1. 上传文件
## 2. 下载文件
## 3. 同步模板
```
npm i -g @kevisual/cli
```

View File

@@ -1,5 +1,5 @@
import { program, Command } from '@/program.ts'; import { program, Command } from '@/program.ts';
import { chalk } from '../module/chalk.ts'; import { chalk } from '../../module/chalk.ts';
import path from 'node:path'; import path from 'node:path';
import { spawn } from 'node:child_process'; import { spawn } from 'node:child_process';
import { useKey } from '@kevisual/use-config'; import { useKey } from '@kevisual/use-config';
@@ -7,7 +7,7 @@ import os from 'node:os'
import fs from 'node:fs'; import fs from 'node:fs';
import { select } from '@inquirer/prompts'; import { select } from '@inquirer/prompts';
const MODELS = ['minimax', 'glm', 'volcengine'] as const; const MODELS = ['minimax', 'glm', 'volcengine', 'bailian'] as const;
type Model = typeof MODELS[number]; type Model = typeof MODELS[number];
const changeMinimax = (token?: string) => { const changeMinimax = (token?: string) => {
@@ -53,7 +53,20 @@ const changeVolcengine = (token?: string) => {
} }
} }
} }
const changeBailian = (token?: string) => {
const auth_token = token || useKey('BAILIAN_API_KEY')
return {
"env": {
"ANTHROPIC_AUTH_TOKEN": auth_token,
"ANTHROPIC_BASE_URL": "https://coding.dashscope.aliyuncs.com/apps/anthropic",
"ANTHROPIC_DEFAULT_HAIKU_MODEL": "qwen3-coder-plus",
"ANTHROPIC_DEFAULT_OPUS_MODEL": "qwen3-coder-plus",
"ANTHROPIC_DEFAULT_SONNET_MODEL": "qwen3-coder-plus",
"ANTHROPIC_MODEL": "qwen3-coder-plus"
},
"includeCoAuthoredBy": false
}
}
/** /**
* ~/.claude.json "hasCompletedOnboarding": true * ~/.claude.json "hasCompletedOnboarding": true
*/ */
@@ -83,6 +96,7 @@ const modelConfig: Record<Model, (token?: string) => object> = {
minimax: changeMinimax, minimax: changeMinimax,
glm: changeGLM, glm: changeGLM,
volcengine: changeVolcengine, volcengine: changeVolcengine,
bailian: changeBailian,
}; };
const readOrCreateConfig = (configPath: string): Record<string, unknown> => { const readOrCreateConfig = (configPath: string): Record<string, unknown> => {

View File

@@ -111,6 +111,7 @@ const command = new Command('deploy')
} }
const uploadDirectory = isDirectory ? directory : path.dirname(directory); const uploadDirectory = isDirectory ? directory : path.dirname(directory);
const res = await uploadFiles(_relativeFiles, uploadDirectory, { key, version, username: org, noCheckAppFiles: !noCheck, directory: options.directory }); const res = await uploadFiles(_relativeFiles, uploadDirectory, { key, version, username: org, noCheckAppFiles: !noCheck, directory: options.directory });
logger.debug('upload res', res);
if (res?.code === 200) { if (res?.code === 200) {
res.data?.upload?.map?.((d) => { res.data?.upload?.map?.((d) => {
console.log(chalk.green('uploaded file', d?.name, d?.path)); console.log(chalk.green('uploaded file', d?.name, d?.path));
@@ -119,6 +120,7 @@ const command = new Command('deploy')
key: key, key: key,
version: version, version: version,
}); });
logger.debug('queryAppVersion res', res2);
if (res2.code !== 200) { if (res2.code !== 200) {
console.error(chalk.red('查询应用版本失败'), res2.message, key); console.error(chalk.red('查询应用版本失败'), res2.message, key);
return; return;
@@ -169,7 +171,7 @@ const uploadFiles = async (files: string[], directory: string, opts: UploadFileO
logger.error('请检查文件是否存在'); logger.error('请检查文件是否存在');
} }
data.files.push({ path: file, hash: hash }); data.files.push({ path: file, hash: hash });
if(filePath.includes('readme.md')) { if (filePath.includes('readme.md')) {
description = fs.readFileSync(filePath, 'utf-8'); description = fs.readFileSync(filePath, 'utf-8');
} }
} }
@@ -213,8 +215,10 @@ const uploadFiles = async (files: string[], directory: string, opts: UploadFileO
} }
const filename = path.basename(filePath); const filename = path.basename(filePath);
logger.debug('upload file', file, filename); logger.debug('upload file', file, filename);
// 解决 busbox 文件名乱码: 将 UTF-8 编码的文件名转换为 binary 字符串
const encodedFilename = Buffer.from(filename, 'utf-8').toString('binary');
form.append('file', fs.createReadStream(filePath), { form.append('file', fs.createReadStream(filePath), {
filename: filename, filename: encodedFilename,
filepath: file, filepath: file,
}); });
needUpload = true; needUpload = true;

74
src/command/jwks.ts Normal file
View File

@@ -0,0 +1,74 @@
import { generate } from '@kevisual/auth'
import { program, Command } from '@/program.ts';
import fs from 'node:fs';
import path from 'node:path';
export const getPath = async (dir: string) => {
const JWKS_PATH = path.join(dir, 'jwks.json');
const PRIVATE_JWK_PATH = path.join(dir, 'privateKey.json');
const PRIVATE_KEY_PATH = path.join(dir, 'privateKey.txt');
const PUBLIC_KEY_PATH = path.join(dir, 'publicKey.txt');
return {
JWKS_PATH,
PRIVATE_JWK_PATH,
PRIVATE_KEY_PATH,
PUBLIC_KEY_PATH,
}
}
const jwksCmd = new Command('jwks')
.description('JWKS 相关命令')
.action(async (opts) => {
});
const jwksGenerate = new Command('generate')
.alias('gen')
.option('-d , --dir <dir>', '指定保存目录,默认当前目录下 jwt 文件夹', 'jwt')
.description('生成 JWKS 密钥对')
.action(async (opts) => {
const dir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
const { JWKS_PATH, PRIVATE_JWK_PATH, PRIVATE_KEY_PATH, PUBLIC_KEY_PATH } = await getPath(dir);
const { jwks, privateJWK, privatePEM, publicPEM } = await generate();
fs.writeFileSync(PUBLIC_KEY_PATH, publicPEM);
fs.writeFileSync(PRIVATE_KEY_PATH, privatePEM);
fs.writeFileSync(PRIVATE_JWK_PATH, JSON.stringify(privateJWK, null, 2));
fs.writeFileSync(JWKS_PATH, JSON.stringify(jwks, null, 2));
console.log(`Keys have been saved to directory: ${dir}`);
});
jwksCmd.addCommand(jwksGenerate);
const getJWKS = new Command('get')
.description('获取 JWKS 内容')
.option('-d , --dir <dir>', '指定 JWKS 所在目录,默认当前目录下 jwt 文件夹', 'jwt')
.option('-t, --type <type>', '指定获取类型jwks 或 privateJWK', 'jwks')
.action(async (opts) => {
const dir = path.isAbsolute(opts.dir) ? opts.dir : path.join(process.cwd(), opts.dir);
const { JWKS_PATH, PRIVATE_JWK_PATH } = await getPath(dir);
const type = opts.type || 'jwks';
if (type !== 'jwks') {
if (!fs.existsSync(PRIVATE_JWK_PATH)) {
console.error(`Private JWK file not found in directory: ${dir}`);
return;
}
const privateJWKContent = fs.readFileSync(PRIVATE_JWK_PATH, 'utf-8');
console.log('Private JWK:\n');
console.log(privateJWKContent);
return;
}
if (!fs.existsSync(JWKS_PATH)) {
console.error(`JWKS file not found in directory: ${dir}`);
return;
}
const jwksContent = fs.readFileSync(JWKS_PATH, 'utf-8');
console.log('PublicJWKS:\n');
console.log(jwksContent);
});
jwksCmd.addCommand(getJWKS);
program.addCommand(jwksCmd);

View File

@@ -14,7 +14,7 @@ const parseIfJson = (str: string) => {
return {}; return {};
} }
}; };
const command = new Command('npm').description('npm command show publish and set .npmrc').action(async (options) => {}); const command = new Command('npm').description('npm command show publish and set .npmrc').action(async (options) => { });
const publish = new Command('publish') const publish = new Command('publish')
.argument('[registry]') .argument('[registry]')
.option('-p --proxy', 'proxy') .option('-p --proxy', 'proxy')
@@ -33,6 +33,10 @@ const publish = new Command('publish')
name: 'npm', name: 'npm',
value: 'npm', value: 'npm',
}, },
{
name: 'cnb',
value: 'cnb'
}
], ],
}); });
} }
@@ -60,6 +64,9 @@ const publish = new Command('publish')
case 'npm': case 'npm':
cmd = 'npm publish --registry https://registry.npmjs.org'; cmd = 'npm publish --registry https://registry.npmjs.org';
break; break;
case 'cnb':
cmd = 'npm publish --registry https://npm.cnb.cool/kevisual/registry/-/packages/';
break;
default: default:
cmd = 'npm publish --registry https://npm.xiongxiao.me'; cmd = 'npm publish --registry https://npm.xiongxiao.me';
break; break;
@@ -136,6 +143,7 @@ const npmrc = new Command('set')
const npmrcContent = const npmrcContent =
config?.npmrc || config?.npmrc ||
`//npm.xiongxiao.me/:_authToken=\${ME_NPM_TOKEN} `//npm.xiongxiao.me/:_authToken=\${ME_NPM_TOKEN}
//npm.cnb.cool/kevisual/registry/-/packages/:_authToken=\${CNB_API_KEY}
//registry.npmjs.org/:_authToken=\${NPM_TOKEN} //registry.npmjs.org/:_authToken=\${NPM_TOKEN}
`; `;
const execPath = process.cwd(); const execPath = process.cwd();

View File

@@ -0,0 +1,2 @@
// TODO: 对 .opencode/plugin/agent.ts 的内容进行管理
// 例如添加、删除、列出等操作

View File

@@ -6,8 +6,9 @@ import { getConfig, query } from '@/module/index.ts';
import { fileIsExist } from '@/uitls/file.ts'; import { fileIsExist } from '@/uitls/file.ts';
import { chalk } from '@/module/chalk.ts'; import { chalk } from '@/module/chalk.ts';
import * as backServices from '@/query/services/index.ts'; import * as backServices from '@/query/services/index.ts';
import { select, input } from '@inquirer/prompts'; import { input } from '@inquirer/prompts';
import { logger } from '@/module/logger.ts'; import { logger } from '@/module/logger.ts';
// 查找文件(忽略大小写) // 查找文件(忽略大小写)
async function findFileInsensitive(targetFile: string): Promise<string | null> { async function findFileInsensitive(targetFile: string): Promise<string | null> {
const files = fs.readdirSync('.'); const files = fs.readdirSync('.');
@@ -249,14 +250,9 @@ const packCommand = new Command('pack')
let appKey: string | undefined; let appKey: string | undefined;
let version = packageInfo.version || ''; let version = packageInfo.version || '';
if (!version) { if (!version) {
const answers = await inquirer.prompt([ version = await input({
{ message: 'Enter your version:',
type: 'input', });
name: 'version',
message: 'Enter your version:',
},
]);
version = answers.version || version;
} }
if (basename) { if (basename) {
@@ -271,14 +267,9 @@ const packCommand = new Command('pack')
appKey = basenameArr[1] || ''; appKey = basenameArr[1] || '';
} }
if (!appKey) { if (!appKey) {
const answers = await inquirer.prompt([ appKey = await input({
{ message: 'Enter your appKey:',
type: 'input', });
name: 'appKey',
message: 'Enter your appKey:',
},
]);
appKey = answers.appKey || appKey;
} }
let value = await pack({ let value = await pack({
packDist, packDist,

View File

@@ -8,7 +8,7 @@ import { logger, printClickableLink } from '@/module/logger.ts';
import { chalk } from '@/module/chalk.ts'; import { chalk } from '@/module/chalk.ts';
import path from 'node:path'; import path from 'node:path';
import { fileIsExist } from '@/uitls/file.ts'; import { fileIsExist } from '@/uitls/file.ts';
import { confirm } from '@inquirer/prompts'
const command = new Command('sync') const command = new Command('sync')
.option('-d --dir <dir>') .option('-d --dir <dir>')
.description('同步项目') .description('同步项目')
@@ -33,7 +33,19 @@ const syncUpload = new Command('upload')
}; };
const filepath = sync.getRelativePath(opts.file); const filepath = sync.getRelativePath(opts.file);
const newInfos = []; const newInfos = [];
const uploadLength = syncList.length;
logger.info(`开始上传文件,总计 ${uploadLength} 个文件`);
if (uploadLength > 100) {
// 提示用户确认
const shouldContinue = await confirm({
message: `即将上传 ${uploadLength} 个文件,是否继续?`,
default: false,
});
if (!shouldContinue) {
logger.info('已取消上传');
return;
}
}
for (const item of syncList) { for (const item of syncList) {
if (!item.auth || !item.exist) { if (!item.auth || !item.exist) {
nodonwArr.push(item); nodonwArr.push(item);

View File

@@ -17,8 +17,10 @@ import './command/gist/index.ts';
import './command/config-remote.ts'; import './command/config-remote.ts';
import './command/config-secret-remote.ts'; import './command/config-secret-remote.ts';
import './command/ai.ts'; import './command/ai.ts';
import './command/cc.ts' import './command/claude/cc.ts'
import './command/docker.ts'; import './command/docker.ts';
import './command/jwks.ts';
// program.parse(process.argv); // program.parse(process.argv);
export const runParser = async (argv: string[]) => { export const runParser = async (argv: string[]) => {

View File

@@ -1,6 +1,7 @@
import os from 'os'; import os from 'os';
import path from 'path'; import path from 'path';
import fs from 'fs'; import fs from 'fs';
import { useKey } from '@kevisual/use-config';
export const envisionPath = path.join(os.homedir(), '.config', 'envision'); export const envisionPath = path.join(os.homedir(), '.config', 'envision');
const configPath = path.join(os.homedir(), '.config', 'envision', 'config.json'); const configPath = path.join(os.homedir(), '.config', 'envision', 'config.json');
@@ -37,5 +38,6 @@ export const writeConfig = (config: Record<string, any>) => {
}; };
export const getEnvToken = () => { export const getEnvToken = () => {
return process.env.KEVISUAL_TOKEN || ''; const envTokne = useKey('KEVISUAL_TOKEN') || '';
return envTokne;
} }

View File

@@ -1,3 +1,4 @@
import { logger } from '@/module/logger.ts';
import { query } from '@/module/query.ts'; import { query } from '@/module/query.ts';
import { DataOpts } from '@kevisual/query'; import { DataOpts } from '@kevisual/query';
@@ -26,6 +27,7 @@ export const queryApp = async (params: QueryAppParams, opts?: any) => {
}; };
export const queryAppVersion = async (params: { key?: string; version?: string; id?: string }, opts?: DataOpts) => { export const queryAppVersion = async (params: { key?: string; version?: string; id?: string }, opts?: DataOpts) => {
logger.debug('queryAppVersion params', params, query.url);
return await query.post( return await query.post(
{ {
path: 'app', path: 'app',

4
temp.md Normal file
View File

@@ -0,0 +1,4 @@
"@nut-tree-fork/nut-js": "^4.2.6",
"@kevisual/hot-api": "^0.0.3",
KEVISUAL_TOKEN="" LOG_LEVEL=DEBUG pnpm dev deploy ./cli-center/dist -k cli -v 0.0.4 -u -y y