Compare commits

...

20 Commits

Author SHA1 Message Date
c77578805a feat: 更新版本号至0.0.3,调整发布脚本以匹配新版本 2025-12-18 03:49:30 +08:00
ca1c3706b2 feat: 实现首次登录功能,添加用户名和密码输入,更新状态管理 2025-12-18 03:47:59 +08:00
6e1ffe173a feat: 更新开发脚本,添加新的环境变量支持,优化管理员登录流程 2025-12-18 03:47:07 +08:00
5b610fd600 init cli center 2025-12-18 01:51:51 +08:00
b9624e4f6f fix: 添加身份验证检查,未登录用户重定向至根目录 2025-12-17 23:22:36 +08:00
8f29ddb449 add external 2025-12-17 22:32:33 +08:00
22de8cad52 fix: 移除不再使用的依赖项 '@kevisual/hot-api' 和 '@nut-tree-fork/nut-js' 2025-12-17 21:33:06 +08:00
73d98a1209 chore: update dependencies and package versions
- Bump packageManager from pnpm@10.25.0 to pnpm@10.26.0
- Update @kevisual/router from ^0.0.37 to ^0.0.39
- Update @types/node from ^24.10.2 to ^25.0.3
- Update inquirer from ^13.0.2 to ^13.1.0
- Update lodash-es from ^4.17.21 to ^4.17.22
- Update send from ^1.2.0 to ^1.2.1
- Update version of @kevisual/cli from 0.0.74 to 0.0.75 in package.json
- Update pnpm-lock.yaml to reflect changes in dependencies and versions
2025-12-17 20:22:30 +08:00
6b96a22c7a feat: 支持通过环境变量配置助手目录 2025-12-17 20:20:35 +08:00
2393cbefbb fix: 修复下载配置文件失败时的错误处理逻辑 2025-12-17 12:18:55 +08:00
0ca5989a40 feat: 在上传文件时读取并保存 readme.md 的内容作为描述 2025-12-17 11:22:36 +08:00
48f2695367 fix: 修改未登录状态下的返回值,提供更明确的错误信息 2025-12-15 23:06:21 +08:00
7d4bc37c09 update 2025-12-15 23:02:06 +08:00
f3f1a1d058 update 2025-12-14 11:42:48 +08:00
4aeb3637bf feat: enhance AI commands and logging system
- Update @kevisual/query to 0.0.32 and @kevisual/router to 0.0.37
- Restructure AI command interface with run and deploy subcommands
- Add comprehensive logging throughout cmd-execution flow
- Improve sync module with better configuration handling
- Add clickable link functionality in logger
- Enhance error handling and debugging capabilities

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
2025-12-10 17:45:09 +08:00
5b83f7a6d1 up 2025-12-09 13:40:41 +08:00
9127df2600 add sender 2025-12-08 16:43:36 +08:00
8118daa4e2 update deploy 2025-12-07 11:05:27 +08:00
eca7b42377 udpate 2025-12-06 18:55:43 +08:00
ee33208e6c fix: fix hot-api for not build in bun app.mjs 2025-12-05 20:53:25 +08:00
74 changed files with 16105 additions and 309 deletions

2
.gitignore vendored
View File

@@ -4,7 +4,7 @@ node_modules
dist dist
pack-dist pack-dist
apps
assistant-app assistant-app
build build

View File

@@ -6,6 +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'];
/** /**
* *
* @param {string} p * @param {string} p
@@ -20,11 +21,10 @@ await Bun.build({
naming: { naming: {
entry: 'assistant.js', entry: 'assistant.js',
}, },
external: ['pm2'], external,
define: { define: {
ENVISION_VERSION: JSON.stringify(pkg.version), ENVISION_VERSION: JSON.stringify(pkg.version),
}, },
env: 'ENVISION_*',
}); });
await Bun.build({ await Bun.build({
@@ -38,8 +38,7 @@ await Bun.build({
define: { define: {
ENVISION_VERSION: JSON.stringify(pkg.version), ENVISION_VERSION: JSON.stringify(pkg.version),
}, },
external: ['pm2'], external,
env: 'ENVISION_*',
}); });
// const copyDist = ['dist', 'bin']; // const copyDist = ['dist', 'bin'];
const copyDist = ['dist']; const copyDist = ['dist'];

View File

@@ -10,7 +10,7 @@
], ],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)", "author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT", "license": "MIT",
"packageManager": "pnpm@10.24.0", "packageManager": "pnpm@10.26.0",
"type": "module", "type": "module",
"files": [ "files": [
"dist", "dist",
@@ -20,8 +20,9 @@
], ],
"scripts": { "scripts": {
"dev": "bun run src/run.ts ", "dev": "bun run src/run.ts ",
"dev:server": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/run-server.ts --home ", "dev:server": "bun --watch src/run-server.ts ",
"dev:share": "cross-env NODE_TLS_REJECT_UNAUTHORIZED=0 bun --watch src/test/remote-app.ts ", "dev:cnb": "ASSISTANT_CONFIG_DIR=/workspace bun --watch src/run-server.ts ",
"dev:share": "bun --watch src/test/remote-app.ts ",
"build:lib": "bun run bun-lib.config.mjs", "build:lib": "bun run bun-lib.config.mjs",
"postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t", "postbuild:lib": "dts -i src/lib.ts -o assistant-lib.d.ts -d libs -t",
"build": "rimraf dist && bun run bun.config.mjs", "build": "rimraf dist && bun run bun.config.mjs",
@@ -41,18 +42,18 @@
} }
}, },
"devDependencies": { "devDependencies": {
"@kevisual/ai": "^0.0.16", "@kevisual/ai": "^0.0.19",
"@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.30", "@kevisual/query": "0.0.32",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@kevisual/router": "^0.0.33", "@kevisual/router": "^0.0.39",
"@kevisual/types": "^0.0.10", "@kevisual/types": "^0.0.10",
"@kevisual/use-config": "^1.0.21", "@kevisual/use-config": "^1.0.21",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.4",
"@types/lodash-es": "^4.17.12", "@types/lodash-es": "^4.17.12",
"@types/node": "^24.10.1", "@types/node": "^25.0.3",
"@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,10 +62,10 @@
"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.0.2", "inquirer": "^13.1.0",
"lodash-es": "^4.17.21", "lodash-es": "^4.17.22",
"nanoid": "^5.1.6", "nanoid": "^5.1.6",
"send": "^1.2.0", "send": "^1.2.1",
"supports-color": "^10.2.2", "supports-color": "^10.2.2",
"ws": "npm:@kevisual/ws" "ws": "npm:@kevisual/ws"
}, },
@@ -75,8 +76,6 @@
"access": "public" "access": "public"
}, },
"dependencies": { "dependencies": {
"@kevisual/hot-api": "^0.0.2",
"@nut-tree-fork/nut-js": "^4.2.6",
"eventemitter3": "^5.0.1", "eventemitter3": "^5.0.1",
"lowdb": "^7.0.1", "lowdb": "^7.0.1",
"lru-cache": "^11.2.4", "lru-cache": "^11.2.4",

View File

@@ -4,10 +4,10 @@ import { spawnSync } from 'node:child_process';
const command = new Command('server') const command = new Command('server')
.description('启动服务') .description('启动服务')
.option('-d, --daemon', '是否以守护进程方式运行') .option('-d, --daemon', '是否以守护进程方式运行')
.option('-n, --name <name>', '服务名称') .option('-n, --name <name>', '服务名称', 'assistant-server')
.option('-p, --port <port>', '服务端口') .option('-p, --port <port>', '服务端口')
.option('-s, --start', '是否启动服务') .option('-s, --start', '是否启动服务')
.option('-i, --home', '是否以home方式运行') .option('-e, --interpreter <interpreter>', '指定使用的解释器', 'bun')
.action((options) => { .action((options) => {
const { port } = options; const { port } = options;
const [_interpreter, execPath] = process.argv; const [_interpreter, execPath] = process.argv;
@@ -24,8 +24,8 @@ const command = new Command('server')
if (port) { if (port) {
shellCommands.push(`-p ${port}`); shellCommands.push(`-p ${port}`);
} }
if (options.home) { if (options.interpreter) {
shellCommands.push('--home'); shellCommands.push(`-e ${options.interpreter}`);
} }
const basename = _interpreter.split('/').pop(); const basename = _interpreter.split('/').pop();

View File

@@ -4,10 +4,17 @@ 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';
let kevisualDir = path.join(homedir(), 'kevisual');
const envKevisualDir = process.env.ASSISTANT_CONFIG_DIR
if (envKevisualDir) {
kevisualDir = envKevisualDir;
console.log('使用环境变量 ASSISTANT_CONFIG_DIR 作为 kevisual 目录:', kevisualDir);
}
/** /**
* 助手配置文件路径, 全局配置文件目录 * 助手配置文件路径, 全局配置文件目录
*/ */
export const configDir = createDir(path.join(homedir(), 'kevisual/assistant-app')); export const configDir = createDir(path.join(kevisualDir, 'assistant-app'));
/** /**
* 助手配置文件初始化 * 助手配置文件初始化
@@ -63,7 +70,7 @@ export type ReturnInitConfigType = ReturnType<typeof initConfig>;
type AuthPermission = { type AuthPermission = {
type?: 'auth-proxy' | 'public' | 'private' | 'project'; type?: 'auth-proxy' | 'public' | 'private' | 'project';
username?: string; // 用户名 username?: string; // 用户名
admin?: Omit<AuthPermission, 'admin'>; admin?: string[];
}; };
export type AssistantConfigData = { export type AssistantConfigData = {
pageApi?: string; // https://kevisual.cn pageApi?: string; // https://kevisual.cn

View File

@@ -1,6 +1,6 @@
export type ProxyInfo = { export type ProxyInfo = {
/** /**
* 代理路径, 比如/root/center, 匹配的路径 * 代理路径, 比如/root/home, 匹配的路径
*/ */
path?: string; path?: string;
/** /**

View File

@@ -2,7 +2,7 @@ import { LocalProxy, LocalProxyOpts } from './index.ts';
import http from 'node:http'; import http from 'node:http';
import { fileProxy } from './proxy/file-proxy.ts'; import { fileProxy } from './proxy/file-proxy.ts';
const localProxy = new LocalProxy({}); const localProxy = new LocalProxy({});
let home = '/root/center'; let home = '/root/home';
export const initProxy = (data: LocalProxyOpts & { home?: string }) => { export const initProxy = (data: LocalProxyOpts & { home?: string }) => {
localProxy.pagesDir = data.pagesDir || ''; localProxy.pagesDir = data.pagesDir || '';
localProxy.watch = data.watch ?? false; localProxy.watch = data.watch ?? false;

View File

@@ -1,6 +1,6 @@
export type ProxyInfo = { export type ProxyInfo = {
/** /**
* 代理路径, 比如/root/center, 匹配的路径 * 代理路径, 比如/root/home, 匹配的路径
*/ */
path?: string; path?: string;
/** /**

View File

@@ -0,0 +1,20 @@
import { app } from '@/app.ts';
// import { Hotkeys } from '@kevisual/hot-api';
import { Hotkeys } from './lib.ts';
import { useContextKey } from '@kevisual/context';
app.route({
path: 'key-sender',
// middleware: ['admin-auth']
}).define(async (ctx) => {
let keys = ctx.query.keys;
if (keys.includes(' ')) {
keys = keys.replace(/\s+/g, '+');
}
const hotKeys: Hotkeys = useContextKey('hotkeys', () => new Hotkeys());
if (typeof keys === 'string') {
await hotKeys.pressHotkey({
hotkey: keys,
});
}
ctx.body = 'ok';
}).addTo(app);

View File

@@ -0,0 +1,89 @@
import { keyboard, Key } from "@nut-tree-fork/nut-js";
/**
* 控制功能部分的案件映射
*/
export const keyMap: Record<string, Key> = {
'ctrl': Key.LeftControl,
'leftctrl': Key.LeftControl,
'rightctrl': Key.RightControl,
'alt': Key.LeftAlt,
'leftalt': Key.LeftAlt,
'rightalt': Key.RightAlt,
'shift': Key.LeftShift,
'leftshift': Key.LeftShift,
'rightshift': Key.RightShift,
'meta': Key.LeftSuper,
'cmd': Key.LeftCmd,
'win': Key.LeftWin,
// 根据操作系统选择 Ctrl 或 Command 键
'ctrlorcommand': process.platform === 'darwin' ? Key.LeftCmd : Key.LeftControl,
};
/**
* 将快捷键字符串转换为 Key 枚举值
* @param hotkey
* @returns
*/
export const parseHotkey = (hotkey: string): Key[] => {
return hotkey
.toLowerCase()
.split('+')
.map(key => {
const trimmed = key.trim().toLowerCase();
// 如果是修饰键,从映射表中获取
if (keyMap[trimmed]) {
return keyMap[trimmed];
}
// 如果是字母,转换为大写并查找对应的 Key
if (trimmed.length === 1 && /[a-z]/.test(trimmed)) {
const upperKey = trimmed.toUpperCase();
return Key[upperKey as keyof typeof Key] as Key;
}
// 其他情况直接查找
return Key[trimmed as keyof typeof Key] as Key;
})
.filter((key): key is Key => key !== undefined);
}
type PressHostKeysOptions = {
hotkey: string;
durationMs?: number;
}
export const pressHotkey = async (opts: PressHostKeysOptions): Promise<boolean> => {
const { hotkey, durationMs = 100 } = opts;
const keys = parseHotkey(hotkey);
console.log('准备模拟按下快捷键:', hotkey);
// 同时按下所有键
await keyboard.pressKey(...keys);
// 短暂延迟后释放
await new Promise(resolve => setTimeout(resolve, durationMs));
// 释放所有键
await keyboard.releaseKey(...keys);
return true
}
/**
* 模拟按下一组快捷键,支持逗号分隔的多个快捷键
* @param opts
* @returns
*/
export const pressHotkeys = async (opts: PressHostKeysOptions): Promise<boolean> => {
let { hotkey } = opts;
hotkey = hotkey.replace(/\s+/g, ''); // 去除所有空格
const hotkeyList = hotkey.split(',').map(hk => hk.trim());
if (hotkeyList.length === 0) {
return await pressHotkey({ ...opts, hotkey });
}
for (const hk of hotkeyList) {
await pressHotkey({ ...opts, hotkey: hk });
// 每个快捷键之间稍作延迟
await new Promise(resolve => setTimeout(resolve, 200));
}
return true;
}
export class Hotkeys {
pressHotkey = pressHotkey;
pressHotkeys = pressHotkeys;
}

