@eoasmxd/freya 0.3.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/README.md +9 -3
- 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/doc/_index.md +20 -7
- package/doc/getting-started.md +1 -1
- package/doc/installation-guide.md +2 -2
- package/doc/{architecture-design.md → specifications/architecture-design.md} +7 -4
- package/doc/{config-spec.md → specifications/config-spec.md} +1 -1
- package/doc/{llm-interface-params.md → specifications/llm-interface-params.md} +8 -8
- package/doc/{prompt-system.md → specifications/prompt-system.md} +1 -1
- package/doc/tutorials/_index.md +96 -0
- package/doc/tutorials/part0_basic/0.1_probability_prediction.md +155 -0
- package/doc/tutorials/part0_basic/0.2_attention_and_context.md +145 -0
- package/doc/tutorials/part0_basic/0.3_generation_parameters.md +132 -0
- package/doc/tutorials/part0_basic/0.4_debugging_token.md +99 -0
- package/doc/tutorials/part0_basic/1.1_stateless_and_history.md +143 -0
- package/doc/tutorials/part0_basic/1.2_chat_data_structure.md +179 -0
- package/doc/tutorials/part0_basic/1.3_system_user_assistant.md +147 -0
- package/doc/tutorials/part0_basic/1.4_freya_model_proxy.md +203 -0
- package/doc/tutorials/part0_basic/_index.md +28 -0
- package/doc/tutorials/part1_react/2.1_agency_vs_chatbot.md +127 -0
- package/doc/tutorials/part1_react/2.2_react_mind_model.md +144 -0
- package/doc/tutorials/part1_react/2.3_freya_agent_executor.md +233 -0
- package/doc/tutorials/part1_react/2.4_debugging_loop_deadlock.md +160 -0
- package/doc/tutorials/part1_react/3.1_hardcoded_prompt_pain.md +104 -0
- package/doc/tutorials/part1_react/3.2_decoupled_architecture.md +138 -0
- package/doc/tutorials/part1_react/3.3_freya_dual_read_probe.md +152 -0
- package/doc/tutorials/part1_react/3.4_debugging_composition_placeholder.md +97 -0
- package/doc/tutorials/part1_react/_index.md +28 -0
- package/doc/tutorials/part2_tools/4.1_json_schema_mapping.md +119 -0
- package/doc/tutorials/part2_tools/4.2_tool_call_raw_packet.md +105 -0
- package/doc/tutorials/part2_tools/4.3_freya_tool_execution.md +145 -0
- package/doc/tutorials/part2_tools/4.4_debugging_observation_fix.md +154 -0
- package/doc/tutorials/part2_tools/5.1_observation_injection.md +131 -0
- package/doc/tutorials/part2_tools/5.2_openai_vs_gemini_protocol.md +125 -0
- package/doc/tutorials/part2_tools/5.3_freya_llm_proxy_mapping.md +162 -0
- package/doc/tutorials/part2_tools/5.4_debugging_parallel_call_chaos.md +135 -0
- package/doc/tutorials/part2_tools/_index.md +28 -0
- package/doc/tutorials/part3_memory/6.1_session_state_lifecycle.md +143 -0
- package/doc/tutorials/part3_memory/6.2_physical_sandbox_separation.md +108 -0
- package/doc/tutorials/part3_memory/6.3_freya_session_storage.md +139 -0
- package/doc/tutorials/part3_memory/6.4_debugging_session_concurrency.md +161 -0
- package/doc/tutorials/part3_memory/7.1_context_overflow_loss.md +109 -0
- package/doc/tutorials/part3_memory/7.2_sliding_window_vs_summary.md +85 -0
- package/doc/tutorials/part3_memory/7.3_freya_compactor_impl.md +142 -0
- package/doc/tutorials/part3_memory/7.4_debugging_summarize_deadlock.md +160 -0
- package/doc/tutorials/part3_memory/_index.md +28 -0
- package/doc/tutorials/part4_streaming/8.1_sse_protocol_basics.md +119 -0
- package/doc/tutorials/part4_streaming/8.2_hiding_thoughts_in_stream.md +132 -0
- package/doc/tutorials/part4_streaming/8.3_freya_event_bus.md +104 -0
- package/doc/tutorials/part4_streaming/8.4_debugging_stream_decoder.md +158 -0
- package/doc/tutorials/part4_streaming/9.1_abort_signal_braking.md +162 -0
- package/doc/tutorials/part4_streaming/9.2_async_event_channels.md +142 -0
- package/doc/tutorials/part4_streaming/9.3_freya_abort_billing.md +122 -0
- package/doc/tutorials/part4_streaming/9.4_debugging_abort_lock_deadlock.md +187 -0
- package/doc/tutorials/part4_streaming/_index.md +28 -0
- package/doc/tutorials/part5_plugins/10.1_microkernel_decoupling.md +142 -0
- package/doc/tutorials/part5_plugins/10.2_plugin_metadata_security.md +125 -0
- package/doc/tutorials/part5_plugins/10.3_channel_plugin_development.md +149 -0
- package/doc/tutorials/part5_plugins/10.4_debugging_channel_reconnection.md +160 -0
- package/doc/tutorials/part5_plugins/_index.md +21 -0
- package/doc/tutorials/part6_advanced/11.1_react_model_flaws.md +111 -0
- package/doc/tutorials/part6_advanced/11.2_reflexion_mind_model.md +103 -0
- package/doc/tutorials/part6_advanced/11.3_reflexion_hands_on.md +182 -0
- package/doc/tutorials/part6_advanced/11.4_debugging_reflexion_convergence.md +108 -0
- package/doc/tutorials/part6_advanced/12.1_single_agent_limits.md +100 -0
- package/doc/tutorials/part6_advanced/12.2_multi_agent_patterns.md +121 -0
- package/doc/tutorials/part6_advanced/12.3_freya_multi_agent_routing.md +143 -0
- package/doc/tutorials/part6_advanced/12.4_multi_agent_hands_on.md +176 -0
- package/doc/tutorials/part6_advanced/_index.md +28 -0
- package/doc/tutorials/preface.md +30 -0
- 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
|
@@ -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]);
|
|
@@ -3,6 +3,7 @@ import { GlobalConfigPanel } from './panels/GlobalConfigPanel.jsx';
|
|
|
3
3
|
import { ProviderConfigPanel } from './panels/ProviderConfigPanel.jsx';
|
|
4
4
|
import { PromptConfigPanel } from './panels/PromptConfigPanel.jsx';
|
|
5
5
|
import { PluginConfigPanel } from './panels/PluginConfigPanel.jsx';
|
|
6
|
+
import { SkillConfigPanel } from './panels/SkillConfigPanel.jsx';
|
|
6
7
|
|
|
7
8
|
interface ConfigModalProps {
|
|
8
9
|
onClose: () => void;
|
|
@@ -13,13 +14,14 @@ export const ConfigModal: React.FC<ConfigModalProps> = ({
|
|
|
13
14
|
onClose,
|
|
14
15
|
getApiUrl
|
|
15
16
|
}) => {
|
|
16
|
-
const [activeTab, setActiveTab] = useState<'global' | 'providers' | 'prompts' | 'plugins'>('global');
|
|
17
|
+
const [activeTab, setActiveTab] = useState<'global' | 'providers' | 'prompts' | 'plugins' | 'skills'>('global');
|
|
17
18
|
|
|
18
19
|
const getTabTitle = () => {
|
|
19
20
|
if (activeTab === 'global') return '全局参数设置';
|
|
20
21
|
if (activeTab === 'providers') return '大模型提供商与模型管理';
|
|
21
22
|
if (activeTab === 'prompts') return '系统提示词管理';
|
|
22
23
|
if (activeTab === 'plugins') return '扩展插件管理';
|
|
24
|
+
if (activeTab === 'skills') return '技能卡管理';
|
|
23
25
|
return '';
|
|
24
26
|
};
|
|
25
27
|
|
|
@@ -55,6 +57,12 @@ export const ConfigModal: React.FC<ConfigModalProps> = ({
|
|
|
55
57
|
>
|
|
56
58
|
扩展插件
|
|
57
59
|
</button>
|
|
60
|
+
<button
|
|
61
|
+
className={`tab-btn ${activeTab === 'skills' ? 'active' : ''}`}
|
|
62
|
+
onClick={() => setActiveTab('skills')}
|
|
63
|
+
>
|
|
64
|
+
技能卡管理
|
|
65
|
+
</button>
|
|
58
66
|
</div>
|
|
59
67
|
</div>
|
|
60
68
|
|
|
@@ -85,6 +93,10 @@ export const ConfigModal: React.FC<ConfigModalProps> = ({
|
|
|
85
93
|
{activeTab === 'plugins' && (
|
|
86
94
|
<PluginConfigPanel getApiUrl={getApiUrl} />
|
|
87
95
|
)}
|
|
96
|
+
|
|
97
|
+
{activeTab === 'skills' && (
|
|
98
|
+
<SkillConfigPanel getApiUrl={getApiUrl} />
|
|
99
|
+
)}
|
|
88
100
|
</div>
|
|
89
101
|
</div>
|
|
90
102
|
</div>
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
import React, { useEffect, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
interface SkillEntry {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
description: string;
|
|
7
|
+
enabled: boolean;
|
|
8
|
+
source: 'builtin' | 'runtime';
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface SkillConfigPanelProps {
|
|
12
|
+
getApiUrl: (path: string) => string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const SkillConfigPanel: React.FC<SkillConfigPanelProps> = ({ getApiUrl }) => {
|
|
16
|
+
const [skills, setSkills] = useState<SkillEntry[]>([]);
|
|
17
|
+
const [loading, setLoading] = useState<boolean>(true);
|
|
18
|
+
const [toasts, setToasts] = useState<{ id: string; message: string; type: 'success' | 'error' | 'info' }[]>([]);
|
|
19
|
+
|
|
20
|
+
const showToast = (message: string, type: 'success' | 'error' | 'info' = 'success') => {
|
|
21
|
+
const id = (typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function')
|
|
22
|
+
? crypto.randomUUID()
|
|
23
|
+
: Math.random().toString(36).substring(2, 15);
|
|
24
|
+
setToasts(prev => [...prev, { id, message, type }]);
|
|
25
|
+
setTimeout(() => {
|
|
26
|
+
setToasts(prev => prev.filter(t => t.id !== id));
|
|
27
|
+
}, 3000);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
const loadSkills = async () => {
|
|
31
|
+
try {
|
|
32
|
+
setLoading(true);
|
|
33
|
+
const res = await fetch(getApiUrl('/api/config/skills'));
|
|
34
|
+
const json = await res.json();
|
|
35
|
+
if (json.success && Array.isArray(json.data)) {
|
|
36
|
+
setSkills(json.data);
|
|
37
|
+
}
|
|
38
|
+
} catch (err) {
|
|
39
|
+
console.error('加载技能列表失败:', err);
|
|
40
|
+
showToast('加载技能列表失败', 'error');
|
|
41
|
+
} finally {
|
|
42
|
+
setLoading(false);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
useEffect(() => {
|
|
47
|
+
loadSkills();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const toggleSkill = async (skillId: string, enabled: boolean) => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch(getApiUrl('/api/config/skills/toggle'), {
|
|
53
|
+
method: 'POST',
|
|
54
|
+
headers: { 'Content-Type': 'application/json' },
|
|
55
|
+
body: JSON.stringify({ skillId, enabled })
|
|
56
|
+
});
|
|
57
|
+
const json = await res.json();
|
|
58
|
+
if (json.success) {
|
|
59
|
+
showToast(`技能 "${skillId}" 已${enabled ? '启用' : '禁用'}`, 'success');
|
|
60
|
+
loadSkills();
|
|
61
|
+
} else {
|
|
62
|
+
showToast(`切换技能状态失败: ${json.message || json.error}`, 'error');
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
console.error('切换技能状态失败:', err);
|
|
66
|
+
showToast('切换技能状态失败', 'error');
|
|
67
|
+
}
|
|
68
|
+
};
|
|
69
|
+
|
|
70
|
+
if (loading && skills.length === 0) {
|
|
71
|
+
return <div style={{ padding: '1rem', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>正在加载技能卡配置...</div>;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (!loading && skills.length === 0) {
|
|
75
|
+
return (
|
|
76
|
+
<div style={{ padding: '2rem 1rem', textAlign: 'center', color: 'var(--text-secondary)', fontSize: '0.85rem' }}>
|
|
77
|
+
暂未扫描到任何技能卡。可在 <code>~/.freya/skills/</code> 目录下添加 <code>*.md</code> 技能卡文件。
|
|
78
|
+
</div>
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div className="plugins-list">
|
|
84
|
+
{skills.map((skill) => {
|
|
85
|
+
const displayName = skill.name || skill.id;
|
|
86
|
+
const displayDesc = skill.description || '未提供描述信息';
|
|
87
|
+
const isBuiltin = skill.source === 'builtin';
|
|
88
|
+
|
|
89
|
+
return (
|
|
90
|
+
<div key={skill.id} className="plugin-card">
|
|
91
|
+
<div>
|
|
92
|
+
<div className="plugin-title">
|
|
93
|
+
{displayName}
|
|
94
|
+
<span style={{ fontSize: '0.72rem', color: 'var(--text-secondary)', fontWeight: 'normal', marginLeft: '0.5rem' }}>
|
|
95
|
+
({skill.id})
|
|
96
|
+
</span>
|
|
97
|
+
<span
|
|
98
|
+
style={{
|
|
99
|
+
fontSize: '0.68rem',
|
|
100
|
+
marginLeft: '0.5rem',
|
|
101
|
+
padding: '0.1rem 0.4rem',
|
|
102
|
+
borderRadius: '4px',
|
|
103
|
+
background: isBuiltin ? 'rgba(59, 130, 246, 0.15)' : 'rgba(16, 185, 129, 0.15)',
|
|
104
|
+
color: isBuiltin ? '#60a5fa' : '#34d399'
|
|
105
|
+
}}
|
|
106
|
+
>
|
|
107
|
+
{isBuiltin ? '内置' : '自定义'}
|
|
108
|
+
</span>
|
|
109
|
+
</div>
|
|
110
|
+
<div className="plugin-desc">{displayDesc}</div>
|
|
111
|
+
</div>
|
|
112
|
+
<div>
|
|
113
|
+
<label className="switch">
|
|
114
|
+
<input
|
|
115
|
+
type="checkbox"
|
|
116
|
+
checked={skill.enabled}
|
|
117
|
+
onChange={(e) => toggleSkill(skill.id, e.target.checked)}
|
|
118
|
+
/>
|
|
119
|
+
<span className="slider" />
|
|
120
|
+
</label>
|
|
121
|
+
</div>
|
|
122
|
+
</div>
|
|
123
|
+
);
|
|
124
|
+
})}
|
|
125
|
+
|
|
126
|
+
{toasts.length > 0 && (
|
|
127
|
+
<div className="toast-container">
|
|
128
|
+
{toasts.map(t => (
|
|
129
|
+
<div key={t.id} className={`toast-card ${t.type}`}>
|
|
130
|
+
<span>{t.message}</span>
|
|
131
|
+
</div>
|
|
132
|
+
))}
|
|
133
|
+
</div>
|
|
134
|
+
)}
|
|
135
|
+
</div>
|
|
136
|
+
);
|
|
137
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { fileURLToPath } from 'node:url';
|
|
4
|
+
import type { FreyaContext, LLMMessage } from '@eoasmxd/freya-sdk';
|
|
5
|
+
|
|
6
|
+
export interface AuditResult {
|
|
7
|
+
passed: boolean;
|
|
8
|
+
reason: string;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/** 前置 SQL 安全与完整性审查审计服务 */
|
|
12
|
+
export class SqlAuditService {
|
|
13
|
+
private cachedPrompt: string | null = null;
|
|
14
|
+
|
|
15
|
+
/** 双通道探针加载审计提示词模板 */
|
|
16
|
+
private async loadAuditPrompt(ctx: FreyaContext): Promise<string> {
|
|
17
|
+
if (this.cachedPrompt) {
|
|
18
|
+
return this.cachedPrompt;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
const promptFileName = 'plugin.prompt.mysql.select.audit.md';
|
|
22
|
+
const runtimeOverridePath = path.join(ctx.paths.projectRoot, 'config', 'prompts', promptFileName);
|
|
23
|
+
|
|
24
|
+
try {
|
|
25
|
+
const content = await fs.readFile(runtimeOverridePath, 'utf-8');
|
|
26
|
+
if (content.trim()) {
|
|
27
|
+
this.cachedPrompt = content;
|
|
28
|
+
return content;
|
|
29
|
+
}
|
|
30
|
+
} catch {
|
|
31
|
+
// 运行时覆盖不存在,继续降级读取内置模板
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const currentDir = path.dirname(fileURLToPath(import.meta.url));
|
|
35
|
+
const packageDefaultPath = path.resolve(currentDir, '..', 'config', 'prompts', promptFileName);
|
|
36
|
+
|
|
37
|
+
try {
|
|
38
|
+
const content = await fs.readFile(packageDefaultPath, 'utf-8');
|
|
39
|
+
this.cachedPrompt = content;
|
|
40
|
+
return content;
|
|
41
|
+
} catch (err: any) {
|
|
42
|
+
ctx.logger.error(`加载内置 SQL 审计提示词模板失败: ${packageDefaultPath}`, err);
|
|
43
|
+
return '';
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** 执行前置独立 LLM 分析审查 */
|
|
48
|
+
public async audit(sql: string, connectionName: string, ctx: FreyaContext): Promise<AuditResult> {
|
|
49
|
+
const trimmedSql = sql.trim();
|
|
50
|
+
if (!trimmedSql) {
|
|
51
|
+
return { passed: false, reason: 'SQL 语句内容不能为空。' };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const auditPrompt = await this.loadAuditPrompt(ctx);
|
|
55
|
+
if (!auditPrompt) {
|
|
56
|
+
return { passed: false, reason: 'SQL 审计提示词模板未就绪,安全拒绝执行。' };
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
const messages: LLMMessage[] = [
|
|
60
|
+
{
|
|
61
|
+
role: 'system',
|
|
62
|
+
content: auditPrompt
|
|
63
|
+
},
|
|
64
|
+
{
|
|
65
|
+
role: 'user',
|
|
66
|
+
content: JSON.stringify({
|
|
67
|
+
connection: connectionName,
|
|
68
|
+
sql: trimmedSql
|
|
69
|
+
}, null, 2)
|
|
70
|
+
}
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
try {
|
|
74
|
+
const response = await ctx.llm.chat(messages);
|
|
75
|
+
const rawOutput = response.message?.content?.trim() || '';
|
|
76
|
+
|
|
77
|
+
const cleaned = rawOutput
|
|
78
|
+
.replace(/^```json\s*/i, '')
|
|
79
|
+
.replace(/^```\s*/i, '')
|
|
80
|
+
.replace(/\s*```$/i, '')
|
|
81
|
+
.trim();
|
|
82
|
+
|
|
83
|
+
const parsed = JSON.parse(cleaned);
|
|
84
|
+
if (typeof parsed.passed === 'boolean') {
|
|
85
|
+
return {
|
|
86
|
+
passed: parsed.passed,
|
|
87
|
+
reason: parsed.reason ? String(parsed.reason) : (parsed.passed ? '审查通过' : '未提供拦截原因')
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
passed: false,
|
|
93
|
+
reason: `审查响应结构不符合预期: ${rawOutput}`
|
|
94
|
+
};
|
|
95
|
+
} catch (err: any) {
|
|
96
|
+
ctx.logger.error('前置 LLM SQL 审计过程发生异常', err);
|
|
97
|
+
return {
|
|
98
|
+
passed: false,
|
|
99
|
+
reason: `SQL 安全审查服务调用失败: ${err?.message || err}`
|
|
100
|
+
};
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
import type { FreyaContext, ToolPlugin, FreyaTool } from '@eoasmxd/freya-sdk';
|
|
2
|
+
import { MysqlPoolManager } from './pool-manager.js';
|
|
3
|
+
import { SqlAuditService } from './audit.js';
|
|
4
|
+
import { MysqlQueryTool } from './tools.js';
|
|
5
|
+
|
|
6
|
+
/** MySQL 数据库查询工具箱插件 */
|
|
7
|
+
export default class MysqlToolsPlugin implements ToolPlugin {
|
|
8
|
+
type = 'tool' as const;
|
|
9
|
+
|
|
10
|
+
private poolManager?: MysqlPoolManager;
|
|
11
|
+
private auditService = new SqlAuditService();
|
|
12
|
+
private tools: FreyaTool[] = [];
|
|
13
|
+
|
|
14
|
+
async setup(ctx: FreyaContext): Promise<void> {
|
|
15
|
+
this.poolManager = new MysqlPoolManager(ctx);
|
|
16
|
+
this.tools = [
|
|
17
|
+
new MysqlQueryTool(this.poolManager, this.auditService)
|
|
18
|
+
];
|
|
19
|
+
|
|
20
|
+
const available = this.poolManager.getAvailableConnectionNames();
|
|
21
|
+
ctx.logger.info(`MySQL 工具箱插件初始化就绪,已注册连接: [${available.join(', ') || '暂未配置'}]`);
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
getId(): string {
|
|
25
|
+
return 'mysql';
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
getInstructionPrompt(): string {
|
|
29
|
+
return 'plugin.prompt.mysql';
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
getTools(): FreyaTool[] {
|
|
33
|
+
return this.tools;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
async stop(ctx: FreyaContext): Promise<void> {
|
|
37
|
+
if (this.poolManager) {
|
|
38
|
+
await this.poolManager.closeAll();
|
|
39
|
+
ctx.logger.info('MySQL 工具箱连接资源已全部释放。');
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export const Plugin = MysqlToolsPlugin;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import mysql from 'mysql2/promise';
|
|
2
|
+
import type { FreyaContext } from '@eoasmxd/freya-sdk';
|
|
3
|
+
|
|
4
|
+
/** MySQL 连接配置契约 */
|
|
5
|
+
export interface MysqlConnectionConfig {
|
|
6
|
+
name: string;
|
|
7
|
+
host?: string;
|
|
8
|
+
port?: number;
|
|
9
|
+
user?: string;
|
|
10
|
+
password?: string;
|
|
11
|
+
database?: string;
|
|
12
|
+
connectionLimit?: number;
|
|
13
|
+
connectTimeout?: number;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** 安全解算嵌套或扁平配置值 */
|
|
17
|
+
function getNestedConfig(config: Record<string, any>, keyPath: string): any {
|
|
18
|
+
if (!config) return undefined;
|
|
19
|
+
if (keyPath in config) return config[keyPath];
|
|
20
|
+
const parts = keyPath.split('.');
|
|
21
|
+
let current: any = config;
|
|
22
|
+
for (const part of parts) {
|
|
23
|
+
if (current && typeof current === 'object' && part in current) {
|
|
24
|
+
current = current[part];
|
|
25
|
+
} else {
|
|
26
|
+
return undefined;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
return current;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** MySQL 多命名连接池管理器 */
|
|
33
|
+
export class MysqlPoolManager {
|
|
34
|
+
private pools = new Map<string, mysql.Pool>();
|
|
35
|
+
private connectionConfigs = new Map<string, MysqlConnectionConfig>();
|
|
36
|
+
private defaultConnectionName: string = 'default';
|
|
37
|
+
|
|
38
|
+
constructor(private ctx: FreyaContext) {
|
|
39
|
+
this.reloadConfigs();
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** 加载与同步配置 */
|
|
43
|
+
public reloadConfigs(): void {
|
|
44
|
+
this.connectionConfigs.clear();
|
|
45
|
+
const config = this.ctx.config || {};
|
|
46
|
+
this.defaultConnectionName = getNestedConfig(config, 'mysql.defaultConnection') || 'default';
|
|
47
|
+
|
|
48
|
+
const connections = getNestedConfig(config, 'mysql.connections');
|
|
49
|
+
if (Array.isArray(connections)) {
|
|
50
|
+
for (const item of connections) {
|
|
51
|
+
if (item && typeof item === 'object' && item.name) {
|
|
52
|
+
this.connectionConfigs.set(item.name, {
|
|
53
|
+
name: String(item.name),
|
|
54
|
+
host: item.host ? String(item.host) : '127.0.0.1',
|
|
55
|
+
port: item.port ? Number(item.port) : 3306,
|
|
56
|
+
user: item.user ? String(item.user) : 'root',
|
|
57
|
+
password: item.password ? String(item.password) : '',
|
|
58
|
+
database: item.database ? String(item.database) : undefined,
|
|
59
|
+
connectionLimit: item.connectionLimit ? Number(item.connectionLimit) : 5,
|
|
60
|
+
connectTimeout: item.connectTimeout ? Number(item.connectTimeout) : 10000
|
|
61
|
+
});
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if (this.connectionConfigs.size === 0) {
|
|
67
|
+
const singleHost = getNestedConfig(config, 'mysql.host');
|
|
68
|
+
if (singleHost) {
|
|
69
|
+
this.connectionConfigs.set(this.defaultConnectionName, {
|
|
70
|
+
name: this.defaultConnectionName,
|
|
71
|
+
host: String(singleHost),
|
|
72
|
+
port: getNestedConfig(config, 'mysql.port') ? Number(getNestedConfig(config, 'mysql.port')) : 3306,
|
|
73
|
+
user: getNestedConfig(config, 'mysql.user') ? String(getNestedConfig(config, 'mysql.user')) : 'root',
|
|
74
|
+
password: getNestedConfig(config, 'mysql.password') ? String(getNestedConfig(config, 'mysql.password')) : '',
|
|
75
|
+
database: getNestedConfig(config, 'mysql.database') ? String(getNestedConfig(config, 'mysql.database')) : undefined,
|
|
76
|
+
connectionLimit: getNestedConfig(config, 'mysql.connectionLimit') ? Number(getNestedConfig(config, 'mysql.connectionLimit')) : 5,
|
|
77
|
+
connectTimeout: getNestedConfig(config, 'mysql.connectTimeout') ? Number(getNestedConfig(config, 'mysql.connectTimeout')) : 10000
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** 获取指定名称的数据库连接池 */
|
|
84
|
+
public getPool(targetName?: string): { pool: mysql.Pool; config: MysqlConnectionConfig } {
|
|
85
|
+
this.reloadConfigs();
|
|
86
|
+
const connectionName = targetName || this.defaultConnectionName;
|
|
87
|
+
const conf = this.connectionConfigs.get(connectionName);
|
|
88
|
+
|
|
89
|
+
if (!conf) {
|
|
90
|
+
const available = Array.from(this.connectionConfigs.keys()).join(', ') || '无配置';
|
|
91
|
+
throw new Error(`未找到名为 "${connectionName}" 的 MySQL 连接配置。当前可用连接: [${available}]`);
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
let pool = this.pools.get(connectionName);
|
|
95
|
+
if (!pool) {
|
|
96
|
+
pool = mysql.createPool({
|
|
97
|
+
host: conf.host,
|
|
98
|
+
port: conf.port,
|
|
99
|
+
user: conf.user,
|
|
100
|
+
password: conf.password,
|
|
101
|
+
database: conf.database,
|
|
102
|
+
connectionLimit: conf.connectionLimit,
|
|
103
|
+
connectTimeout: conf.connectTimeout,
|
|
104
|
+
waitForConnections: true,
|
|
105
|
+
queueLimit: 0,
|
|
106
|
+
dateStrings: true
|
|
107
|
+
});
|
|
108
|
+
this.pools.set(connectionName, pool);
|
|
109
|
+
this.ctx.logger.info(`MySQL 连接池已创建: [${connectionName}] -> ${conf.user}@${conf.host}:${conf.port}/${conf.database || ''}`);
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return { pool, config: conf };
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** 获取所有可用连接名称 */
|
|
116
|
+
public getAvailableConnectionNames(): string[] {
|
|
117
|
+
return Array.from(this.connectionConfigs.keys());
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** 释放所有已建立的连接池资源 */
|
|
121
|
+
public async closeAll(): Promise<void> {
|
|
122
|
+
for (const [name, pool] of this.pools.entries()) {
|
|
123
|
+
try {
|
|
124
|
+
await pool.end();
|
|
125
|
+
this.ctx.logger.info(`MySQL 连接池已释放: [${name}]`);
|
|
126
|
+
} catch (err: any) {
|
|
127
|
+
this.ctx.logger.warn(`关闭 MySQL 连接池 [${name}] 时发生异常: ${err?.message || err}`);
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
this.pools.clear();
|
|
131
|
+
}
|
|
132
|
+
}
|