@eoasmxd/freya 0.4.0 → 0.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/core/dist/command/commands/skill-commands.js +5 -4
- package/core/dist/config/config-manager.d.ts +5 -1
- package/core/dist/config/config-manager.js +13 -1
- package/core/dist/kernel.js +2 -2
- package/core/dist/skill/skill-registry.d.ts +12 -2
- package/core/dist/skill/skill-registry.js +89 -8
- package/core/dist/tools/meta/index.d.ts +3 -1
- package/core/dist/tools/meta/index.js +9 -1
- package/core/dist/web/config-api.js +18 -0
- package/core/package.json +2 -2
- package/package.json +3 -2
- package/plugins/plugin-gemini/package.json +1 -1
- package/plugins/plugin-openai/package.json +1 -1
- package/plugins/plugin-telegram-channel/package.json +1 -1
- package/plugins/plugin-tool-fs/package.json +1 -1
- package/plugins/plugin-tool-memory/package.json +1 -1
- package/plugins/plugin-tool-mysql/config/prompts/plugin.prompt.mysql.md +9 -0
- package/plugins/plugin-tool-mysql/config/prompts/plugin.prompt.mysql.select.audit.md +26 -0
- package/plugins/plugin-tool-mysql/dist/audit.d.ts +13 -0
- package/plugins/plugin-tool-mysql/dist/audit.js +87 -0
- package/plugins/plugin-tool-mysql/dist/index.d.ts +14 -0
- package/plugins/plugin-tool-mysql/dist/index.js +34 -0
- package/plugins/plugin-tool-mysql/dist/pool-manager.d.ts +32 -0
- package/plugins/plugin-tool-mysql/dist/pool-manager.js +113 -0
- package/plugins/plugin-tool-mysql/dist/tools.d.ts +11 -0
- package/plugins/plugin-tool-mysql/dist/tools.js +88 -0
- package/plugins/plugin-tool-mysql/package.json +33 -0
- package/plugins/plugin-tool-mysql/schema.json +83 -0
- package/plugins/plugin-tool-web/package.json +1 -1
- package/plugins/plugin-wecom-channel/package.json +1 -1
- package/plugins/plugin-weixin-channel/package.json +1 -1
- package/src/packages/core/src/command/commands/skill-commands.ts +6 -5
- package/src/packages/core/src/config/config-manager.ts +15 -1
- package/src/packages/core/src/kernel.ts +3 -2
- package/src/packages/core/src/skill/skill-registry.ts +100 -8
- package/src/packages/core/src/tools/meta/index.ts +9 -1
- package/src/packages/core/src/web/config-api.ts +20 -0
- package/src/packages/ui/src/features/config/ConfigModal.tsx +13 -1
- package/src/packages/ui/src/features/config/panels/SkillConfigPanel.tsx +137 -0
- package/src/plugins/plugin-tool-mysql/src/audit.ts +103 -0
- package/src/plugins/plugin-tool-mysql/src/index.ts +44 -0
- package/src/plugins/plugin-tool-mysql/src/pool-manager.ts +132 -0
- package/src/plugins/plugin-tool-mysql/src/tools.ts +100 -0
- package/ui/assets/{index-Be0cAgdB.js → index-BqPQMflk.js} +14 -14
- package/ui/index.html +1 -1
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
/** 安全解算嵌套或扁平配置值 */
|
|
3
|
+
function getNestedConfig(config, keyPath) {
|
|
4
|
+
if (!config)
|
|
5
|
+
return undefined;
|
|
6
|
+
if (keyPath in config)
|
|
7
|
+
return config[keyPath];
|
|
8
|
+
const parts = keyPath.split('.');
|
|
9
|
+
let current = config;
|
|
10
|
+
for (const part of parts) {
|
|
11
|
+
if (current && typeof current === 'object' && part in current) {
|
|
12
|
+
current = current[part];
|
|
13
|
+
}
|
|
14
|
+
else {
|
|
15
|
+
return undefined;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
return current;
|
|
19
|
+
}
|
|
20
|
+
/** MySQL 多命名连接池管理器 */
|
|
21
|
+
export class MysqlPoolManager {
|
|
22
|
+
ctx;
|
|
23
|
+
pools = new Map();
|
|
24
|
+
connectionConfigs = new Map();
|
|
25
|
+
defaultConnectionName = 'default';
|
|
26
|
+
constructor(ctx) {
|
|
27
|
+
this.ctx = ctx;
|
|
28
|
+
this.reloadConfigs();
|
|
29
|
+
}
|
|
30
|
+
/** 加载与同步配置 */
|
|
31
|
+
reloadConfigs() {
|
|
32
|
+
this.connectionConfigs.clear();
|
|
33
|
+
const config = this.ctx.config || {};
|
|
34
|
+
this.defaultConnectionName = getNestedConfig(config, 'mysql.defaultConnection') || 'default';
|
|
35
|
+
const connections = getNestedConfig(config, 'mysql.connections');
|
|
36
|
+
if (Array.isArray(connections)) {
|
|
37
|
+
for (const item of connections) {
|
|
38
|
+
if (item && typeof item === 'object' && item.name) {
|
|
39
|
+
this.connectionConfigs.set(item.name, {
|
|
40
|
+
name: String(item.name),
|
|
41
|
+
host: item.host ? String(item.host) : '127.0.0.1',
|
|
42
|
+
port: item.port ? Number(item.port) : 3306,
|
|
43
|
+
user: item.user ? String(item.user) : 'root',
|
|
44
|
+
password: item.password ? String(item.password) : '',
|
|
45
|
+
database: item.database ? String(item.database) : undefined,
|
|
46
|
+
connectionLimit: item.connectionLimit ? Number(item.connectionLimit) : 5,
|
|
47
|
+
connectTimeout: item.connectTimeout ? Number(item.connectTimeout) : 10000
|
|
48
|
+
});
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
if (this.connectionConfigs.size === 0) {
|
|
53
|
+
const singleHost = getNestedConfig(config, 'mysql.host');
|
|
54
|
+
if (singleHost) {
|
|
55
|
+
this.connectionConfigs.set(this.defaultConnectionName, {
|
|
56
|
+
name: this.defaultConnectionName,
|
|
57
|
+
host: String(singleHost),
|
|
58
|
+
port: getNestedConfig(config, 'mysql.port') ? Number(getNestedConfig(config, 'mysql.port')) : 3306,
|
|
59
|
+
user: getNestedConfig(config, 'mysql.user') ? String(getNestedConfig(config, 'mysql.user')) : 'root',
|
|
60
|
+
password: getNestedConfig(config, 'mysql.password') ? String(getNestedConfig(config, 'mysql.password')) : '',
|
|
61
|
+
database: getNestedConfig(config, 'mysql.database') ? String(getNestedConfig(config, 'mysql.database')) : undefined,
|
|
62
|
+
connectionLimit: getNestedConfig(config, 'mysql.connectionLimit') ? Number(getNestedConfig(config, 'mysql.connectionLimit')) : 5,
|
|
63
|
+
connectTimeout: getNestedConfig(config, 'mysql.connectTimeout') ? Number(getNestedConfig(config, 'mysql.connectTimeout')) : 10000
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
/** 获取指定名称的数据库连接池 */
|
|
69
|
+
getPool(targetName) {
|
|
70
|
+
this.reloadConfigs();
|
|
71
|
+
const connectionName = targetName || this.defaultConnectionName;
|
|
72
|
+
const conf = this.connectionConfigs.get(connectionName);
|
|
73
|
+
if (!conf) {
|
|
74
|
+
const available = Array.from(this.connectionConfigs.keys()).join(', ') || '无配置';
|
|
75
|
+
throw new Error(`未找到名为 "${connectionName}" 的 MySQL 连接配置。当前可用连接: [${available}]`);
|
|
76
|
+
}
|
|
77
|
+
let pool = this.pools.get(connectionName);
|
|
78
|
+
if (!pool) {
|
|
79
|
+
pool = mysql.createPool({
|
|
80
|
+
host: conf.host,
|
|
81
|
+
port: conf.port,
|
|
82
|
+
user: conf.user,
|
|
83
|
+
password: conf.password,
|
|
84
|
+
database: conf.database,
|
|
85
|
+
connectionLimit: conf.connectionLimit,
|
|
86
|
+
connectTimeout: conf.connectTimeout,
|
|
87
|
+
waitForConnections: true,
|
|
88
|
+
queueLimit: 0,
|
|
89
|
+
dateStrings: true
|
|
90
|
+
});
|
|
91
|
+
this.pools.set(connectionName, pool);
|
|
92
|
+
this.ctx.logger.info(`MySQL 连接池已创建: [${connectionName}] -> ${conf.user}@${conf.host}:${conf.port}/${conf.database || ''}`);
|
|
93
|
+
}
|
|
94
|
+
return { pool, config: conf };
|
|
95
|
+
}
|
|
96
|
+
/** 获取所有可用连接名称 */
|
|
97
|
+
getAvailableConnectionNames() {
|
|
98
|
+
return Array.from(this.connectionConfigs.keys());
|
|
99
|
+
}
|
|
100
|
+
/** 释放所有已建立的连接池资源 */
|
|
101
|
+
async closeAll() {
|
|
102
|
+
for (const [name, pool] of this.pools.entries()) {
|
|
103
|
+
try {
|
|
104
|
+
await pool.end();
|
|
105
|
+
this.ctx.logger.info(`MySQL 连接池已释放: [${name}]`);
|
|
106
|
+
}
|
|
107
|
+
catch (err) {
|
|
108
|
+
this.ctx.logger.warn(`关闭 MySQL 连接池 [${name}] 时发生异常: ${err?.message || err}`);
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
this.pools.clear();
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import type { FreyaContext, ToolDefinition, FreyaTool } from '@eoasmxd/freya-sdk';
|
|
2
|
+
import type { MysqlPoolManager } from './pool-manager.js';
|
|
3
|
+
import type { SqlAuditService } from './audit.js';
|
|
4
|
+
/** MySQL 数据库查询执行工具 */
|
|
5
|
+
export declare class MysqlQueryTool implements FreyaTool {
|
|
6
|
+
private poolManager;
|
|
7
|
+
private auditService;
|
|
8
|
+
constructor(poolManager: MysqlPoolManager, auditService: SqlAuditService);
|
|
9
|
+
getDefinition(): ToolDefinition;
|
|
10
|
+
execute(args: Record<string, any>, ctx: FreyaContext): Promise<string>;
|
|
11
|
+
}
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
/** 脱敏错误信息中的敏感凭据 */
|
|
2
|
+
function sanitizeErrorMessage(err, password) {
|
|
3
|
+
let message = err?.message || String(err);
|
|
4
|
+
if (password) {
|
|
5
|
+
message = message.replaceAll(password, '******');
|
|
6
|
+
}
|
|
7
|
+
return message;
|
|
8
|
+
}
|
|
9
|
+
/** MySQL 数据库查询执行工具 */
|
|
10
|
+
export class MysqlQueryTool {
|
|
11
|
+
poolManager;
|
|
12
|
+
auditService;
|
|
13
|
+
constructor(poolManager, auditService) {
|
|
14
|
+
this.poolManager = poolManager;
|
|
15
|
+
this.auditService = auditService;
|
|
16
|
+
}
|
|
17
|
+
getDefinition() {
|
|
18
|
+
return {
|
|
19
|
+
name: 'mysql_query',
|
|
20
|
+
description: '执行 MySQL SELECT 查询语句并返回结构化数据。执行前会进行独立 LLM 安全与完整性审核,支持通过 connection 指定目标连接。',
|
|
21
|
+
parameters: {
|
|
22
|
+
type: 'object',
|
|
23
|
+
properties: {
|
|
24
|
+
sql: {
|
|
25
|
+
type: 'string',
|
|
26
|
+
description: '待执行的 SQL 查询语句'
|
|
27
|
+
},
|
|
28
|
+
connection: {
|
|
29
|
+
type: 'string',
|
|
30
|
+
description: '目标数据库连接名称(可选,若未指定则使用系统默认连接)'
|
|
31
|
+
},
|
|
32
|
+
params: {
|
|
33
|
+
type: 'array',
|
|
34
|
+
items: {},
|
|
35
|
+
description: '可选的参数化查询参数列表'
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
required: ['sql']
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async execute(args, ctx) {
|
|
43
|
+
const rawSql = typeof args.sql === 'string' ? args.sql.trim() : '';
|
|
44
|
+
if (!rawSql) {
|
|
45
|
+
return '❌ 参数错误: sql 语句不能为空。';
|
|
46
|
+
}
|
|
47
|
+
const connectionName = typeof args.connection === 'string' && args.connection.trim()
|
|
48
|
+
? args.connection.trim()
|
|
49
|
+
: undefined;
|
|
50
|
+
const queryParams = Array.isArray(args.params) ? args.params : undefined;
|
|
51
|
+
const auditResult = await this.auditService.audit(rawSql, connectionName || 'default', ctx);
|
|
52
|
+
if (!auditResult.passed) {
|
|
53
|
+
return `❌ SQL 审查未通过,已拒绝执行。\n原因: ${auditResult.reason}`;
|
|
54
|
+
}
|
|
55
|
+
let currentPassword = '';
|
|
56
|
+
try {
|
|
57
|
+
const { pool, config } = this.poolManager.getPool(connectionName);
|
|
58
|
+
currentPassword = config.password || '';
|
|
59
|
+
const maxRows = Number(ctx.config?.mysql?.maxRows ?? ctx.config?.['mysql.maxRows']) || 100;
|
|
60
|
+
const [rows] = await pool.query(rawSql, queryParams);
|
|
61
|
+
if (!Array.isArray(rows)) {
|
|
62
|
+
return JSON.stringify({
|
|
63
|
+
connection: config.name,
|
|
64
|
+
database: config.database || null,
|
|
65
|
+
affectedRows: rows.affectedRows ?? 0,
|
|
66
|
+
info: rows.info || '查询已完成'
|
|
67
|
+
}, null, 2);
|
|
68
|
+
}
|
|
69
|
+
const totalCount = rows.length;
|
|
70
|
+
const truncated = totalCount > maxRows;
|
|
71
|
+
const data = truncated ? rows.slice(0, maxRows) : rows;
|
|
72
|
+
return JSON.stringify({
|
|
73
|
+
connection: config.name,
|
|
74
|
+
database: config.database || null,
|
|
75
|
+
totalCount,
|
|
76
|
+
returnedCount: data.length,
|
|
77
|
+
truncated,
|
|
78
|
+
notice: truncated ? `结果集超过最大限制 ${maxRows} 条,已自动截断返回前 ${maxRows} 条记录。` : undefined,
|
|
79
|
+
data
|
|
80
|
+
}, null, 2);
|
|
81
|
+
}
|
|
82
|
+
catch (err) {
|
|
83
|
+
const safeMessage = sanitizeErrorMessage(err, currentPassword);
|
|
84
|
+
ctx.logger.error(`MySQL 查询执行异常: ${safeMessage}`);
|
|
85
|
+
return `❌ MySQL 查询执行失败: ${safeMessage}`;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@eoasmxd/freya-plugin-tool-mysql",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": false,
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"publishConfig": {
|
|
7
|
+
"access": "public"
|
|
8
|
+
},
|
|
9
|
+
"description": "Freya MySQL 数据库查询工具集插件",
|
|
10
|
+
"freya": {
|
|
11
|
+
"displayName": "MySQL 数据库查询工具集",
|
|
12
|
+
"defaultEnabled": false,
|
|
13
|
+
"schema": "./schema.json",
|
|
14
|
+
"prompts": [
|
|
15
|
+
"plugin.prompt.mysql.md",
|
|
16
|
+
"plugin.prompt.mysql.select.audit.md"
|
|
17
|
+
]
|
|
18
|
+
},
|
|
19
|
+
"type": "module",
|
|
20
|
+
"main": "./dist/index.js",
|
|
21
|
+
"scripts": {
|
|
22
|
+
"build": "tsc -p tsconfig.json",
|
|
23
|
+
"clean": "rm -rf dist"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"@eoasmxd/freya-sdk": "^0.4.1",
|
|
27
|
+
"mysql2": "^3.11.0"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@types/node": "^22.0.0",
|
|
31
|
+
"typescript": "^5.5.0"
|
|
32
|
+
}
|
|
33
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
[
|
|
2
|
+
{
|
|
3
|
+
"key": "mysql.defaultConnection",
|
|
4
|
+
"defaultValue": "default",
|
|
5
|
+
"description": "默认使用的数据库连接名称",
|
|
6
|
+
"type": "string",
|
|
7
|
+
"category": "MySQL 连接"
|
|
8
|
+
},
|
|
9
|
+
{
|
|
10
|
+
"key": "mysql.maxRows",
|
|
11
|
+
"defaultValue": 100,
|
|
12
|
+
"description": "单次查询最大返回记录条数(防止大模型上下文窗口溢出)",
|
|
13
|
+
"type": "number",
|
|
14
|
+
"min": 1,
|
|
15
|
+
"max": 1000,
|
|
16
|
+
"category": "MySQL 安全",
|
|
17
|
+
"uiHint": "slider"
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
"key": "mysql.connections",
|
|
21
|
+
"defaultValue": [],
|
|
22
|
+
"description": "MySQL 命名数据库连接配置列表",
|
|
23
|
+
"type": "array",
|
|
24
|
+
"category": "MySQL 连接",
|
|
25
|
+
"children": [
|
|
26
|
+
{
|
|
27
|
+
"key": "name",
|
|
28
|
+
"description": "连接名称 (例如 default、bi_db)",
|
|
29
|
+
"type": "string",
|
|
30
|
+
"required": true
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
"key": "host",
|
|
34
|
+
"description": "数据库主机 (例如 127.0.0.1)",
|
|
35
|
+
"type": "string",
|
|
36
|
+
"required": true
|
|
37
|
+
},
|
|
38
|
+
{
|
|
39
|
+
"key": "port",
|
|
40
|
+
"description": "数据库端口 (默认 3306)",
|
|
41
|
+
"type": "number",
|
|
42
|
+
"defaultValue": 3306,
|
|
43
|
+
"required": true
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
"key": "user",
|
|
47
|
+
"description": "数据库用户名",
|
|
48
|
+
"type": "string",
|
|
49
|
+
"required": true
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
"key": "password",
|
|
53
|
+
"description": "数据库密码",
|
|
54
|
+
"type": "string",
|
|
55
|
+
"required": true,
|
|
56
|
+
"sensitive": true,
|
|
57
|
+
"uiHint": "password"
|
|
58
|
+
},
|
|
59
|
+
{
|
|
60
|
+
"key": "database",
|
|
61
|
+
"description": "数据库名称",
|
|
62
|
+
"type": "string",
|
|
63
|
+
"required": false
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
"key": "connectionLimit",
|
|
67
|
+
"description": "连接池最大连接数 (默认 5)",
|
|
68
|
+
"type": "number",
|
|
69
|
+
"defaultValue": 5,
|
|
70
|
+
"min": 1,
|
|
71
|
+
"max": 50
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
"key": "connectTimeout",
|
|
75
|
+
"description": "连接超时时间 (毫秒,默认 10000)",
|
|
76
|
+
"type": "number",
|
|
77
|
+
"defaultValue": 10000,
|
|
78
|
+
"min": 1000,
|
|
79
|
+
"max": 60000
|
|
80
|
+
}
|
|
81
|
+
]
|
|
82
|
+
}
|
|
83
|
+
]
|
|
@@ -22,11 +22,12 @@ export function registerSkillCommands(deps: SkillCommandDeps): void {
|
|
|
22
22
|
};
|
|
23
23
|
|
|
24
24
|
const handleList = async (): Promise<string> => {
|
|
25
|
-
|
|
25
|
+
const activeSkills = Array.from(skills.values()).filter((s) => s.enabled !== false);
|
|
26
|
+
if (activeSkills.length === 0) {
|
|
26
27
|
return '📋 暂无可用技能。';
|
|
27
28
|
}
|
|
28
|
-
const lines =
|
|
29
|
-
`- **${s.name}** — ${s.description || '无描述'}`
|
|
29
|
+
const lines = activeSkills.map((s) =>
|
|
30
|
+
`- **${s.name}** \`${s.id}\` — ${s.description || '无描述'}`
|
|
30
31
|
).join('\n');
|
|
31
32
|
return `### 📋 可用技能\n\n${lines}`;
|
|
32
33
|
};
|
|
@@ -36,8 +37,8 @@ export function registerSkillCommands(deps: SkillCommandDeps): void {
|
|
|
36
37
|
return '❌ 用法:`\`/skill set <skillId>\``。';
|
|
37
38
|
}
|
|
38
39
|
const skill = skills.get(skillId);
|
|
39
|
-
if (!skill) {
|
|
40
|
-
return `❌ 技能 \`${skillId}\`
|
|
40
|
+
if (!skill || skill.enabled === false) {
|
|
41
|
+
return `❌ 技能 \`${skillId}\` 不存在或未启用。请使用 \`/skill list\` 查看可用技能。`;
|
|
41
42
|
}
|
|
42
43
|
await sessionManager.updateSession(sessionId, { activeSkillId: skillId });
|
|
43
44
|
return `✅ 已激活技能: **${skill.name}** \`${skillId}\`。`;
|
|
@@ -4,6 +4,7 @@ import type { FreyaPluginManager } from '../plugin/plugin-manager.js';
|
|
|
4
4
|
import type { FreyaPromptManager } from '../prompt/prompt-manager.js';
|
|
5
5
|
import { FreyaConfigFileHandler } from './file-handler.js';
|
|
6
6
|
import { FreyaConfigSchemaRegistry } from './schema-registry.js';
|
|
7
|
+
import type { FreyaSkillRegistry, FreyaSkill } from '../skill/skill-registry.js';
|
|
7
8
|
import path from 'node:path';
|
|
8
9
|
import { PROJECT_ROOT } from '../utils/paths.js';
|
|
9
10
|
|
|
@@ -155,19 +156,22 @@ export class FreyaConfigManager {
|
|
|
155
156
|
private llmRegistry: FreyaLLMRegistry;
|
|
156
157
|
private pluginManager: FreyaPluginManager;
|
|
157
158
|
private promptManager: FreyaPromptManager;
|
|
159
|
+
private skillRegistry?: FreyaSkillRegistry;
|
|
158
160
|
|
|
159
161
|
constructor(
|
|
160
162
|
context: FreyaContext,
|
|
161
163
|
schemaRegistry: FreyaConfigSchemaRegistry,
|
|
162
164
|
promptManager: FreyaPromptManager,
|
|
163
165
|
llmRegistry: FreyaLLMRegistry,
|
|
164
|
-
pluginManager: FreyaPluginManager
|
|
166
|
+
pluginManager: FreyaPluginManager,
|
|
167
|
+
skillRegistry?: FreyaSkillRegistry
|
|
165
168
|
) {
|
|
166
169
|
this.context = context;
|
|
167
170
|
this.schemaRegistry = schemaRegistry;
|
|
168
171
|
this.promptManager = promptManager;
|
|
169
172
|
this.llmRegistry = llmRegistry;
|
|
170
173
|
this.pluginManager = pluginManager;
|
|
174
|
+
this.skillRegistry = skillRegistry;
|
|
171
175
|
}
|
|
172
176
|
|
|
173
177
|
/** 获取全部敏感字段的 keyPath 列表 */
|
|
@@ -480,6 +484,16 @@ export class FreyaConfigManager {
|
|
|
480
484
|
return await this.pluginManager.togglePlugin(pluginId, enabled);
|
|
481
485
|
}
|
|
482
486
|
|
|
487
|
+
listSkills(): FreyaSkill[] {
|
|
488
|
+
if (!this.skillRegistry) return [];
|
|
489
|
+
return this.skillRegistry.getAllSkills();
|
|
490
|
+
}
|
|
491
|
+
|
|
492
|
+
async toggleSkill(skillId: string, enabled: boolean): Promise<string> {
|
|
493
|
+
if (!this.skillRegistry) return '❌ 技能注册表服务未初始化。';
|
|
494
|
+
return await this.skillRegistry.toggleSkill(skillId, enabled);
|
|
495
|
+
}
|
|
496
|
+
|
|
483
497
|
async readPrompt(name: string): Promise<string> {
|
|
484
498
|
const promptName = String(name).trim().toUpperCase();
|
|
485
499
|
if (!ALLOWED_PROMPTS.has(promptName)) {
|
|
@@ -68,7 +68,8 @@ export class FreyaKernel {
|
|
|
68
68
|
configSchemaRegistry,
|
|
69
69
|
promptManager,
|
|
70
70
|
llmRegistry,
|
|
71
|
-
this.pluginManager
|
|
71
|
+
this.pluginManager,
|
|
72
|
+
skillRegistry
|
|
72
73
|
);
|
|
73
74
|
|
|
74
75
|
configManager.registerCoreSchema();
|
|
@@ -96,7 +97,7 @@ export class FreyaKernel {
|
|
|
96
97
|
|
|
97
98
|
const configToolbox = new ConfigToolbox(configManager, ctx);
|
|
98
99
|
const sessionToolbox = new SessionToolbox(this.sessionManager);
|
|
99
|
-
const metaToolbox = new FreyaMetaToolbox(this.sessionManager, toolRegistry);
|
|
100
|
+
const metaToolbox = new FreyaMetaToolbox(this.sessionManager, toolRegistry, skillRegistry);
|
|
100
101
|
toolRegistry.registerToolbox(configToolbox);
|
|
101
102
|
toolRegistry.registerToolbox(sessionToolbox);
|
|
102
103
|
toolRegistry.registerToolbox(metaToolbox);
|
|
@@ -8,27 +8,66 @@ export interface FreyaSkill {
|
|
|
8
8
|
name: string;
|
|
9
9
|
description: string;
|
|
10
10
|
content: string;
|
|
11
|
+
enabled: boolean;
|
|
12
|
+
source: 'builtin' | 'runtime';
|
|
11
13
|
}
|
|
12
14
|
|
|
13
|
-
/** 技能注册表,从 skills/ 目录加载 Markdown
|
|
15
|
+
/** 技能注册表,从 skills/ 目录加载 Markdown 格式技能并管理软开关状态 */
|
|
14
16
|
export class FreyaSkillRegistry {
|
|
15
17
|
private skills = new Map<string, FreyaSkill>();
|
|
18
|
+
private context?: FreyaContext;
|
|
16
19
|
|
|
17
20
|
async loadSkills(context: FreyaContext): Promise<void> {
|
|
21
|
+
this.context = context;
|
|
18
22
|
const runtimeSkillsDir = path.join(PROJECT_ROOT, 'skills');
|
|
19
23
|
const defaultSkillsDir = path.join(APP_ROOT, 'skills');
|
|
24
|
+
const configSkillsPath = path.join(PROJECT_ROOT, 'config', 'skills.json');
|
|
25
|
+
|
|
20
26
|
try {
|
|
21
27
|
await fs.mkdir(runtimeSkillsDir, { recursive: true });
|
|
22
|
-
await this.loadSkillsFromDirectory(defaultSkillsDir, context);
|
|
23
|
-
await this.loadSkillsFromDirectory(runtimeSkillsDir, context);
|
|
24
|
-
|
|
28
|
+
await this.loadSkillsFromDirectory(defaultSkillsDir, 'builtin', context);
|
|
29
|
+
await this.loadSkillsFromDirectory(runtimeSkillsDir, 'runtime', context);
|
|
30
|
+
|
|
31
|
+
let configList: Array<{ id: string; enabled: boolean }> = [];
|
|
32
|
+
try {
|
|
33
|
+
const raw = await fs.readFile(configSkillsPath, 'utf-8');
|
|
34
|
+
const parsed = JSON.parse(raw);
|
|
35
|
+
if (Array.isArray(parsed)) {
|
|
36
|
+
configList = parsed;
|
|
37
|
+
}
|
|
38
|
+
} catch {
|
|
39
|
+
configList = [];
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const configMap = new Map<string, boolean>();
|
|
43
|
+
for (const item of configList) {
|
|
44
|
+
if (item && typeof item.id === 'string' && typeof item.enabled === 'boolean') {
|
|
45
|
+
configMap.set(item.id, item.enabled);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
let configChanged = false;
|
|
50
|
+
for (const skill of this.skills.values()) {
|
|
51
|
+
if (configMap.has(skill.id)) {
|
|
52
|
+
skill.enabled = configMap.get(skill.id)!;
|
|
53
|
+
} else {
|
|
54
|
+
configChanged = true;
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (configChanged || configList.length !== this.skills.size) {
|
|
59
|
+
await this.persistSkillsConfig(configSkillsPath);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const enabledCount = Array.from(this.skills.values()).filter((s) => s.enabled).length;
|
|
63
|
+
context.logger.info(`动态技能扫描完成。共加载 ${this.skills.size} 个物理技能 (已启用: ${enabledCount})。`);
|
|
25
64
|
} catch (err: any) {
|
|
26
65
|
context.logger.error('扫描物理技能 skills 目录遭遇故障:', err);
|
|
27
66
|
}
|
|
28
67
|
}
|
|
29
68
|
|
|
30
69
|
/** 从指定目录加载技能到内存注册表中 */
|
|
31
|
-
private async loadSkillsFromDirectory(dirPath: string, context: FreyaContext): Promise<void> {
|
|
70
|
+
private async loadSkillsFromDirectory(dirPath: string, source: 'builtin' | 'runtime', context: FreyaContext): Promise<void> {
|
|
32
71
|
try {
|
|
33
72
|
const files = await fs.readdir(dirPath);
|
|
34
73
|
for (const file of files) {
|
|
@@ -36,11 +75,17 @@ export class FreyaSkillRegistry {
|
|
|
36
75
|
const rawContent = await fs.readFile(path.join(dirPath, file), 'utf-8');
|
|
37
76
|
const { metadata, content } = this.parseFrontmatter(rawContent);
|
|
38
77
|
if (metadata.id) {
|
|
78
|
+
const defaultEnabled = metadata.defaultEnabled !== undefined
|
|
79
|
+
? metadata.defaultEnabled !== 'false'
|
|
80
|
+
: source === 'builtin';
|
|
81
|
+
|
|
39
82
|
this.skills.set(metadata.id, {
|
|
40
83
|
id: metadata.id,
|
|
41
84
|
name: metadata.name || file.replace('.md', ''),
|
|
42
85
|
description: metadata.description || '',
|
|
43
|
-
content: content.trim()
|
|
86
|
+
content: content.trim(),
|
|
87
|
+
enabled: defaultEnabled,
|
|
88
|
+
source
|
|
44
89
|
});
|
|
45
90
|
}
|
|
46
91
|
}
|
|
@@ -50,8 +95,55 @@ export class FreyaSkillRegistry {
|
|
|
50
95
|
}
|
|
51
96
|
}
|
|
52
97
|
|
|
53
|
-
|
|
54
|
-
|
|
98
|
+
/** 获取技能列表(默认只返回启用状态的技能) */
|
|
99
|
+
getSkills(onlyEnabled: boolean = true): Map<string, FreyaSkill> {
|
|
100
|
+
if (!onlyEnabled) {
|
|
101
|
+
return this.skills;
|
|
102
|
+
}
|
|
103
|
+
const filtered = new Map<string, FreyaSkill>();
|
|
104
|
+
for (const [id, skill] of this.skills.entries()) {
|
|
105
|
+
if (skill.enabled) {
|
|
106
|
+
filtered.set(id, skill);
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
return filtered;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** 获取全量技能数组(供管理控制台使用) */
|
|
113
|
+
getAllSkills(): FreyaSkill[] {
|
|
114
|
+
return Array.from(this.skills.values());
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** 切换技能的启用/禁用状态并持久化 */
|
|
118
|
+
async toggleSkill(skillId: string, enabled: boolean): Promise<string> {
|
|
119
|
+
const skill = this.skills.get(skillId);
|
|
120
|
+
if (!skill) {
|
|
121
|
+
return `❌ 未找到 ID 为 "${skillId}" 的技能,请检查名称是否正确。`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
if (skill.enabled === enabled) {
|
|
125
|
+
return `ℹ️ 技能 "${skill.name || skillId}" 状态已是 ${enabled ? '启用' : '禁用'}。`;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
skill.enabled = enabled;
|
|
129
|
+
const configSkillsPath = path.join(PROJECT_ROOT, 'config', 'skills.json');
|
|
130
|
+
try {
|
|
131
|
+
await this.persistSkillsConfig(configSkillsPath);
|
|
132
|
+
this.context?.logger.info(`技能 "${skill.name || skillId}" 已切换为: ${enabled ? '启用' : '禁用'}`);
|
|
133
|
+
return `✅ 技能 "${skill.name || skillId}" 已成功${enabled ? '启用' : '禁用'}。`;
|
|
134
|
+
} catch (err: any) {
|
|
135
|
+
return `❌ 技能状态变更成功,但写入 skills.json 失败: ${err.message}`;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/** 持久化当前所有技能启停状态至 skills.json */
|
|
140
|
+
private async persistSkillsConfig(configSkillsPath: string): Promise<void> {
|
|
141
|
+
await fs.mkdir(path.dirname(configSkillsPath), { recursive: true });
|
|
142
|
+
const payload = Array.from(this.skills.values()).map((s) => ({
|
|
143
|
+
id: s.id,
|
|
144
|
+
enabled: s.enabled
|
|
145
|
+
}));
|
|
146
|
+
await fs.writeFile(configSkillsPath, JSON.stringify(payload, null, 2) + '\n', 'utf-8');
|
|
55
147
|
}
|
|
56
148
|
|
|
57
149
|
get(id: string): FreyaSkill | undefined {
|
|
@@ -1,11 +1,13 @@
|
|
|
1
1
|
import type { FreyaTool, FreyaToolbox } from '@eoasmxd/freya-sdk';
|
|
2
2
|
import type { FreyaSessionManager } from '../../session/session-manager.js';
|
|
3
3
|
import type { FreyaToolRegistry } from '../tool-registry.js';
|
|
4
|
+
import type { FreyaSkillRegistry } from '../../skill/skill-registry.js';
|
|
4
5
|
|
|
5
6
|
export class FreyaMetaToolbox implements FreyaToolbox {
|
|
6
7
|
constructor(
|
|
7
8
|
private sessionManager: FreyaSessionManager,
|
|
8
|
-
private toolRegistry?: FreyaToolRegistry
|
|
9
|
+
private toolRegistry?: FreyaToolRegistry,
|
|
10
|
+
private skillRegistry?: FreyaSkillRegistry
|
|
9
11
|
) {}
|
|
10
12
|
|
|
11
13
|
getId(): string {
|
|
@@ -106,6 +108,12 @@ export class FreyaMetaToolbox implements FreyaToolbox {
|
|
|
106
108
|
if (!sessionId) {
|
|
107
109
|
return '❌ 错误:无法从执行上下文中提取当前会话ID。';
|
|
108
110
|
}
|
|
111
|
+
if (this.skillRegistry) {
|
|
112
|
+
const skill = this.skillRegistry.get(skillId);
|
|
113
|
+
if (!skill || !skill.enabled) {
|
|
114
|
+
return `❌ 错误:技能 [${skillId}] 不存在或已被系统管理员禁用,无法切入该模式。`;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
109
117
|
await this.sessionManager.updateSession(sessionId, { activeSkillId: skillId });
|
|
110
118
|
return `已成功切入 [${skillId}] 技能特长工作模式。`;
|
|
111
119
|
}
|
|
@@ -199,6 +199,26 @@ export class FreyaConfigApi {
|
|
|
199
199
|
return true;
|
|
200
200
|
}
|
|
201
201
|
|
|
202
|
+
if (pathname === '/api/config/skills' && req.method === 'GET') {
|
|
203
|
+
const skills = this.configManager.listSkills();
|
|
204
|
+
res.writeHead(200, this.headers);
|
|
205
|
+
res.end(JSON.stringify({ success: true, data: skills }));
|
|
206
|
+
return true;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (pathname === '/api/config/skills/toggle' && req.method === 'POST') {
|
|
210
|
+
const { skillId, enabled } = await this.getBody(req);
|
|
211
|
+
if (!skillId) {
|
|
212
|
+
res.writeHead(200, this.headers);
|
|
213
|
+
res.end(JSON.stringify({ success: false, error: '缺少必要参数: skillId' }));
|
|
214
|
+
return true;
|
|
215
|
+
}
|
|
216
|
+
const msg = await this.configManager.toggleSkill(skillId, enabled);
|
|
217
|
+
res.writeHead(200, this.headers);
|
|
218
|
+
res.end(JSON.stringify({ success: !msg.startsWith('❌'), message: msg }));
|
|
219
|
+
return true;
|
|
220
|
+
}
|
|
221
|
+
|
|
202
222
|
const promptMatch = pathname.match(/^\/api\/config\/prompts\/([^/]+)$/);
|
|
203
223
|
if (promptMatch && req.method === 'GET') {
|
|
204
224
|
const promptName = decodeURIComponent(promptMatch[1]);
|