View File

@@ -2,8 +2,10 @@ import { app, assistantConfig } from '../app.ts';
import './config/index.ts'; import './config/index.ts';
import './shop-install/index.ts'; import './shop-install/index.ts';
import './ai/index.ts'; import './ai/index.ts';
import './light-code/index.ts'; // TODO:
// import './light-code/index.ts';
import './user/index.ts'; import './user/index.ts';
import './hot-api/key-sender/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';
@@ -12,7 +14,7 @@ export const getTokenUser = async (ctx: any) => {
const res = await query.post({ const res = await query.post({
path: 'user', path: 'user',
key: 'me', key: 'me',
token: ctx.state.token, token: ctx.state.token || ctx.query.token,
}); });
if (res.code !== 200) { if (res.code !== 200) {
return ctx.throw(401, 'not login'); return ctx.throw(401, 'not login');
@@ -24,7 +26,7 @@ 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 });
if (!token) { if (!token) {
return ctx.throw(401, 'not login'); return ctx.throw(401, 'not login');
} }
@@ -45,8 +47,17 @@ const checkAuth = async (ctx: any, isAdmin = false) => {
auth.username = username; auth.username = username;
assistantConfig.setConfig({ auth }); assistantConfig.setConfig({ auth });
} }
if (isAdmin) { if (isAdmin && auth.username) {
if (auth.username && auth.username !== username) { const admins = config.auth?.admin || [];
let isCheckAdmin = false;
const admin = auth.username;
if (admin === username) {
isCheckAdmin = true;
}
if (!isCheckAdmin && admins.length > 0 && admins.includes(username)) {
isCheckAdmin = true;
}
if (!isCheckAdmin) {
return ctx.throw(403, 'not admin user'); return ctx.throw(403, 'not admin user');
} }
} }
@@ -68,6 +79,7 @@ app
description: '管理员鉴权, 获取用户信息,并验证是否为管理员。', description: '管理员鉴权, 获取用户信息,并验证是否为管理员。',
}) })
.define(async (ctx) => { .define(async (ctx) => {
console.log('query', ctx.query);
await checkAuth(ctx, true); await checkAuth(ctx, true);
}) })
.addTo(app); .addTo(app);

View File

@@ -1,10 +1,11 @@
import { app, assistantConfig } from '@/app.ts'; import { app, assistantConfig } from '@/app.ts';
import { AppDownload } from '@/services/app/index.ts'; import { AppDownload } from '@/services/app/index.ts';
import { AssistantApp } from '@/module/assistant/index.ts'; import { AssistantApp } from '@/module/assistant/index.ts';
import { shopDefine } from './define.ts';
app app
.route({ .route({
...shopDefine.get('getRegistry'), path: 'shop',
key: 'get-registry',
description: '获取应用商店注册表信息',
middleware: ['admin-auth'], middleware: ['admin-auth'],
metadata: { metadata: {
admin: true, admin: true,
@@ -19,7 +20,9 @@ app
app app
.route({ .route({
...shopDefine.get('listInstalled'), path: 'shop',
key: 'list-installed',
description: '列出当前已安装的所有应用',
middleware: ['admin-auth'], middleware: ['admin-auth'],
metadata: { metadata: {
admin: true, admin: true,
@@ -35,7 +38,9 @@ app
app app
.route({ .route({
...shopDefine.get('install'), path: 'shop',
key: 'install',
description: '安装指定的应用,可以指定 id、type、force 和 yes 参数',
middleware: ['admin-auth'], middleware: ['admin-auth'],
metadata: { metadata: {
admin: true, admin: true,
@@ -60,7 +65,9 @@ app
app app
.route({ .route({
...shopDefine.get('uninstall'), path: 'shop',
key: 'uninstall',
description: '卸载指定的应用,可以指定 id 和 type 参数',
middleware: ['admin-auth'], middleware: ['admin-auth'],
metadata: { metadata: {
admin: true, admin: true,

View File

@@ -6,29 +6,64 @@ app.route({
description: '管理员用户登录', description: '管理员用户登录',
}).define(async (ctx) => { }).define(async (ctx) => {
const { username, password } = ctx.query; const { username, password } = ctx.query;
const query = assistantConfig.query; const auth = assistantConfig.getConfig().auth || {};
const auth = assistantConfig.getConfig().auth; if (auth && auth.username && auth.username !== username) {
const res = await query.post({
path: 'user',
key: 'login',
data: {
username,
password,
},
})
if (res.code !== 200) {
return ctx.throw(401, 'login failed');
}
const loginUser = res.data.username;
if (auth.username && loginUser !== auth.username) {
return ctx.throw(403, 'login user is not admin user'); return ctx.throw(403, 'login user is not admin user');
} }
if (!auth.username) { // 发起请求,转发客户端 cookie
// 初始管理员账号 const res = await fetch(`${assistantConfig.baseURL}`, {
auth.username = 'admin'; method: 'POST',
assistantConfig.setConfig({ auth }); headers: {
} 'Content-Type': 'application/json',
// 保存配置 },
body: JSON.stringify({
path: 'user',
key: 'login',
username,
password,
}),
});
ctx.body = res.data; // 转发上游服务器返回的所有 set-cookie支持多个 cookie
const setCookieHeaders = res.headers.getSetCookie?.() || [];
if (setCookieHeaders.length > 0) {
// 设置多个 cookie 到原生 http.ServerResponse
ctx.res.setHeader('Set-Cookie', setCookieHeaders);
} else {
// 兼容旧版本,使用 get 方法
const setCookieHeader = res.headers.get('set-cookie');
if (setCookieHeader) {
ctx.res.setHeader('Set-Cookie', setCookieHeader);
}
}
const responseData = await res.json();
console.debug('admin login response', { res: responseData });
if (responseData.code !== 200) {
console.debug('admin login failed', { res: responseData });
return ctx.throw(401, 'login failed');
}
const me = await assistantConfig.query.post({
path: 'user',
key: 'me',
token: responseData.data.token,
})
if (me.code === 200) {
const loginUser = me.data.username;
if (auth.username && loginUser !== auth.username) {
return ctx.throw(403, 'login user is not admin user');
}
if (!auth.username) {
// 初始管理员账号
auth.username = loginUser;
if (!auth.type) {
auth.type = 'public';
}
assistantConfig.setConfig({ auth });
console.log('set first admin user', { username: loginUser });
}
// 保存配置
}
ctx.body = responseData.data;
}).addTo(app); }).addTo(app);

View File

@@ -9,7 +9,7 @@ import path from 'node:path'
import chalk from 'chalk'; import chalk from 'chalk';
import { AssistantApp } from './lib.ts'; import { AssistantApp } from './lib.ts';
import { getBunPath } from './module/get-bun-path.ts'; import { getBunPath } from './module/get-bun-path.ts';
export const runServer = async (port?: number, listenPath = '127.0.0.1') => { export const runServer = async (port: number = 51015, listenPath = '127.0.0.1') => {
let _port: number | undefined; let _port: number | undefined;
if (port) { if (port) {
_port = await getPort({ port }); _port = await getPort({ port });

View File

@@ -10,6 +10,7 @@ export type AssistantInitOptions = {
path?: string; path?: string;
init?: boolean; init?: boolean;
}; };
const randomId = () => Math.random().toString(36).substring(2, 8);
/** /**
* 助手初始化类 * 助手初始化类
* @class AssistantInit * @class AssistantInit
@@ -41,6 +42,7 @@ export class AssistantInit extends AssistantConfig {
this.createEnvConfig(); this.createEnvConfig();
this.createOtherConfig(); this.createOtherConfig();
this.initPnpm(); this.initPnpm();
this.initIgnore();
} }
get query() { get query() {
if (!this.#query) { if (!this.#query) {
@@ -48,6 +50,9 @@ export class AssistantInit extends AssistantConfig {
} }
return this.#query; return this.#query;
} }
get baseURL() {
return `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`;
}
setQuery(query?: Query) { setQuery(query?: Query) {
this.#query = query || new Query({ this.#query = query || new Query({
url: `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`, url: `${this.getConfig()?.pageApi || 'https://kevisual.cn'}/api/router`,
@@ -153,10 +158,46 @@ export class AssistantInit extends AssistantConfig {
create, create,
}; };
} }
initIgnore() {
const gitignorePath = path.join(this.configDir, '.gitignore');
let content = '';
if (checkFileExists(gitignorePath, true)) {
content = fs.readFileSync(gitignorePath, 'utf-8');
}
const ignoreLines = [
'node_modules',
'.DS_Store',
'dist',
'pack-dist',
'cache-file',
'build',
'apps/**/node_modules/',
'pages/**/node_modules/',
'.env',
'!.env*development',
'.pnpm-store',
'.vite',
'.astro'
];
let updated = false;
ignoreLines.forEach((line) => {
if (!content.includes(line)) {
content += `\n${line}`;
updated = true;
}
});
if (updated) {
fs.writeFileSync(gitignorePath, content.trim() + '\n');
console.log(chalk.green('.gitignore 文件更新成功'));
}
}
protected getDefaultInitAssistantConfig() { protected getDefaultInitAssistantConfig() {
const id = randomId();
return { return {
id,
description: '助手配置文件', description: '助手配置文件',
home: '/root/center', docs: "https://kevisual.cn/root/cli-docs/",
home: '/root/home',
proxy: [], proxy: [],
apiProxyList: [], apiProxyList: [],
share: { share: {

View File

@@ -8,10 +8,21 @@ localProxy.initFromAssistantConfig(assistantConfig);
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();
const home = _assistantConfig?.home || '/root/home';
const auth = _assistantConfig?.auth || {};
let noAdmin = !auth.username;
const toSetting = () => {
res.writeHead(302, { Location: `/root/cli/setting/` });
res.end();
return true;
}
const url = new URL(req.url, 'http://localhost'); const url = new URL(req.url, 'http://localhost');
const pathname = decodeURIComponent(url.pathname); const pathname = decodeURIComponent(url.pathname);
if (pathname === '/' && _assistantConfig?.home) { if (pathname === '/') {
res.writeHead(302, { Location: `${_assistantConfig?.home}/` }); if (noAdmin) {
return toSetting();
}
res.writeHead(302, { Location: `${home}/` });
return res.end(); return res.end();
} }
if (pathname.startsWith('/favicon.ico')) { if (pathname.startsWith('/favicon.ico')) {
@@ -20,8 +31,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return; return;
} }
if (pathname.startsWith('/client')) { if (pathname.startsWith('/client')) {
logger.info('url', { url: req.url }); logger.debug('handle by router', { url: req.url });
console.debug('handle by router');
return; return;
} }
// client, api, v1, serve 开头的拦截 // client, api, v1, serve 开头的拦截
@@ -42,6 +52,9 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
res.end('Not Found Proxy'); res.end('Not Found Proxy');
return; return;
} }
if (noAdmin) {
return toSetting();
}
if (_app && urls.length === 3) { if (_app && urls.length === 3) {
// 重定向到 // 重定向到
res.writeHead(302, { Location: `${req.url}/` }); res.writeHead(302, { Location: `${req.url}/` });
@@ -58,7 +71,7 @@ export const proxyRoute = async (req: http.IncomingMessage, res: http.ServerResp
return res.end(`Not Found [${proxyApi.path}] rootPath`); return res.end(`Not Found [${proxyApi.path}] rootPath`);
} }
return fileProxy(req, res, { return fileProxy(req, res, {
path: proxyApi.path, // 代理路径, 比如/root/center path: proxyApi.path, // 代理路径, 比如/root/home
rootPath: proxyApi.rootPath, rootPath: proxyApi.rootPath,
...proxyApi, ...proxyApi,
indexPath: _indexPath, // 首页路径 indexPath: _indexPath, // 首页路径

0
cli-center/.env Normal file
View File

6
cli-center/.gitignore vendored Normal file
View File

@@ -0,0 +1,6 @@
node_modules
.DS_Store
.astro
dist

View File

@@ -0,0 +1,43 @@
import { defineConfig } from 'astro/config';
import mdx from '@astrojs/mdx';
import react from '@astrojs/react';
import sitemap from '@astrojs/sitemap';
import pkgs from './package.json';
import tailwindcss from '@tailwindcss/vite';
import dotenv from 'dotenv';
// import vue from '@astrojs/vue';
dotenv.config();
const isDev = process.env.NODE_ENV === 'development';
let target = process.env.VITE_API_URL || 'http://localhost:51015';
const apiProxy = { target: target, changeOrigin: true, ws: true, rewriteWsOrigin: true, secure: false, cookieDomainRewrite: 'localhost' };
let proxy = {
'/root/': apiProxy,
'/api': apiProxy,
'/client': apiProxy,
};
const basename = isDev ? undefined : `${pkgs.basename}`;
export default defineConfig({
base: basename,
integrations: [
mdx(),
react(), //
// vue(),
// sitemap(), // sitemap must be site has a domain
],
vite: {
plugins: [tailwindcss()],
define: {
BASE_NAME: JSON.stringify(basename || ''),
},
server: {
port: 7008,
host: '0.0.0.0',
allowedHosts: true,
proxy,
},
},
});

View File

@@ -0,0 +1,22 @@
{
"$schema": "https://ui.shadcn.com/schema.json",
"style": "new-york",
"rsc": false,
"tsx": true,
"tailwind": {
"config": "",
"css": "src/styles/global.css",
"baseColor": "neutral",
"cssVariables": true,
"prefix": ""
},
"iconLibrary": "lucide",
"aliases": {
"components": "@/components",
"utils": "@/lib/utils",
"ui": "@/components/ui",
"lib": "@/lib",
"hooks": "@/hooks"
},
"registries": {}
}

25
cli-center/kevisual.json Normal file
View File

@@ -0,0 +1,25 @@
{
"metadata": {
"name": "kevisual",
"share": "public"
},
"registry": "https://kevisual.cn/root/ai/kevisual/frontend/simple-astro-template",
"clone": {
".": {
"enabled": true
}
},
"syncd": [
{
"files": [
"**/*"
],
"registry": ""
}
],
"sync": {
".gitignore": {
"url": "/gitignore.txt"
}
}
}

69
cli-center/package.json Normal file
View File

@@ -0,0 +1,69 @@
{
"name": "@kevisual/cli",
"version": "0.0.3",
"description": "",
"main": "index.js",
"basename": "/root/cli",
"scripts": {
"dev": "astro dev",
"build": "astro build",
"preview": "astro preview",
"pub": "envision deploy ./dist -k cli -v 0.0.3 -u",
"slide:dev": "slidev --open slides/index.md",
"slide:build": "slidev build slides/index.md --base /root/cli-slide/",
"slide:pub": "envision deploy ./slides/dist -k cli-slide -v 0.0.3 -u",
"ui": "pnpm dlx shadcn@latest add "
},
"keywords": [],
"author": "abearxiong <xiongxiao@xiongxiao.me> (https://www.xiongxiao.me)",
"license": "MIT",
"type": "module",
"dependencies": {
"@astrojs/mdx": "^4.3.13",
"@astrojs/react": "^4.4.2",
"@astrojs/sitemap": "^3.6.0",
"@astrojs/vue": "^5.1.3",
"@kevisual/context": "^0.0.4",
"@kevisual/query": "^0.0.32",
"@kevisual/query-login": "^0.0.7",
"@kevisual/registry": "^0.0.1",
"@radix-ui/react-slot": "^1.2.4",
"@tailwindcss/vite": "^4.1.18",
"@uiw/react-md-editor": "^4.0.11",
"antd": "^6.1.1",
"astro": "^5.16.6",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"dayjs": "^1.11.19",
"es-toolkit": "^1.43.0",
"github-markdown-css": "^5.8.1",
"highlight.js": "^11.11.1",
"lucide-react": "^0.561.0",
"marked": "^17.0.1",
"marked-highlight": "^2.2.3",
"nanoid": "^5.1.6",
"react": "^19.2.3",
"react-dom": "^19.2.3",
"react-toastify": "^11.0.5",
"tailwind-merge": "^3.4.0",
"vue": "^3.5.25",
"zustand": "^5.0.9"
},
"publishConfig": {
"access": "public"
},
"devDependencies": {
"@kevisual/types": "^0.0.10",
"@types/react": "^19.2.7",
"@types/react-dom": "^19.2.3",
"dotenv": "^17.2.3",
"tailwindcss": "^4.1.18",
"tw-animate-css": "^1.4.0"
},
"packageManager": "pnpm@10.26.0",
"onlyBuiltDependencies": [
"@tailwindcss/oxide",
"esbuild",
"sharp"
]
}

6922
cli-center/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps({
count: {
default: 0,
},
})
const counter = ref(props.count)
</script>
<template>
<div class="flex w-min border border-main rounded-md">
<button
class="border-r border-main p-2 font-mono outline-none hover:bg-gray-400 hover:bg-opacity-20"
@click="counter -= 1"
>
-
</button>
<span class="m-auto p-2">{{ counter }}</span>
<button
class="border-l border-main p-2 font-mono outline-none hover:bg-gray-400 hover:bg-opacity-20"
@click="counter += 1"
>
+
</button>
</div>
</template>

View File

@@ -0,0 +1,611 @@
# Welcome to Slidev
Presentation slides for developers
<div @click="$slidev.nav.next" class="mt-12 py-1" hover:bg="white op-10">
Press Space for next page <carbon:arrow-right />
</div>
<div class="abs-br m-6 text-xl">
<button @click="$slidev.nav.openInEditor()" title="Open in Editor" class="slidev-icon-btn">
<carbon:edit />
</button>
<a href="https://github.com/slidevjs/slidev" target="_blank" class="slidev-icon-btn">
<carbon:logo-github />
</a>
</div>
<!--
The last comment block of each slide will be treated as slide notes. It will be visible and editable in Presenter Mode along with the slide. [Read more in the docs](https://sli.dev/guide/syntax.html#notes)
-->
---
transition: fade-out
---
# What is Slidev?
Slidev is a slides maker and presenter designed for developers, consist of the following features
- 📝 **Text-based** - focus on the content with Markdown, and then style them later
- 🎨 **Themable** - themes can be shared and re-used as npm packages
- 🧑‍💻 **Developer Friendly** - code highlighting, live coding with autocompletion
- 🤹 **Interactive** - embed Vue components to enhance your expressions
- 🎥 **Recording** - built-in recording and camera view
- 📤 **Portable** - export to PDF, PPTX, PNGs, or even a hostable SPA
- 🛠 **Hackable** - virtually anything that's possible on a webpage is possible in Slidev
<br>
<br>
Read more about [Why Slidev?](https://sli.dev/guide/why)
<!--
You can have `style` tag in markdown to override the style for the current page.
Learn more: https://sli.dev/features/slide-scope-style
-->
<style>
h1 {
background-color: #2B90B6;
background-image: linear-gradient(45deg, #4EC5D4 10%, #146b8c 20%);
background-size: 100%;
-webkit-background-clip: text;
-moz-background-clip: text;
-webkit-text-fill-color: transparent;
-moz-text-fill-color: transparent;
}
</style>
<!--
Here is another comment.
-->
---
transition: slide-up
level: 2
---
# Navigation
Hover on the bottom-left corner to see the navigation's controls panel, [learn more](https://sli.dev/guide/ui#navigation-bar)
## Keyboard Shortcuts
| | |
| --------------------------------------------------- | --------------------------- |
| <kbd>right</kbd> / <kbd>space</kbd> | next animation or slide |
| <kbd>left</kbd> / <kbd>shift</kbd><kbd>space</kbd> | previous animation or slide |
| <kbd>up</kbd> | previous slide |
| <kbd>down</kbd> | next slide |
<!-- https://sli.dev/guide/animations.html#click-animation -->
<img
v-click
class="absolute -bottom-9 -left-7 w-80 opacity-50"
src="https://sli.dev/assets/arrow-bottom-left.svg"
alt=""
/>
<p v-after class="absolute bottom-23 left-45 opacity-30 transform -rotate-10">Here!</p>
---
layout: two-cols
layoutClass: gap-16
---
# Table of contents
You can use the `Toc` component to generate a table of contents for your slides:
```html
<Toc minDepth="1" maxDepth="1" />
```
The title will be inferred from your slide content, or you can override it with `title` and `level` in your frontmatter.
::right::
<Toc text-sm minDepth="1" maxDepth="2" />
---
layout: image-right
image: https://cover.sli.dev
---
# Code
Use code snippets and get the highlighting directly, and even types hover!
```ts [filename-example.ts] {all|4|6|6-7|9|all} twoslash
// TwoSlash enables TypeScript hover information
// and errors in markdown code blocks
// More at https://shiki.style/packages/twoslash
import { computed, ref } from 'vue'
const count = ref(0)
const doubled = computed(() => count.value * 2)
doubled.value = 2
```
<arrow v-click="[4, 5]" x1="350" y1="310" x2="195" y2="342" color="#953" width="2" arrowSize="1" />
<!-- This allow you to embed external code blocks -->
<!-- <<< @/snippets/external.ts#snippet -->
<!-- Footer -->
[Learn more](https://sli.dev/features/line-highlighting)
<!-- Inline style -->
<style>
.footnotes-sep {
@apply mt-5 opacity-10;
}
.footnotes {
@apply text-sm opacity-75;
}
.footnote-backref {
display: none;
}
</style>
<!--
Notes can also sync with clicks
[click] This will be highlighted after the first click
[click] Highlighted with `count = ref(0)`
[click:3] Last click (skip two clicks)
-->
---
level: 2
---
# Shiki Magic Move
Powered by [shiki-magic-move](https://shiki-magic-move.netlify.app/), Slidev supports animations across multiple code snippets.
Add multiple code blocks and wrap them with <code>````md magic-move</code> (four backticks) to enable the magic move. For example:
````md magic-move {lines: true}
```ts {*|2|*}
// step 1
const author = reactive({
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
})
```
```ts {*|1-2|3-4|3-4,8}
// step 2
export default {
data() {
return {
author: {
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
}
}
}
}
```
```ts
// step 3
export default {
data: () => ({
author: {
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
}
})
}
```
Non-code blocks are ignored.
```vue
<!-- step 4 -->
<script setup>
const author = {
name: 'John Doe',
books: [
'Vue 2 - Advanced Guide',
'Vue 3 - Basic Guide',
'Vue 4 - The Mystery'
]
}
</script>
```
````
---
# Components
<div grid="~ cols-2 gap-4">
<div>
You can use Vue components directly inside your slides.
We have provided a few built-in components like `<Tweet/>` and `<Youtube/>` that you can use directly. And adding your custom components is also super easy.
```html
<Counter :count="10" />
```
<!-- ../components/Counter.vue -->
<Counter :count="10" m="t-4" />
Check out [the guides](https://sli.dev/builtin/components.html) for more.
</div>
<div>
```html
<Tweet id="1390115482657726468" />
```
<Tweet id="1390115482657726468" scale="0.65" />
</div>
</div>
<!--
Presenter note with **bold**, *italic*, and ~~striked~~ text.
Also, HTML elements are valid:
<div class="flex w-full">
<span style="flex-grow: 1;">Left content</span>
<span>Right content</span>
</div>
-->
---
class: px-20
---
# Themes
Slidev comes with powerful theming support. Themes can provide styles, layouts, components, or even configurations for tools. Switching between themes by just **one edit** in your frontmatter:
<div grid="~ cols-2 gap-2" m="t-2">
```yaml
---
theme: default
---
```
```yaml
---
theme: seriph
---
```
<img border="rounded" src="https://github.com/slidevjs/themes/blob/main/screenshots/theme-default/01.png?raw=true" alt="">
<img border="rounded" src="https://github.com/slidevjs/themes/blob/main/screenshots/theme-seriph/01.png?raw=true" alt="">
</div>
Read more about [How to use a theme](https://sli.dev/guide/theme-addon#use-theme) and
check out the [Awesome Themes Gallery](https://sli.dev/resources/theme-gallery).
---
# Clicks Animations
You can add `v-click` to elements to add a click animation.
<div v-click>
This shows up when you click the slide:
```html
<div v-click>This shows up when you click the slide.</div>
```
</div>
<br>
<v-click>
The <span v-mark.red="3"><code>v-mark</code> directive</span>
also allows you to add
<span v-mark.circle.orange="4">inline marks</span>
, powered by [Rough Notation](https://roughnotation.com/):
```html
<span v-mark.underline.orange>inline markers</span>
```
</v-click>
<div mt-20 v-click>
[Learn more](https://sli.dev/guide/animations#click-animation)
</div>
---
# Motions
Motion animations are powered by [@vueuse/motion](https://motion.vueuse.org/), triggered by `v-motion` directive.
```html
<div
v-motion
:initial="{ x: -80 }"
:enter="{ x: 0 }"
:click-3="{ x: 80 }"
:leave="{ x: 1000 }"
>
Slidev
</div>
```
<div class="w-60 relative">
<div class="relative w-40 h-40">
<img
v-motion
:initial="{ x: 800, y: -100, scale: 1.5, rotate: -50 }"
:enter="final"
class="absolute inset-0"
src="https://sli.dev/logo-square.png"
alt=""
/>
<img
v-motion
:initial="{ y: 500, x: -100, scale: 2 }"
:enter="final"
class="absolute inset-0"
src="https://sli.dev/logo-circle.png"
alt=""
/>
<img
v-motion
:initial="{ x: 600, y: 400, scale: 2, rotate: 100 }"
:enter="final"
class="absolute inset-0"
src="https://sli.dev/logo-triangle.png"
alt=""
/>
</div>
<div
class="text-5xl absolute top-14 left-40 text-[#2B90B6] -z-1"
v-motion
:initial="{ x: -80, opacity: 0}"
:enter="{ x: 0, opacity: 1, transition: { delay: 2000, duration: 1000 } }">
Slidev
</div>
</div>
<!-- vue script setup scripts can be directly used in markdown, and will only affects current page -->
<script setup lang="ts">
const final = {
x: 0,
y: 0,
rotate: 0,
scale: 1,
transition: {
type: 'spring',
damping: 10,
stiffness: 20,
mass: 2
}
}
</script>
<div
v-motion
:initial="{ x:35, y: 30, opacity: 0}"
:enter="{ y: 0, opacity: 1, transition: { delay: 3500 } }">
[Learn more](https://sli.dev/guide/animations.html#motion)
</div>
---
# LaTeX
LaTeX is supported out-of-box. Powered by [KaTeX](https://katex.org/).
<div h-3 />
Inline $\sqrt{3x-1}+(1+x)^2$
Block
$$ {1|3|all}
\begin{aligned}
\nabla \cdot \vec{E} &= \frac{\rho}{\varepsilon_0} \\
\nabla \cdot \vec{B} &= 0 \\
\nabla \times \vec{E} &= -\frac{\partial\vec{B}}{\partial t} \\
\nabla \times \vec{B} &= \mu_0\vec{J} + \mu_0\varepsilon_0\frac{\partial\vec{E}}{\partial t}
\end{aligned}
$$
[Learn more](https://sli.dev/features/latex)
---
# Diagrams
You can create diagrams / graphs from textual descriptions, directly in your Markdown.
<div class="grid grid-cols-4 gap-5 pt-4 -mb-6">
```mermaid {scale: 0.5, alt: 'A simple sequence diagram'}
sequenceDiagram
Alice->John: Hello John, how are you?
Note over Alice,John: A typical interaction
```
```mermaid {theme: 'neutral', scale: 0.8}
graph TD
B[Text] --> C{Decision}
C -->|One| D[Result 1]
C -->|Two| E[Result 2]
```
```mermaid
mindmap
root((mindmap))
Origins
Long history
::icon(fa fa-book)
Popularisation
British popular psychology author Tony Buzan
Research
On effectiveness<br/>and features
On Automatic creation
Uses
Creative techniques
Strategic planning
Argument mapping
Tools
Pen and paper
Mermaid
```
```plantuml {scale: 0.7}
@startuml
package "Some Group" {
HTTP - [First Component]
[Another Component]
}
node "Other Groups" {
FTP - [Second Component]
[First Component] --> FTP
}
cloud {
[Example 1]
}
database "MySql" {
folder "This is my folder" {
[Folder 3]
}
frame "Foo" {
[Frame 4]
}
}
[Another Component] --> [Example 1]
[Example 1] --> [Folder 3]
[Folder 3] --> [Frame 4]
@enduml
```
</div>
Learn more: [Mermaid Diagrams](https://sli.dev/features/mermaid) and [PlantUML Diagrams](https://sli.dev/features/plantuml)
---
foo: bar
dragPos:
square: 691,32,167,_,-16
---
# Draggable Elements
Double-click on the draggable elements to edit their positions.
<br>
###### Directive Usage
```md
<img v-drag="'square'" src="https://sli.dev/logo.png">
```
<br>
###### Component Usage
```md
<v-drag text-3xl>
<div class="i-carbon:arrow-up" />
Use the `v-drag` component to have a draggable container!
</v-drag>
```
<v-drag pos="640,212,261,_,-15">
<div text-center text-3xl border border-main rounded>
Double-click me!
</div>
</v-drag>
<img v-drag="'square'" src="https://sli.dev/logo.png">
###### Draggable Arrow
```md
<v-drag-arrow two-way />
```
<v-drag-arrow pos="360,319,253,46" two-way op70 />
---
src: ./pages/imported-slides.md
hide: false
---
---
# Monaco Editor
Slidev provides built-in Monaco Editor support.
Add `{monaco}` to the code block to turn it into an editor:
```ts {monaco}
import { ref } from 'vue'
import { emptyArray } from './external'
const arr = ref(emptyArray(10))
```
Use `{monaco-run}` to create an editor that can execute the code directly in the slide:
```ts {monaco-run}
import { version } from 'vue'
import { emptyArray, sayHello } from './external'
sayHello()
console.log(`vue ${version}`)
console.log(emptyArray<number>(10).reduce(fib => [...fib, fib.at(-1)! + fib.at(-2)!], [1, 1]))
```
---
layout: center
class: text-center
---
# Learn More
[Documentation](https://sli.dev) · [GitHub](https://github.com/slidevjs/slidev) · [Showcases](https://sli.dev/resources/showcases)
<PoweredBySlidev mt-10 />

View File

@@ -0,0 +1,28 @@
---
theme: default
# random image from a curated Unsplash collection by Anthony
background: https://cover.sli.dev
# 介绍文档: https://sli.dev
title: Welcome to Slidev
info: |
## 关于Slidev的介绍
演示稿
class: text-center
# https://sli.dev/features/drawing
drawings:
persist: false
# slide transition: https://sli.dev/guide/animations.html#slide-transitions
transition: slide-left
# enable MDC Syntax: https://sli.dev/features/mdc
mdc: true
htmlAttrs:
dir: ltr
lang: zh-CN
# duration of the presentation
duration: 35min
---
# slide 是一个 所见即所得的幻灯片制作工具
---
src: ./demos/contents.md
hide: false
---

View File

@@ -0,0 +1,10 @@
---
title: '例子'
---
# 常用语法结构
---
---
# 第二个

View File

@@ -0,0 +1,41 @@
import { wrapBasename } from "@/modules/basename"
export const Footer = () => {
const links = [
{
href: wrapBasename('/'),
label: '主页',
},
{
href: wrapBasename('/docs'),
label: '文档',
},
]
return (
<footer className="fixed bottom-0 w-full bg-white border-t border-gray-200 shadow-lg">
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-4">
{/* 链接区域 */}
<nav className="flex flex-wrap justify-center items-center gap-2 sm:gap-4 mb-3">
{links.map((link) => (
<a
key={link.href}
href={link.href}
className="relative px-4 py-2 text-sm sm:text-base font-medium text-gray-600 hover:text-blue-600 transition-all duration-300 ease-in-out
before:absolute before:bottom-0 before:left-0 before:w-0 before:h-0.5 before:bg-blue-600 before:transition-all before:duration-300
hover:before:w-full active:scale-95"
>
{link.label}
</a>
))}
</nav>
{/* 版权信息 */}
<div className="text-center text-xs sm:text-sm text-gray-500">
&copy; 2025 Daily Question
</div>
</div>
</footer>
)
}

View File

@@ -0,0 +1,62 @@
import { useMemo } from "react";
export type MenuProps = {
items: MenuItem[];
basename?: string;
};
export type MenuItem = {
id: string;
data: {
title: string;
tags: string[];
hideInMenu?: boolean;
}
}
export const Menu = (props: MenuProps) => {
const { items, basename = '' } = props;
const list = useMemo(() => {
return items.filter(item => !item.data?.hideInMenu).sort((a, b) => {
return (a.id).localeCompare(b.id)
});
}, [items]);
if (list.length === 0) {
return null;
}
const currentPath = typeof window !== 'undefined' ? window.location.pathname : '';
const isActive = (itemId: string) => {
return currentPath.includes(`/docs/${itemId}`);
};
return (
<nav className='flex-1 overflow-y-auto scrollbar bg-white border border-gray-200 rounded-lg shadow-sm'>
<div className="sticky top-0 bg-white border-b border-gray-200 px-4 py-3 rounded-t-lg">
<h2 className="text-sm font-semibold text-black"></h2>
</div>
<div className="p-2 space-y-0.5">
{list.map(item => (
<a
key={item.id}
href={`${basename}/docs/${item.id}/`}
className={`group block rounded-md transition-all duration-200 ease-in-out border-l-3 ${
isActive(item.id)
? 'bg-gray-100 border-l-4 border-black shadow-sm'
: 'border-transparent hover:bg-gray-50 hover:border-l-4 hover:border-gray-400'
}`}
>
<div className="px-3 py-2.5">
<h3 className={`text-sm font-medium transition-colors ${
isActive(item.id)
? 'text-black font-semibold'
: 'text-gray-700 group-hover:text-black'
}`}>
{item.data?.title}
</h3>
</div>
</a>
))}
</div>
</nav>
);
}

View File

@@ -0,0 +1,78 @@
import { use, useEffect, useState } from "react";
import { Layout } from "./layout"
import { useStore } from "./store";
export const FirstLogin = () => {
const store = useStore();
const [username, setUsername] = useState('')
const [password, setPassword] = useState('')
useEffect(() => {
store.initAdmin();
}, []);
useEffect(() => {
if (store.username) {
setUsername(store.username);
}
}, [store.username]);
const onClickLogin = async () => {
await store.login(username, password);
}
return (
<Layout>
<div className='flex items-center justify-center px-4'>
<div className='w-full max-w-md space-y-6'>
<h1 className='text-2xl font-bold text-black text-center'></h1>
<blockquote className="text-gray-500 p-4 mt-4">
<a className="text-gray-700 mx-1" href="https://kevisual.cn">kevisual</a> "全局设置"
<a className="text-gray-700 mx-1 underline" href="../docs/01-login-first/"></a>
</blockquote>
<form className='space-y-4 mt-8'>
<div>
<label htmlFor='account' className='block text-sm font-medium text-gray-900 mb-2'> </label>
<input
type='text'
id='account'
onChange={e => setUsername(e.target.value)}
value={username}
placeholder='请输入账号'
className='w-full px-4 py-2 border-2 border-black bg-white text-black placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent'
/>
</div>
<div>
<label htmlFor='password' className='block text-sm font-medium text-gray-900 mb-2'> </label>
<input
type='password'
id='password'
onChange={e => setPassword(e.target.value)}
value={password}
placeholder='请输入密码'
className='w-full px-4 py-2 border-2 border-black bg-white text-black placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black focus:border-transparent'
/>
</div>
<button
type='button'
onClick={onClickLogin}
className='w-full px-4 py-2 bg-black text-white font-medium hover:bg-gray-800 active:bg-gray-900 transition-colors focus:outline-none focus:ring-2 focus:ring-black focus:ring-offset-2'>
</button>
</form>
</div>
</div>
</Layout>
);
}
export const Config = () => {
const store = useStore();
useEffect(() => {
store.initAdmin();
}, []);
return (
<Layout>
<div className="p-4">
<pre className="bg-gray-100 p-4 rounded-lg overflow-x-auto">
{JSON.stringify(store.config, null, 2)}
</pre>
</div>
</Layout>
)
}

View File

@@ -0,0 +1,22 @@
import { Nav } from './nav'
export const Layout = (props) => {
return (
<div className="flex min-h-screen bg-white">
<div className="left-nav w-64 bg-white border-r border-gray-100">
<div className="p-4">
<h2 className="text-xl font-bold text-black"></h2>
</div>
<div className="p-4">
<Nav />
</div>
</div>
<div className="main flex-1 flex flex-col">
<div className="main-header bg-white px-6 py-4 border-b border-gray-100">
<h1 className="text-lg font-semibold text-black"></h1>
</div>
<main className="flex-1 p-6 bg-gray-50">{props.children}</main>
</div>
</div>
)
}

View File

@@ -0,0 +1,34 @@
import { wrapBasename } from "../../modules/basename";
import { clsx } from 'clsx';
export const Nav = () => {
const currentPath = typeof window !== 'undefined' ? window.location.pathname : ''
const navItems = [
{ name: '管理员设置', path: wrapBasename('/setting/') },
{ name: '全局设置', path: wrapBasename('/setting/all/') },
];
const isActive = (path: string) => {
return currentPath === path
}
return (
<nav className="space-y-2">
{navItems.map((item) => (
<a
key={item.path}
href={item.path}
className={clsx(
"block px-4 py-2 rounded transition-colors border-l-4",
isActive(item.path)
? "bg-gray-100 text-black border-black font-medium"
: "text-gray-700 border-transparent hover:bg-gray-50 hover:border-gray-300"
)}
>
{item.name}
</a>
))}
</nav>
)
}

View File

@@ -0,0 +1,42 @@
import { query, queryLogin } from '@/modules/query';
import { create } from 'zustand';
type SettingState = {
username?: string;
initAdmin: () => any;
login: (username: string, password: string) => any;
config?: any;
}
export const useStore = create<SettingState>((set => ({
username: undefined,
config: undefined,
initAdmin: async () => {
const res = await query.post({
path: 'config'
})
console.log('initAdmin', res);
if (res.code === 200) {
const auth = res.data.auth || {}
if (auth.username) {
set({ username: auth.username });
}
set({ config: res.data });
}
},
login: async (username: string, password: string) => {
const res = await query.post({
path: 'admin',
key: 'login',
username,
password
});
if (res.code === 200) {
set({ username });
const setToken = await queryLogin.setLoginToken(res.data)
console.log('setToken', setToken);
}
console.log('login res', res);
return res;
}
})));

View File

@@ -0,0 +1,70 @@
import { cn } from '@/lib/utils';
import { useEffect, useState } from 'react';
import { Marked } from 'marked';
import hljs from 'highlight.js';
import { markedHighlight } from 'marked-highlight';
const markedAndHighlight = new Marked(
markedHighlight({
emptyLangClass: 'hljs',
langPrefix: 'hljs language-',
highlight(code, lang, info) {
const language = hljs.getLanguage(lang) ? lang : 'plaintext';
return hljs.highlight(code, { language }).value;
},
}),
);
export const md2html = async (md: string) => {
const html = markedAndHighlight.parse(md);
return html;
};
export const clearMeta = (markdown?: string) => {
if (!markdown) return '';
// Remove YAML front matter if present
const yamlRegex = /^---\n[\s\S]*?\n---\n/;
return markdown.replace(yamlRegex, '');
};
type Props = {
children?: React.ReactNode;
className?: string;
style?: React.CSSProperties;
content?: string; // Optional content prop for markdown text
[key: string]: any; // Allow any additional props
};
export const MarkdownPreview = (props: Props) => {
return (
<div
className={cn(
'markdown-body scrollbar h-full overflow-auto w-full px-6 py-2 max-w-[800px] border my-4 flex flex-col justify-self-center rounded-md shadow-md',
props.className,
)}
style={props.style}>
{props.children ? <WrapperText>{props.children}</WrapperText> : <MarkdownPreviewWrapper content={clearMeta(props.content)} />}
</div>
);
};
export const WrapperText = (props: { children?: React.ReactNode; html?: string }) => {
if (props.html) {
return <div className='w-full' dangerouslySetInnerHTML={{ __html: props.html }} />;
}
return <div className='w-full h-full'>{props.children}</div>;
};
export const MarkdownPreviewWrapper = (props: Props) => {
const [html, setHtml] = useState<string>('');
useEffect(() => {
init();
}, [props.content]);
const init = async () => {
if (props.content) {
const htmlContent = await md2html(props.content);
setHtml(htmlContent);
} else {
setHtml('');
}
};
return <WrapperText html={html} />;
};

View File

@@ -0,0 +1,47 @@
---
import '../styles/global.css';
import '../styles/theme.css';
export interface Props {
title?: string;
description?: string;
lang?: string;
charset?: string;
}
const { title = 'Light Code', description = 'A lightweight code editor', lang = 'zh-CN', charset = 'UTF-8' } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset={charset} />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='description' content={description} />
<title>{title}</title>
<!-- 样式 -->
<slot name='head' />
</head>
<body>
<slot />
<!-- 脚本 -->
<slot name='scripts' />
</body>
</html>
<style>
html {
font-family: system-ui, sans-serif;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
}
* {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,60 @@
import * as React from "react"
import { Slot } from "@radix-ui/react-slot"
import { cva, type VariantProps } from "class-variance-authority"
import { cn } from "@/lib/utils"
const buttonVariants = cva(
"inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-all disabled:pointer-events-none disabled:opacity-50 [&_svg]:pointer-events-none [&_svg:not([class*='size-'])]:size-4 shrink-0 [&_svg]:shrink-0 outline-none focus-visible:border-ring focus-visible:ring-ring/50 focus-visible:ring-[3px] aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive",
{
variants: {
variant: {
default: "bg-primary text-primary-foreground hover:bg-primary/90",
destructive:
"bg-destructive text-white hover:bg-destructive/90 focus-visible:ring-destructive/20 dark:focus-visible:ring-destructive/40 dark:bg-destructive/60",
outline:
"border bg-background shadow-xs hover:bg-accent hover:text-accent-foreground dark:bg-input/30 dark:border-input dark:hover:bg-input/50",
secondary:
"bg-secondary text-secondary-foreground hover:bg-secondary/80",
ghost:
"hover:bg-accent hover:text-accent-foreground dark:hover:bg-accent/50",
link: "text-primary underline-offset-4 hover:underline",
},
size: {
default: "h-9 px-4 py-2 has-[>svg]:px-3",
sm: "h-8 rounded-md gap-1.5 px-3 has-[>svg]:px-2.5",
lg: "h-10 rounded-md px-6 has-[>svg]:px-4",
icon: "size-9",
"icon-sm": "size-8",
"icon-lg": "size-10",
},
},
defaultVariants: {
variant: "default",
size: "default",
},
}
)
function Button({
className,
variant,
size,
asChild = false,
...props
}: React.ComponentProps<"button"> &
VariantProps<typeof buttonVariants> & {
asChild?: boolean
}) {
const Comp = asChild ? Slot : "button"
return (
<Comp
data-slot="button"
className={cn(buttonVariants({ variant, size, className }))}
{...props}
/>
)
}
export { Button, buttonVariants }

View File

@@ -0,0 +1,29 @@
<script setup lang="ts">
import { ref } from 'vue'
const props = defineProps({
count: {
default: 0,
},
})
const counter = ref(props.count)
</script>
<template>
<div class="flex w-min border border-main rounded-md">
<button
class="border-r border-main p-2 font-mono outline-none hover:bg-gray-400 hover:bg-opacity-20"
@click="counter -= 1"
>
-
</button>
<span class="m-auto p-2">{{ counter }}</span>
<button
class="border-l border-main p-2 font-mono outline-none hover:bg-gray-400 hover:bg-opacity-20"
@click="counter += 1"
>
+
</button>
</div>
</template>

View File

@@ -0,0 +1,24 @@
// @ts-ignore
import { defineCollection, z } from 'astro:content';
import { glob, file } from 'astro/loaders'; // 不适用于旧版 API
const docs = defineCollection({
loader: glob({ pattern: '**/[^_]*.md', base: './src/data/docs' }),
schema: z.object({
title: z.string().optional(),
description: z.string().optional(),
tags: z.array(z.string()).optional(),
// pubDate: z.coerce.date(),
createdAt: z.coerce.date().optional(),
updatedAt: z.coerce.date().optional(),
showMenu: z.boolean().optional().default(true),
/**
* 在侧边栏隐藏该文档
*/
hideInMenu: z.boolean().optional().default(false),
order: z.number().optional().default(0),
}),
});
export const collections = { docs };

View File

@@ -0,0 +1,15 @@
---
title: '介绍'
tags: ['introduce']
createdAt: '2025-12-18 20:00:00'
---
# @kevisual/cli 目的?
对于每一个人来说,搭建知识库,自动化,数据可视化,都是一件非常复杂的事情。但是,我们希望通过 `@kevisual/cli` 让这件事情变得非常简单。
AI 时代,人人都可以成为开发者,每一个人的灵感和想法都能变成代码,而把代码运行起来,把自己的知识库运行起来,也应该有一个体系化的解决方案。而这就是基于这个初衷。
## 数据代理
https://kevisual.cn 只作为一个用户管理的平台而每一个人的设备都需要运行一个cli而这个cli负责管理所有的程序的运行但是通过公网去访问到自己的服务。

View File

@@ -0,0 +1,13 @@
---
title: '第一次登录'
tags: ['setting']
createdAt: '2025-12-18 20:00:00'
---
## 第一次登录设备
每一台设备需要有自己的管理员信息第一次登录的时候用kevisual.cn 的账号密码登录,就会把这个账号设置为这个设备的管理员账号。
下一次修改管理员配置,必须管理员才能操作。
或者自己在 `kevisual/assistant-app/assistant-config.json` 里面修改管理员账号.

View File

@@ -0,0 +1,24 @@
---
import '../styles/global.css';
export interface Props {
title?: string;
description?: string;
}
import 'github-markdown-css/github-markdown-light.css';
import 'highlight.js/styles/github-dark.css';
const { title, description } = Astro.props;
---
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>{title || '文档'}</title>
</head>
<body class="bg-gray-50 min-h-screen">
<div class="container mx-auto px-4 py-8 max-w-4xl">
<article class="markdown-body bg-white rounded-lg shadow-lg p-8">
<slot />
</article>
</div>
</body>
</html>

View File

@@ -0,0 +1,47 @@
---
import '../styles/global.css';
import '../styles/theme.css';
export interface Props {
title?: string;
description?: string;
lang?: string;
charset?: string;
}
const { title = 'Light Code', description = 'A lightweight code editor', lang = 'zh-CN', charset = 'UTF-8' } = Astro.props;
---
<!doctype html>
<html lang={lang}>
<head>
<meta charset={charset} />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<meta name='description' content={description} />
<title>{title}</title>
<!-- 样式 -->
<slot name='head' />
</head>
<body>
<slot />
<!-- 脚本 -->
<slot name='scripts' />
</body>
</html>
<style>
html {
font-family: system-ui, sans-serif;
}
html,
body {
margin: 0;
padding: 0;
min-height: 100vh;
}
* {
box-sizing: border-box;
}
</style>

View File

@@ -0,0 +1,4 @@
---
---
分页组件

View File

@@ -0,0 +1,73 @@
---
export interface Props {
children: any;
}
import '../styles/global.css';
import '../styles/theme.css';
import 'github-markdown-css/github-markdown-light.css';
import { Menu, MenuItem } from '../apps/menu';
export interface Props {
title?: string;
description?: string;
lang?: string;
charset?: string;
showMenu?: boolean;
menu?: MenuItem[];
basename?: string;
}
const {
title = 'Light Code',
description = 'A lightweight code editor',
lang = 'zh-CN',
charset = 'UTF-8',
showMenu = true,
menu,
basename = '',
} = Astro.props;
---
<html lang={lang}>
<head>
<meta charset={charset} />
<meta name='viewport' content='width=device-width, initial-scale=1.0' />
<title>{title}</title>
<meta name='description' content={description} />
<style>
html,
body {
width: 100%;
height: 100%;
margin: 0;
padding: 0;
overflow: hidden;
}
</style>
</head>
<body class='flex flex-col items-center bg-background'>
<div class='w-full'>
<slot name='header' />
</div>
<main class='flex-1 flex overflow-hidden w-full max-w-7xl px-4 py-4'>
{
showMenu && (
<aside class='w-64 min-w-64 h-full flex flex-col'>
<slot name='menu'>
<Menu items={menu!} client:only basename={basename} />
</slot>
</aside>
)
}
<div class='flex-1 h-full flex items-start justify-center overflow-hidden'>
<article class='markdown-body h-full scrollbar overflow-auto px-8 py-6 w-full max-w-4xl border border-border rounded-lg shadow-sm bg-card'>
<slot />
</article>
</div>
</main>
<footer class='w-full border-t border-border bg-card/50 backdrop-blur-sm'>
<slot name='footer'>
<div class='text-center text-sm text-muted-foreground py-4'>Copyright &copy; 2025</div>
</slot>
</footer>
</body>
</html>

View File

@@ -0,0 +1,6 @@
import { clsx, type ClassValue } from "clsx"
import { twMerge } from "tailwind-merge"
export function cn(...inputs: ClassValue[]) {
return twMerge(clsx(inputs))
}

View File

@@ -0,0 +1,13 @@
// @ts-ignore
export const basename = BASE_NAME;
console.log(basename);
export const wrapBasename = (path: string) => {
const hasEnd = path.endsWith('/')
if (basename) {
return `${basename}${path}` + (hasEnd ? '' : '/');
} else {
return path;
}
}

View File

@@ -0,0 +1,23 @@
import { QueryClient, Query } from '@kevisual/query'
import { QueryLoginBrowser } from '@kevisual/query-login'
const getUrl = () => {
const host = window.location.host
const isKevisual = host.includes('kevisual');
if (isKevisual) {
return '/api/router'
}
return '/client/router'
}
export const query = new QueryClient({
url: getUrl()
});
export const remoteQuery = new Query({
url: '/api/router'
});
export const queryLogin = new QueryLoginBrowser({
query: remoteQuery
});

View File

@@ -0,0 +1,9 @@
---
import Html from '@/components/html.astro';
---
<Html>
<main>
</main>
</Html>

View File

@@ -0,0 +1,10 @@
---
import Html from '@/components/html.astro';
// import Counter from '@/components/vue/Counter.vue';
---
<Html>
<main>
<!-- <Counter count={10} client:only/> -->
</main>
</Html>

View File

@@ -0,0 +1,27 @@
---
import { getCollection, render } from 'astro:content';
import Main from '@/layouts/mdx.astro';
import { basename } from '@/modules/basename';
// 1. 为每个集合条目生成一个新路径
export async function getStaticPaths() {
const posts = await getCollection('docs');
return posts.map((post) => ({
params: { id: post.id },
props: { post },
data: post,
}));
}
type Post = {
data: { title: string; tags: string[]; showMenu?: boolean };
};
// 2. 对于你的模板,你可以直接从 prop 获取条目
const { post } = Astro.props as { post: Post };
const { Content } = await render(post);
const showMenu = post.data?.showMenu;
const staticPaths = await getStaticPaths();
const menu = staticPaths.map((item) => item.data);
---
<Main showMenu={showMenu} menu={menu} basename={basename} title={post.data.title}>
<Content />
</Main>

View File

@@ -0,0 +1,80 @@
---
import { getCollection } from 'astro:content';
const posts = await getCollection('docs');
import { basename, wrapBasename } from '@/modules/basename';
import Blank from '@/layouts/blank.astro';
---
<Blank>
<main class='min-h-screen bg-linear-to-br from-slate-50 to-slate-100 dark:from-slate-900 dark:to-slate-800'>
<div class='max-w-4xl mx-auto px-4 sm:px-6 lg:px-8 py-12'>
{/* 页面标题区域 */}
<div class='mb-12'>
<h1 class='text-4xl sm:text-5xl font-bold text-slate-900 dark:text-white mb-4 bg-clip-text bg-linear-to-r from-blue-600 to-purple-600'>📚 文档列表</h1>
<p class='text-slate-600 dark:text-slate-400 text-lg'>浏览所有可用的文档资源</p>
<div class='mt-4 h-1 w-20 bg-linear-to-r from-blue-600 to-purple-600 rounded-full'></div>
</div>
{/* 文档列表 */}
<div class='space-y-4'>
{
posts.map((post) => {
const tags = post.data.tags || [];
const postUrl = wrapBasename(`/docs/${post.id}`);
return (
<article class='group bg-white dark:bg-slate-800 rounded-xl shadow-sm hover:shadow-xl transition-all duration-300 overflow-hidden border border-slate-200 dark:border-slate-700 hover:border-blue-500 dark:hover:border-blue-400'>
<div class='p-6'>
{/* 文档标题 */}
<a href={postUrl} class='block'>
<h2 class='text-xl sm:text-2xl font-semibold text-slate-900 dark:text-white group-hover:text-blue-600 dark:group-hover:text-blue-400 transition-colors duration-200 mb-3'>
{post.data.title}
</h2>
</a>
{/* 文档描述(如果有) */}
{post.data.description && <p class='text-slate-600 dark:text-slate-400 mb-4 line-clamp-2'>{post.data.description}</p>}
{/* 标签列表 */}
{tags.length > 0 && (
<div class='flex flex-wrap gap-2 mt-4'>
{tags.map((tag) => (
<div class='inline-flex items-center px-3 py-1 rounded-full text-sm font-medium bg-blue-50 dark:bg-blue-900/30 text-blue-700 dark:text-blue-300 hover:bg-blue-100 dark:hover:bg-blue-900/50 transition-colors duration-200 border border-blue-200 dark:border-blue-800'>
<span class='mr-1'>#</span>
{tag}
</div>
))}
</div>
)}
{/* 阅读更多指示器 */}
<a
href={postUrl}
class='mt-4 flex items-center text-blue-600 dark:text-blue-400 text-sm font-medium opacity-0 group-hover:opacity-100 transition-opacity duration-200'>
<span>阅读更多</span>
<svg
class='w-4 h-4 ml-1 transform group-hover:translate-x-1 transition-transform duration-200'
fill='none'
viewBox='0 0 24 24'
stroke='currentColor'>
<path stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M9 5l7 7-7 7' />
</svg>
</a>
</div>
</article>
);
})
}
</div>
{/* 空状态 */}
{
posts.length === 0 && (
<div class='text-center py-16'>
<div class='text-6xl mb-4'>📭</div>
<p class='text-xl text-slate-600 dark:text-slate-400'>暂无文档</p>
</div>
)
}
</div>
</main>
</Blank>

View File

@@ -0,0 +1,47 @@
---
// import { query } from '@/modules/query.ts';
console.log('Hello from index.astro');
import '../styles/global.css';
---
<html lang='en'>
<head>
<title>My Homepage</title>
</head>
<body>
<h1 onclick="{onClick}">Welcome to my website!</h1>
<div class='bg-amber-50 w-20 h-20 rounded-full'></div>
<div id='root'></div>
<script type='importmap' data-vite-ignore is:inline>
{
"imports": {
"react": "https://esm.sh/react@19.1.0",
"react-dom": "https://esm.sh/react-dom@19.1.0/client.js",
"react-toastify": "https://esm.sh/react-toastify@11.0.5"
}
}
</script>
<script type='module' data-vite-ignore is:inline>
import { Button, message } from 'https://esm.sh/antd?standalone';
import React from 'react';
import { ToastContainer, toast } from 'react-toastify';
import { createRoot } from 'react-dom';
setTimeout(() => {
toast.loading('Hello from index.astro');
window.toast = toast;
console.log('message', toast);
}, 1000);
console.log('Hello from index.astro', Button);
const root = document.getElementById('root');
const render = createRoot(root);
const App = () => {
const button = React.createElement(Button, null, 'Hello');
const messageEl = React.createElement(ToastContainer, null, 'Hello');
const wrapperMessage = React.createElement('div', null, [button, messageEl]);
return wrapperMessage;
};
// render.render(React.createElement(Button, null, 'Hello'), root);
render.render(App(), root);
</script>
</body>
</html>

View File

@@ -0,0 +1,8 @@
---
import Html from '@/components/html.astro';
import { FirstLogin } from '@/apps/setting/index.tsx';
---
<Html>
<FirstLogin client:only />
</Html>

View File

@@ -0,0 +1,8 @@
---
import Html from '@/components/html.astro';
import { Config } from '@/apps/setting/index.tsx';
---
<Html>
<Config client:only />
</Html>

View File

@@ -0,0 +1,120 @@
@import 'tailwindcss';
@import "tw-animate-css";
@custom-variant dark (&:is(.dark *));
@theme inline {
--radius-sm: calc(var(--radius) - 4px);
--radius-md: calc(var(--radius) - 2px);
--radius-lg: var(--radius);
--radius-xl: calc(var(--radius) + 4px);
--color-background: var(--background);
--color-foreground: var(--foreground);
--color-card: var(--card);
--color-card-foreground: var(--card-foreground);
--color-popover: var(--popover);
--color-popover-foreground: var(--popover-foreground);
--color-primary: var(--primary);
--color-primary-foreground: var(--primary-foreground);
--color-secondary: var(--secondary);
--color-secondary-foreground: var(--secondary-foreground);
--color-muted: var(--muted);
--color-muted-foreground: var(--muted-foreground);
--color-accent: var(--accent);
--color-accent-foreground: var(--accent-foreground);
--color-destructive: var(--destructive);
--color-border: var(--border);
--color-input: var(--input);
--color-ring: var(--ring);
--color-chart-1: var(--chart-1);
--color-chart-2: var(--chart-2);
--color-chart-3: var(--chart-3);
--color-chart-4: var(--chart-4);
--color-chart-5: var(--chart-5);
--color-sidebar: var(--sidebar);
--color-sidebar-foreground: var(--sidebar-foreground);
--color-sidebar-primary: var(--sidebar-primary);
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
--color-sidebar-accent: var(--sidebar-accent);
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
--color-sidebar-border: var(--sidebar-border);
--color-sidebar-ring: var(--sidebar-ring);
}
:root {
--radius: 0.625rem;
--background: oklch(1 0 0);
--foreground: oklch(0.145 0 0);
--card: oklch(1 0 0);
--card-foreground: oklch(0.145 0 0);
--popover: oklch(1 0 0);
--popover-foreground: oklch(0.145 0 0);
--primary: oklch(0.205 0 0);
--primary-foreground: oklch(0.985 0 0);
--secondary: oklch(0.97 0 0);
--secondary-foreground: oklch(0.205 0 0);
--muted: oklch(0.97 0 0);
--muted-foreground: oklch(0.556 0 0);
--accent: oklch(0.97 0 0);
--accent-foreground: oklch(0.205 0 0);
--destructive: oklch(0.577 0.245 27.325);
--border: oklch(0.922 0 0);
--input: oklch(0.922 0 0);
--ring: oklch(0.708 0 0);
--chart-1: oklch(0.646 0.222 41.116);
--chart-2: oklch(0.6 0.118 184.704);
--chart-3: oklch(0.398 0.07 227.392);
--chart-4: oklch(0.828 0.189 84.429);
--chart-5: oklch(0.769 0.188 70.08);
--sidebar: oklch(0.985 0 0);
--sidebar-foreground: oklch(0.145 0 0);
--sidebar-primary: oklch(0.205 0 0);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.97 0 0);
--sidebar-accent-foreground: oklch(0.205 0 0);
--sidebar-border: oklch(0.922 0 0);
--sidebar-ring: oklch(0.708 0 0);
}
.dark {
--background: oklch(0.145 0 0);
--foreground: oklch(0.985 0 0);
--card: oklch(0.205 0 0);
--card-foreground: oklch(0.985 0 0);
--popover: oklch(0.205 0 0);
--popover-foreground: oklch(0.985 0 0);
--primary: oklch(0.922 0 0);
--primary-foreground: oklch(0.205 0 0);
--secondary: oklch(0.269 0 0);
--secondary-foreground: oklch(0.985 0 0);
--muted: oklch(0.269 0 0);
--muted-foreground: oklch(0.708 0 0);
--accent: oklch(0.269 0 0);
--accent-foreground: oklch(0.985 0 0);
--destructive: oklch(0.704 0.191 22.216);
--border: oklch(1 0 0 / 10%);
--input: oklch(1 0 0 / 15%);
--ring: oklch(0.556 0 0);
--chart-1: oklch(0.488 0.243 264.376);
--chart-2: oklch(0.696 0.17 162.48);
--chart-3: oklch(0.769 0.188 70.08);
--chart-4: oklch(0.627 0.265 303.9);
--chart-5: oklch(0.645 0.246 16.439);
--sidebar: oklch(0.205 0 0);
--sidebar-foreground: oklch(0.985 0 0);
--sidebar-primary: oklch(0.488 0.243 264.376);
--sidebar-primary-foreground: oklch(0.985 0 0);
--sidebar-accent: oklch(0.269 0 0);
--sidebar-accent-foreground: oklch(0.985 0 0);
--sidebar-border: oklch(1 0 0 / 10%);
--sidebar-ring: oklch(0.556 0 0);
}
@layer base {
* {
@apply border-border outline-ring/50;
}
body {
@apply bg-background text-foreground;
}
}

View File

@@ -0,0 +1,98 @@
@import 'tailwindcss';
@theme {
/* --color-primary: #ffc107;
--color-secondary: #ffa000;
--color-text-primary: #000000;
--color-text-secondary: #000000;
--color-success: #28a745; */
--color-scrollbar-thumb: #999999;
--color-scrollbar-track: rgba(0, 0, 0, 0.1);
--color-scrollbar-thumb-hover: #666666;
--scrollbar-color: #ffc107;
}
html,
body {
width: 100%;
height: 100%;
font-size: 16px;
font-family: 'Montserrat', sans-serif;
}
/* font-family */
@utility font-family-mon {
font-family: 'Montserrat', sans-serif;
}
@utility font-family-rob {
font-family: 'Roboto', sans-serif;
}
@utility font-family-int {
font-family: 'Inter', sans-serif;
}
@utility font-family-orb {
font-family: 'Orbitron', sans-serif;
}
@utility font-family-din {
font-family: 'DIN', sans-serif;
}
@utility flex-row-center {
@apply flex flex-row items-center justify-center;
}
@utility flex-col-center {
@apply flex flex-col items-center justify-center;
}
@utility scrollbar {
overflow: auto;
/* 整个滚动条 */
&::-webkit-scrollbar {
width: 3px;
height: 3px;
}
&::-webkit-scrollbar-track {
background-color: var(--color-scrollbar-track);
}
/* 滚动条有滑块的轨道部分 */
&::-webkit-scrollbar-track-piece {
background-color: transparent;
border-radius: 1px;
}
/* 滚动条滑块(竖向:vertical 横向:horizontal) */
&::-webkit-scrollbar-thumb {
cursor: pointer;
background-color: var(--color-scrollbar-thumb);
border-radius: 5px;
}
/* 滚动条滑块hover */
&::-webkit-scrollbar-thumb:hover {
background-color: var(--color-scrollbar-thumb-hover);
}
/* 同时有垂直和水平滚动条时交汇的部分 */
&::-webkit-scrollbar-corner {
display: block;
/* 修复交汇时出现的白块 */
}
}
ul,
menu {
list-style: disc;
}
ol {
list-style: decimal;
}

18
cli-center/tsconfig.json Normal file
View File

@@ -0,0 +1,18 @@
{
"extends": "@kevisual/types/json/frontend.json",
"compilerOptions": {
"baseUrl": ".",
"paths": {
"@/*": [
"./src/*"
],
"@/agent": [
"./src/agent"
]
},
},
"include": [
"src/**/*",
"agent/**/*"
],
}

View File

@@ -1,6 +1,6 @@
{ {
"name": "@kevisual/cli", "name": "@kevisual/cli",
"version": "0.0.71", "version": "0.0.76",
"description": "envision 命令行工具", "description": "envision 命令行工具",
"type": "module", "type": "module",
"basename": "/root/cli", "basename": "/root/cli",
@@ -28,6 +28,7 @@
"scripts": { "scripts": {
"dev": "bun src/run.ts ", "dev": "bun src/run.ts ",
"dev:tsx": "tsx src/run.ts ", "dev:tsx": "tsx src/run.ts ",
"dev:server": "cd assistant && bun --watch src/run-server.ts ",
"build": "rimraf dist && bun run bun.config.mjs", "build": "rimraf dist && bun run bun.config.mjs",
"deploy": "ev pack -u -p -m no", "deploy": "ev pack -u -p -m no",
"pub:me": "npm publish --registry https://npm.xiongxiao.me --tag beta", "pub:me": "npm publish --registry https://npm.xiongxiao.me --tag beta",
@@ -40,22 +41,29 @@
], ],
"author": "abearxiong", "author": "abearxiong",
"dependencies": { "dependencies": {
"@kevisual/app": "^0.0.1",
"@kevisual/context": "^0.0.4", "@kevisual/context": "^0.0.4",
"@kevisual/hot-api": "^0.0.3",
"@nut-tree-fork/nut-js": "^4.2.6",
"eventemitter3": "^5.0.1",
"lowdb": "^7.0.1",
"lru-cache": "^11.2.4",
"micromatch": "^4.0.8", "micromatch": "^4.0.8",
"pm2": "^6.0.14", "pm2": "^6.0.14",
"semver": "^7.7.3" "semver": "^7.7.3",
"unstorage": "^1.17.3"
}, },
"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.30", "@kevisual/query": "0.0.32",
"@kevisual/query-login": "0.0.7", "@kevisual/query-login": "0.0.7",
"@types/bun": "^1.3.3", "@types/bun": "^1.3.4",
"@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": "^24.10.1", "@types/node": "^25.0.3",
"@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",
@@ -64,7 +72,7 @@
"filesize": "^11.0.13", "filesize": "^11.0.13",
"form-data": "^4.0.5", "form-data": "^4.0.5",
"ignore": "^7.0.5", "ignore": "^7.0.5",
"inquirer": "^13.0.2", "inquirer": "^13.1.0",
"jsonwebtoken": "^9.0.3", "jsonwebtoken": "^9.0.3",
"tar": "^7.5.2", "tar": "^7.5.2",
"zustand": "^5.0.9" "zustand": "^5.0.9"

6684
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,2 +1,3 @@
packages: packages:
- 'assistant' - 'assistant'
- 'cli-center'

4
src/ai/ai.ts Normal file
View File

@@ -0,0 +1,4 @@
import { App } from '@kevisual/app/src/app.ts';
import { storage } from '../module/query.ts';
export const app = new App({ token: storage.getItem('token') || '', storage });

6
src/ai/index.ts Normal file
View File

@@ -0,0 +1,6 @@
import { app } from './ai.ts'
import './routes/cmd-run.ts'
export {
app
}

62
src/ai/routes/cmd-run.ts Normal file
View File

@@ -0,0 +1,62 @@
import { app } from '../ai.ts';
import { execSync } from 'node:child_process'
import { logger } from '@/module/logger.ts';
const promptTemplate = `# CMD 结果判断器
分析上一条 CMD 命令的执行结果,判断是否需要执行下一条命令。
- 若结果中隐含或明确指示需继续执行 → 返回:\`{"cmd": "推断出的下一条命令", "type": "cmd"}\`
- 若无后续操作,甚至上一次执行的返回为空或者成功 → 返回:\`{"type": "none"}\`
1. 仅输出合法 JSON无任何额外文本。
2. \`cmd\` 必须从执行结果中合理推断得出,非预设或猜测。
3. 禁止解释、注释、换行或格式错误。`
app.router.route({
path: 'cmd-run',
description: '执行 CMD 命令并判断下一步操作, 参数是 cmd 字符串',
}).define(async (ctx) => {
const cmd = ctx.query.cmd || '';
if (!cmd) {
ctx.throw(400, 'cmd is required');
}
let result = '';
ctx.state.steps = ctx.state?.steps || [];
try {
logger.info('执行命令:', cmd);
result = execSync(cmd, { encoding: 'utf-8' });
ctx.state.steps.push({ cmd, result });
logger.info(result);
} catch (error: any) {
result = error.message || '';
ctx.state.steps.push({ cmd, result, error: true });
ctx.body = {
steps: ctx.state.steps,
}
return;
}
await app.loadAI()
const prompt = `${promptTemplate}\n上一条命令:\n${cmd}\n执行结果:\n${result}\n`;
const response = await app.ai.question(prompt);
const msg = app.ai.utils.extractJsonFromMarkdown(app.ai.responseText);
try {
logger.debug('AI Prompt', prompt);
logger.debug('AI 分析结果:', msg);
const { cmd, type } = msg;
if (type === 'cmd' && cmd) {
await app.router.call({ path: 'cmd-run', payload: { cmd } }, { state: ctx.state });
} else {
logger.info('无后续命令,结束执行');
ctx.state.steps.push({ type: 'none' });
}
} catch (error) {
result = '执行错误,无法解析返回结果为合法 JSON' + app.ai.responseText
logger.error(result);
ctx.state.steps.push({ cmd, result, parseError: true });
}
ctx.body = {
steps: ctx.state.steps,
}
}).addTo(app.router);

41
src/command/ai.ts Normal file
View File

@@ -0,0 +1,41 @@
import { program, Command } from '@/program.ts';
import { app } from '../ai/index.ts';
import util from 'util';
import { chalk } from '@/module/chalk.ts';
import { logger } from '@/module/logger.ts';
const aiCmd = new Command('ai')
.description('AI 相关命令')
.action(async (opts) => {
});
const runCmd = async (cmd: string) => {
const res = await app.router.call({ path: 'cmd-run', payload: { cmd } });
const { body } = res;
const steps = body?.steps || [];
for (const step of steps) {
logger.debug(chalk.blue(`\n==== 步骤: ${step.cmd || '结束'} ====`));
logger.debug(step.result || 'No result');
}
}
const aiRun = new Command('run')
.description('执行 AI 命令')
.option('-c, --cmd <cmd>', '要执行的 CMD 命令')
.action(async (opts) => {
if (opts.cmd) {
await runCmd(opts.cmd);
} else {
console.log('请提供要执行的 CMD 命令');
}
});
const aiRunDeploy = new Command('deploy')
.description('部署 AI 后端应用')
.action(async (opts) => {
const cmd = 'ev pack -p -u';
const res = await runCmd(cmd);
});
aiCmd.addCommand(aiRun);
aiCmd.addCommand(aiRunDeploy);
program.addCommand(aiCmd);

View File

@@ -0,0 +1,78 @@
import { program, Command } from '@/program.ts';
import { query } from '@/module/query.ts';
import { QueryConfig } from '@/query/query-secret/query-secret.ts';
import { showMore } from '@/uitls/show-more.ts';
import fs from 'node:fs';
import path from 'node:path';
const queryConfig = new QueryConfig({ query: query as any });
const command = new Command('remote-secret')
.alias('rs').description('获取或设置远程配置');
const getCommand = new Command('get')
.option('-k, --key <key>', '配置键名')
.action(async (options) => {
const { key } = options || {};
if (!key) {
console.log('Please provide a key using -k or --key option.');
return;
}
const res = await queryConfig.getItem({ id: key });
console.log('res Config Result:', showMore(res.data));
})
const listCommand = new Command('list')
.description('列出所有配置')
.action(async () => {
const res = await queryConfig.listItems();
if (res.code === 200) {
const list = res.data?.list || [];
list.forEach(item => {
console.log(item.id, item.key, showMore(item));
});
} else {
console.log('获取错误:', res.message);
}
});
const updateCommand = new Command('update')
.description('更新远程配置')
.option('-i, --id <id>', '配置ID')
.option('-t, --title <title>', '配置值')
.option('-d, --description <description>', '配置数据JSON格式')
.action(async (options) => {
const { id, title, description } = options || {};
let updateData: any = {};
if (title) {
updateData.title = title;
}
if (description) {
updateData.description = description;
}
if (id) {
updateData.id = id;
}
const res = await queryConfig.updateItem(updateData);
console.log('修改结果:', showMore(res));
});
const deleteCommand = new Command('delete')
.description('删除远程配置')
.option('-i, --id <id>', '配置ID')
.option('-k, --key <key>', '配置键名')
.action(async (options) => {
const { key, id } = options || {};
if (!key && !id) {
console.log('请提供配置键名或配置ID使用 -k 或 --key 选项,或 -i 或 --id 选项。');
return;
}
const res = await queryConfig.deleteItem({ key, id });
console.log('Delete Config Result:', showMore(res));
});
command.addCommand(listCommand);
command.addCommand(getCommand);
command.addCommand(updateCommand);
command.addCommand(deleteCommand);
program.addCommand(command);

View File

@@ -30,7 +30,7 @@ export const getPackageJson = (opts?: { version?: string; appKey?: string }) =>
return null; return null;
} }
const [user, appKey] = userAppArry; const [user, appKey] = userAppArry;
return { basename, version, pkg: packageJson, user, appKey: appKey || opts?.appKey, app }; return { basename, version, pkg: packageJson, user, appKey: opts?.appKey || appKey, app };
} catch (error) { } catch (error) {
return null; return null;
} }
@@ -52,7 +52,6 @@ const command = new Command('deploy')
let { version, key, yes, update, org, showBackend } = options; let { version, key, yes, update, org, showBackend } = options;
const noCheck = !options.noCheck; const noCheck = !options.noCheck;
const dot = !!options.dot; const dot = !!options.dot;
// 获取当前目录是否存在package.json, 如果有从package.json 获取 version 和basename
const pkgInfo = getPackageJson({ version, appKey: key }); const pkgInfo = getPackageJson({ version, appKey: key });
if (!version && pkgInfo?.version) { if (!version && pkgInfo?.version) {
version = pkgInfo?.version || ''; version = pkgInfo?.version || '';
@@ -60,7 +59,7 @@ const command = new Command('deploy')
if (!key && pkgInfo?.appKey) { if (!key && pkgInfo?.appKey) {
key = pkgInfo?.appKey || ''; key = pkgInfo?.appKey || '';
} }
console.log('start deploy'); logger.debug('start deploy');
if (!version || !key) { if (!version || !key) {
const answers = await inquirer.prompt([ const answers = await inquirer.prompt([
{ {
@@ -107,8 +106,8 @@ const command = new Command('deploy')
const filename = path.basename(directory); const filename = path.basename(directory);
_relativeFiles = [filename]; _relativeFiles = [filename];
} }
console.log('upload Files', _relativeFiles); logger.debug('upload Files', _relativeFiles);
console.log('upload Files Key', key, version); logger.debug('upload Files Key', key, version);
if (!yes) { if (!yes) {
// 确认是否上传 // 确认是否上传
const confirm = await inquirer.prompt([ const confirm = await inquirer.prompt([
@@ -125,7 +124,6 @@ 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 });
if (res?.code === 200) { if (res?.code === 200) {
console.log('File uploaded successfully!');
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));
}); });
@@ -140,7 +138,6 @@ const command = new Command('deploy')
// const { id, data, ...rest } = res.data?.app || {}; // const { id, data, ...rest } = res.data?.app || {};
const { id, data, ...rest } = res2.data || {}; const { id, data, ...rest } = res2.data || {};
if (id && !update) { if (id && !update) {
console.log(chalk.green('id: '), id);
if (!org) { if (!org) {
console.log(chalk.green(`更新为最新版本: envision deploy-load ${id}`)); console.log(chalk.green(`更新为最新版本: envision deploy-load ${id}`));
} else { } else {
@@ -153,9 +150,7 @@ const command = new Command('deploy')
} }
logger.debug('deploy success', res2.data); logger.debug('deploy success', res2.data);
if (id && showBackend) { if (id && showBackend) {
console.log('\n'); console.log(chalk.blue('下一个步骤服务端应用部署:\n'), 'envision pack-deploy', id);
console.log(chalk.blue('服务端应用部署: '), 'envision pack-deploy', id);
console.log('\n');
} }
} else { } else {
console.error('File upload failed', res?.message); console.error('File upload failed', res?.message);
@@ -177,14 +172,18 @@ const uploadFiles = async (files: string[], directory: string, opts: UploadFileO
const { key, version, username } = opts || {}; const { key, version, username } = opts || {};
const form = new FormData(); const form = new FormData();
const data: Record<string, any> = { files: [] }; const data: Record<string, any> = { files: [] };
let description = '';
for (const file of files) { for (const file of files) {
const filePath = path.join(directory, file); const filePath = path.join(directory, file);
const hash = getHash(filePath); const hash = getHash(filePath);
if (!hash) { if (!hash) {
console.error('文件', filePath, '不存在'); logger.error('文件', filePath, '不存在');
console.error('请检查文件是否存在'); logger.error('请检查文件是否存在');
} }
data.files.push({ path: file, hash: hash }); data.files.push({ path: file, hash: hash });
if(filePath.includes('readme.md')) {
description = fs.readFileSync(filePath, 'utf-8');
}
} }
data.appKey = key; data.appKey = key;
data.version = version; data.version = version;
@@ -221,11 +220,11 @@ const uploadFiles = async (files: string[], directory: string, opts: UploadFileO
const filePath = path.join(directory, file); const filePath = path.join(directory, file);
const check = checkData.find((d) => d.path === file); const check = checkData.find((d) => d.path === file);
if (check?.isUpload) { if (check?.isUpload) {
console.log('文件已经上传过了', file); logger.debug('文件已经上传过了', file);
continue; continue;
} }
const filename = path.basename(filePath); const filename = path.basename(filePath);
console.log('upload file', file, filename); logger.debug('upload file', file, filename);
form.append('file', fs.createReadStream(filePath), { form.append('file', fs.createReadStream(filePath), {
filename: filename, filename: filename,
filepath: file, filepath: file,
@@ -233,7 +232,7 @@ const uploadFiles = async (files: string[], directory: string, opts: UploadFileO
needUpload = true; needUpload = true;
} }
if (!needUpload) { if (!needUpload) {
console.log('所有文件都上传过了,不需要上传文件'); logger.debug('所有文件都上传过了,不需要上传文件');
return { return {
code: 200, code: 200,
}; };
@@ -261,16 +260,16 @@ const deployLoadFn = async (id: string, org?: string) => {
}, },
}); });
if (res.code === 200) { if (res.code === 200) {
console.log(chalk.green('deploy-load success. current version:', res.data?.version)); logger.info(chalk.green('deploy-load success. current version:', res.data?.version));
// /:username/:appName // /:username/:appName
try { try {
const { user, key } = res.data; const { user, key } = res.data;
const baseURL = getBaseURL(); const baseURL = getBaseURL();
const deployURL = new URL(`/${user}/${key}/`, baseURL); const deployURL = new URL(`/${user}/${key}/`, baseURL);
console.log(chalk.blue('deployURL', deployURL.href)); logger.info(chalk.blue('deployURL', deployURL.href));
} catch (error) { } } catch (error) { }
} else { } else {
console.error('deploy-load failed', res.message); logger.error('deploy-load failed', res.message);
} }
}; };

View File

@@ -79,10 +79,10 @@ const showMe = async (show = true) => {
const localToken = storage.getItem('token'); const localToken = storage.getItem('token');
if (!token && !localToken) { if (!token && !localToken) {
console.log('请先登录'); console.log('请先登录');
return; return { code: 40400, message: '请先登录' };
} }
let me = await queryLogin.getMe(token); let me = await queryLogin.getMe(token);
if (me.code === 401) { if (me?.code === 401) {
me = await queryLogin.getMe(); me = await queryLogin.getMe();
} }
if (show) { if (show) {
@@ -113,6 +113,9 @@ const command = new Command('me')
res = await showMe(false); res = await showMe(false);
isRefresh = true; isRefresh = true;
} }
if (res.code === 40400) {
return
}
if (res.code === 200) { if (res.code === 200) {
if (isRefresh) { if (isRefresh) {
console.log(chalk.green('refresh token success'), '\n'); console.log(chalk.green('refresh token success'), '\n');

View File

@@ -7,6 +7,7 @@ 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 inquirer from 'inquirer'; import inquirer from 'inquirer';
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('.');
@@ -139,9 +140,9 @@ export const pack = async (opts: { packDist?: string, mergeDist?: boolean }) =>
const allFiles = (await Promise.all(filesToInclude.map((file) => collectFileInfo(file)))).flat(); const allFiles = (await Promise.all(filesToInclude.map((file) => collectFileInfo(file)))).flat();
// 输出文件详细信息 // 输出文件详细信息
console.log('文件列表:'); logger.debug('文件列表:');
allFiles.forEach((file) => { allFiles.forEach((file) => {
console.log(`${file.size}B ${file.path}`); logger.debug(`${file.size}B ${file.path}`);
}); });
const totalSize = allFiles.reduce((sum, file) => sum + file.size, 0); const totalSize = allFiles.reduce((sum, file) => sum + file.size, 0);
@@ -150,10 +151,10 @@ export const pack = async (opts: { packDist?: string, mergeDist?: boolean }) =>
collection.totalSize = totalSize; collection.totalSize = totalSize;
collection.tags = packageJson.app?.tags || packageJson.keywords || []; collection.tags = packageJson.app?.tags || packageJson.keywords || [];
console.log('\n基本信息'); logger.debug('\n基本信息');
console.log(`name: ${packageJson.name}`); logger.debug(`name: ${packageJson.name}`);
console.log(`version: ${packageJson.version}`); logger.debug(`version: ${packageJson.version}`);
console.log(`total files: ${allFiles.length}`); logger.debug(`total files: ${allFiles.length}`);
try { try {
copyFilesToPackDist(filesToInclude, cwd, opts.packDist, mergeDist); copyFilesToPackDist(filesToInclude, cwd, opts.packDist, mergeDist);
} catch (error) { } catch (error) {
@@ -188,7 +189,7 @@ const publishCommand = new Command('publish')
console.log('发布逻辑实现', { key, version, config }); console.log('发布逻辑实现', { key, version, config });
}); });
const deployLoadFn = async (id: string, fileKey: string, force = false, install = false) => { const deployLoadFn = async (id: string, fileKey: string, force = true, install = false) => {
if (!id) { if (!id) {
console.error(chalk.red('id is required')); console.error(chalk.red('id is required'));
return; return;
@@ -222,7 +223,7 @@ const deployLoadFn = async (id: string, fileKey: string, force = false, install
console.log('deploy-load success. current version:', res.data?.pkg?.version); console.log('deploy-load success. current version:', res.data?.pkg?.version);
console.log('run: ', 'envision services -s', res.data?.showAppInfo?.key); console.log('run: ', 'envision services -s', res.data?.showAppInfo?.key);
} else { } else {
console.error('deploy-load failed', res.message); console.error('deploy-load 失败', res.message);
} }
return res; return res;
}; };
@@ -303,7 +304,7 @@ const packCommand = new Command('pack')
if (yes) { if (yes) {
deployCommand.push('-y', 'yes'); deployCommand.push('-y', 'yes');
} }
console.log(chalk.blue('deploy doing: '), deployCommand.slice(2).join(' '), '\n'); logger.debug(chalk.blue('deploy doing: '), deployCommand.slice(2).join(' '), '\n');
// console.log('pack deploy services', chalk.blue('example: '), runDeployCommand); // console.log('pack deploy services', chalk.blue('example: '), runDeployCommand);
program.parse(deployCommand); program.parse(deployCommand);
@@ -312,11 +313,10 @@ const packCommand = new Command('pack')
const packDeployCommand = new Command('pack-deploy') const packDeployCommand = new Command('pack-deploy')
.argument('<id>', 'id') .argument('<id>', 'id')
.option('-k, --key <key>', 'fileKey, 服务器的部署文件夹的列表') .option('-k, --key <key>', 'fileKey, 服务器的部署文件夹的列表')
.option('-f --force', 'force')
.option('-i, --install ', 'install dependencies') .option('-i, --install ', 'install dependencies')
.action(async (id, opts) => { .action(async (id, opts) => {
let { force, key, install } = opts || {}; let { key, install } = opts || {};
const res = await deployLoadFn(id, key, force, install); const res = await deployLoadFn(id, key, true, install);
}); });
program.addCommand(packDeployCommand); program.addCommand(packDeployCommand);

View File

@@ -1,6 +1,6 @@
import path from 'node:path'; import path from 'node:path';
import fs from 'node:fs'; import fs from 'node:fs';
import { Config, SyncList, SyncConfigType } from './type.ts'; import { Config, SyncList, SyncConfigType, SyncConfig } from './type.ts';
import { fileIsExist } from '@/uitls/file.ts'; import { fileIsExist } from '@/uitls/file.ts';
import { getHash } from '@/uitls/hash.ts'; import { getHash } from '@/uitls/hash.ts';
import glob from 'fast-glob'; import glob from 'fast-glob';
@@ -32,6 +32,15 @@ export class SyncBase {
this.baseURL = opts?.baseURL ?? ''; this.baseURL = opts?.baseURL ?? '';
this.init(); this.init();
} }
get dir() {
return this.#dir;
}
get configFilename() {
return this.#filename;
}
get configPath() {
return path.join(this.#dir, this.#filename);
}
async init() { async init() {
try { try {
const dir = this.#dir; const dir = this.#dir;
@@ -59,7 +68,8 @@ export class SyncBase {
if (!filename) return false; if (!filename) return false;
const dir = this.#dir; const dir = this.#dir;
const file = path.join(dir, filename); const file = path.join(dir, filename);
return { relative: path.relative(dir, file), absolute: file }; const realFilename = path.basename(filename);
return { relative: path.relative(dir, file), absolute: file, filename: realFilename };
} }
async canDone(syncType: SyncConfigType, type?: SyncConfigType) { async canDone(syncType: SyncConfigType, type?: SyncConfigType) {
if (syncType === 'sync') return true; if (syncType === 'sync') return true;
@@ -120,38 +130,67 @@ export class SyncBase {
return syncList; return syncList;
} }
async getCheckList() { async getCheckList() {
const checkDir = this.config?.checkDir || {}; const checkDir = this.config?.clone || {};
const dirKeys = Object.keys(checkDir); const dirKeys = Object.keys(checkDir);
const registry = this.config?.registry || '';
const files = dirKeys.map((key) => { const files = dirKeys.map((key) => {
return { key, ...this.getRelativePath(key) }; return { key, ...this.getRelativePath(key) };
}); });
return files return files
.map((item) => { .map((item) => {
if (!item) return; if (!item) return;
let auth = checkAuth(checkDir[item.key]?.url, this.baseURL); let url = checkDir[item.key]?.url || registry;
let auth = checkAuth(url, this.baseURL);
return { return {
key: item.key, key: item.key,
...checkDir[item.key], ...checkDir[item.key],
url: url,
filepath: item?.absolute, filepath: item?.absolute,
auth, auth,
}; };
}) })
.filter((item) => item); .filter((item) => item);
} }
/**
* sync 是已有的,优先级高于 fileSync
*
* @param sync
* @param fileSync
* @returns
*/
getMergeSync(sync: Config['sync'] = {}, fileSync: Config['sync'] = {}) { getMergeSync(sync: Config['sync'] = {}, fileSync: Config['sync'] = {}) {
const syncFileSyncKeys = Object.keys(fileSync); const syncFileSyncKeys = Object.keys(fileSync);
const syncKeys = Object.keys(sync); const syncKeys = Object.keys(sync);
const config = this.config!;
const registry = config?.registry;
const keys = [...syncKeys, ...syncFileSyncKeys]; const keys = [...syncKeys, ...syncFileSyncKeys];
const obj: Config['sync'] = {}; const obj: Config['sync'] = {};
const wrapperRegistry = (value: SyncConfig | string) => {
if (typeof value === 'object') {
const url = value.url;
if (registry && !url.startsWith('http')) {
return {
...value,
url: registry.replace(/\/+$/g, '') + '/' + url.replace(/^\/+/g, ''),
};
}
return value;
}
const url = value;
if (registry && !url.startsWith('http')) {
return registry.replace(/\/+$/g, '') + '/' + url.replace(/^\/+/g, '');
}
return url;
}
for (let key of keys) { for (let key of keys) {
const value = sync[key] ?? fileSync[key]; const value = sync[key] ?? fileSync[key];
obj[key] = value; obj[key] = wrapperRegistry(value);
} }
return obj; return obj;
} }
async getSyncDirectoryList() { async getSyncDirectoryList() {
const config = this.config; const config = this.config;
const syncDirectory = config?.syncDirectory || []; const syncDirectory = config?.syncd || [];
let obj: Record<string, any> = {}; let obj: Record<string, any> = {};
const keys: string[] = []; const keys: string[] = [];
for (let item of syncDirectory) { for (let item of syncDirectory) {

View File

@@ -11,7 +11,7 @@ export type SyncDirectory = {
**/ **/
ignore?: string[]; ignore?: string[];
/** /**
* 合并路径的源地址https://kevisual.xiongxiao.me/root/ai/kevisual * 合并路径的源地址https://kevisual.cn/root/ai/kevisual
*/ */
registry?: string; registry?: string;
files?: string[]; files?: string[];
@@ -21,13 +21,13 @@ export type SyncDirectory = {
export interface Config { export interface Config {
name?: string; // 项目名称 name?: string; // 项目名称
version?: string; // 项目版本号 version?: string; // 项目版本号
registry?: string; // 项目仓库地址 registry?: string; // 当前模块的root
metadata?: Record<string, any>; // 元数据, 统一的配置 metadata?: Record<string, any>; // 元数据, 统一的配置
syncDirectory?: SyncDirectory[]; syncd?: SyncDirectory[];
sync?: { sync?: {
[key: string]: SyncConfig | string; [key: string]: SyncConfig | string;
}; };
checkDir?: { clone?: {
[key: string]: { [key: string]: {
url: string; // 需要检查的 url url: string; // 需要检查的 url
replace?: Record<string, string>; // 替换的路径 replace?: Record<string, string>; // 替换的路径

View File

@@ -1,10 +1,10 @@
import { program as app, Command } from '@/program.ts'; import { program as app, Command } from '@/program.ts';
import { SyncBase } from './modules/base.ts'; import { SyncBase } from './modules/base.ts';
import { baseURL, storage } from '@/module/query.ts'; import { baseURL, query, storage } from '@/module/query.ts';
import { fetchLink, fetchAiList } from '@/module/download/install.ts'; import { fetchLink, fetchAiList } from '@/module/download/install.ts';
import fs from 'node:fs'; import fs from 'node:fs';
import { upload } from '@/module/download/upload.ts'; import { upload } from '@/module/download/upload.ts';
import { logger } from '@/module/logger.ts'; 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';
@@ -124,7 +124,9 @@ const syncList = new Command('list')
syncList.forEach((item) => { syncList.forEach((item) => {
if (opts.all) { if (opts.all) {
logger.info(item); logger.info(item);
} else logger.info(chalk.blue(item.key), chalk.gray(item.type), chalk.green(item.url)); } else {
logger.info(chalk.green(printClickableLink({ url: item.url, text: item.key, print: false })), chalk.gray(item.type));
}
}); });
}); });
const syncCreateList = new Command('create') const syncCreateList = new Command('create')
@@ -154,16 +156,29 @@ const syncCreateList = new Command('create')
} }
}); });
const checkDir = new Command('check') const clone = new Command('clone')
.option('-d --dir <dir>', '配置目录') .option('-d --dir <dir>', '配置目录')
.option('-c --config <config>', '配置文件的名字', 'kevisual.json') .option('-c --config <config>', '配置文件的名字', 'kevisual.json')
.option('-i --link <link>', '克隆链接, 比 kevisual.json 优先级更高')
.description('检查目录') .description('检查目录')
.action(async (opts) => { .action(async (opts) => {
const link = opts.link || '';
const sync = new SyncBase({ dir: opts.dir, baseURL: baseURL, configFilename: opts.config }); const sync = new SyncBase({ dir: opts.dir, baseURL: baseURL, configFilename: opts.config });
if (link) {
const res = await query.fetchText(link);
if (res.code === 200) {
fs.writeFileSync(sync.configPath, JSON.stringify(res.data, null, 2));
} else {
logger.error('下载配置文件失败', link, res);
return;
}
await sync.init()
}
const syncList = await sync.getSyncList(); const syncList = await sync.getSyncList();
logger.debug(syncList); logger.debug(syncList);
logger.info('检查目录\n'); logger.info('检查目录\n');
const checkList = await sync.getCheckList(); const checkList = await sync.getCheckList();
logger.info('检查列表', checkList);
for (const item of checkList) { for (const item of checkList) {
if (!item.auth) { if (!item.auth) {
continue; continue;
@@ -184,7 +199,19 @@ const checkDir = new Command('check')
const matchList = matchObjectList const matchList = matchObjectList
.map((item2) => { .map((item2) => {
const rp = sync.getRelativePath(item2.pathname); const rp = sync.getRelativePath(item2.pathname);
if (!rp) return false; if (!rp) return false;
if (rp.absolute.endsWith('gitignore.txt')) {
// 修改为 .gitignore
const newPath = rp.absolute.replace('gitignore.txt', '.gitignore');
rp.absolute = newPath;
rp.relative = path.relative(sync.dir, newPath);
} else if (rp.absolute.endsWith('.dot')) {
const filename = path.basename(rp.absolute, '.dot');
const newPath = path.join(path.dirname(rp.absolute), `.${filename}`);
rp.absolute = newPath;
rp.relative = path.relative(sync.dir, newPath);
}
return { ...item2, relative: rp.relative, absolute: rp.absolute }; return { ...item2, relative: rp.relative, absolute: rp.absolute };
}) })
.filter((i) => i); .filter((i) => i);
@@ -226,6 +253,6 @@ command.addCommand(syncUpload);
command.addCommand(syncDownload); command.addCommand(syncDownload);
command.addCommand(syncList); command.addCommand(syncList);
command.addCommand(syncCreateList); command.addCommand(syncCreateList);
command.addCommand(checkDir); command.addCommand(clone);
app.addCommand(command); app.addCommand(command);

View File

@@ -15,7 +15,8 @@ import './command/app/index.ts';
import './command/gist/index.ts'; import './command/gist/index.ts';
import './command/config-remote.ts'; import './command/config-remote.ts';
import './command/config-secret-remote.ts';
import './command/ai.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,15 @@
import { Logger } from '@kevisual/logger/node'; import { Logger } from '@kevisual/logger/node';
const level = process.env.LOG_LEVEL || 'info'; const level = process.env.LOG_LEVEL || 'info';
export const logger = new Logger({ export const logger = new Logger({
level: level as any, level: level as any,
}); });
export function printClickableLink({ url, text, print = true }: { url: string; text: string, print?: boolean }) {
const escape = '\x1B'; // ESC 字符
const linkStart = `${escape}]8;;${url}${escape}\\`;
const linkEnd = `${escape}]8;;${escape}\\`;
if (print) {
console.log(`${linkStart}${text}${linkEnd}`);
}
return `${linkStart}${text}${linkEnd}`;
}

View File

@@ -0,0 +1,65 @@
/**
* 配置查询
* @updatedAt 2025-12-03 10:33:00
*/
import { Query } from '@kevisual/query';
import type { Result } from '@kevisual/query/query';
type QueryConfigOpts = {
query?: Query;
};
export type Config<T = any> = {
id?: string;
title?: string;
key?: string;
description?: string;
data?: T;
createdAt?: string;
updatedAt?: string;
};
export type UploadConfig = {
key?: string;
version?: string;
};
type PostOpts = {
token?: string;
payload?: Record<string, any>;
};
export class QueryConfig {
query: Query;
constructor(opts?: QueryConfigOpts) {
this.query = opts?.query || new Query();
}
async post<T = Config>(data: any) {
return this.query.post<T>({ path: 'secret', ...data });
}
async getItem({ id, key }: { id?: string; key?: string }, opts?: PostOpts) {
return this.post({
key: 'get',
data: {
id,
key,
},
...opts,
});
}
async updateItem(data: Config, opts?: PostOpts) {
return this.post({
key: 'update',
data,
...opts,
});
}
async deleteItem(data: { id?: string, key?: string }, opts?: PostOpts) {
return this.post({
key: 'delete',
data,
});
}
async listItems(opts?: PostOpts) {
return this.post<{ list: Config[] }>({
key: 'list',
...opts,
});
}
}