@eoasmxd/freya 0.4.0 → 0.4.2

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.
Files changed (57) hide show
  1. package/core/dist/agent/agent-executor.js +5 -0
  2. package/core/dist/command/command-executor.js +7 -0
  3. package/core/dist/command/command-registry.d.ts +6 -1
  4. package/core/dist/command/command-registry.js +31 -1
  5. package/core/dist/command/commands/skill-commands.js +5 -4
  6. package/core/dist/config/config-manager.d.ts +5 -1
  7. package/core/dist/config/config-manager.js +48 -1
  8. package/core/dist/kernel.js +4 -4
  9. package/core/dist/skill/skill-registry.d.ts +12 -2
  10. package/core/dist/skill/skill-registry.js +89 -8
  11. package/core/dist/tools/meta/index.d.ts +3 -1
  12. package/core/dist/tools/meta/index.js +9 -1
  13. package/core/dist/tools/tool-registry.d.ts +12 -7
  14. package/core/dist/tools/tool-registry.js +44 -8
  15. package/core/dist/web/config-api.js +18 -0
  16. package/core/package.json +2 -2
  17. package/package.json +4 -3
  18. package/plugins/plugin-gemini/package.json +1 -1
  19. package/plugins/plugin-openai/package.json +1 -1
  20. package/plugins/plugin-telegram-channel/package.json +1 -1
  21. package/plugins/plugin-tool-fs/package.json +1 -1
  22. package/plugins/plugin-tool-memory/package.json +1 -1
  23. package/plugins/plugin-tool-mysql/config/prompts/plugin.prompt.mysql.md +9 -0
  24. package/plugins/plugin-tool-mysql/config/prompts/plugin.prompt.mysql.select.audit.md +26 -0
  25. package/plugins/plugin-tool-mysql/dist/audit.d.ts +13 -0
  26. package/plugins/plugin-tool-mysql/dist/audit.js +87 -0
  27. package/plugins/plugin-tool-mysql/dist/index.d.ts +14 -0
  28. package/plugins/plugin-tool-mysql/dist/index.js +34 -0
  29. package/plugins/plugin-tool-mysql/dist/pool-manager.d.ts +32 -0
  30. package/plugins/plugin-tool-mysql/dist/pool-manager.js +113 -0
  31. package/plugins/plugin-tool-mysql/dist/tools.d.ts +11 -0
  32. package/plugins/plugin-tool-mysql/dist/tools.js +88 -0
  33. package/plugins/plugin-tool-mysql/package.json +33 -0
  34. package/plugins/plugin-tool-mysql/schema.json +83 -0
  35. package/plugins/plugin-tool-web/package.json +1 -1
  36. package/plugins/plugin-wecom-channel/package.json +1 -1
  37. package/plugins/plugin-weixin-channel/dist/index.js +10 -36
  38. package/plugins/plugin-weixin-channel/package.json +3 -2
  39. package/src/packages/core/src/agent/agent-executor.ts +5 -0
  40. package/src/packages/core/src/command/command-executor.ts +8 -0
  41. package/src/packages/core/src/command/command-registry.ts +35 -1
  42. package/src/packages/core/src/command/commands/skill-commands.ts +6 -5
  43. package/src/packages/core/src/config/config-manager.ts +50 -1
  44. package/src/packages/core/src/kernel.ts +5 -4
  45. package/src/packages/core/src/skill/skill-registry.ts +100 -8
  46. package/src/packages/core/src/tools/meta/index.ts +9 -1
  47. package/src/packages/core/src/tools/tool-registry.ts +49 -9
  48. package/src/packages/core/src/web/config-api.ts +20 -0
  49. package/src/packages/ui/src/features/config/ConfigModal.tsx +13 -1
  50. package/src/packages/ui/src/features/config/panels/SkillConfigPanel.tsx +137 -0
  51. package/src/plugins/plugin-tool-mysql/src/audit.ts +103 -0
  52. package/src/plugins/plugin-tool-mysql/src/index.ts +44 -0
  53. package/src/plugins/plugin-tool-mysql/src/pool-manager.ts +132 -0
  54. package/src/plugins/plugin-tool-mysql/src/tools.ts +100 -0
  55. package/src/plugins/plugin-weixin-channel/src/index.ts +12 -37
  56. package/ui/assets/{index-Be0cAgdB.js → index-BqPQMflk.js} +14 -14
  57. package/ui/index.html +1 -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
+ }
@@ -0,0 +1,100 @@
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
+
5
+ /** 脱敏错误信息中的敏感凭据 */
6
+ function sanitizeErrorMessage(err: any, password?: string): string {
7
+ let message = err?.message || String(err);
8
+ if (password) {
9
+ message = message.replaceAll(password, '******');
10
+ }
11
+ return message;
12
+ }
13
+
14
+ /** MySQL 数据库查询执行工具 */
15
+ export class MysqlQueryTool implements FreyaTool {
16
+ constructor(
17
+ private poolManager: MysqlPoolManager,
18
+ private auditService: SqlAuditService
19
+ ) {}
20
+
21
+ getDefinition(): ToolDefinition {
22
+ return {
23
+ name: 'mysql_query',
24
+ description: '执行 MySQL SELECT 查询语句并返回结构化数据。执行前会进行独立 LLM 安全与完整性审核,支持通过 connection 指定目标连接。',
25
+ parameters: {
26
+ type: 'object',
27
+ properties: {
28
+ sql: {
29
+ type: 'string',
30
+ description: '待执行的 SQL 查询语句'
31
+ },
32
+ connection: {
33
+ type: 'string',
34
+ description: '目标数据库连接名称(可选,若未指定则使用系统默认连接)'
35
+ },
36
+ params: {
37
+ type: 'array',
38
+ items: {},
39
+ description: '可选的参数化查询参数列表'
40
+ }
41
+ },
42
+ required: ['sql']
43
+ }
44
+ };
45
+ }
46
+
47
+ async execute(args: Record<string, any>, ctx: FreyaContext): Promise<string> {
48
+ const rawSql = typeof args.sql === 'string' ? args.sql.trim() : '';
49
+ if (!rawSql) {
50
+ return '❌ 参数错误: sql 语句不能为空。';
51
+ }
52
+
53
+ const connectionName = typeof args.connection === 'string' && args.connection.trim()
54
+ ? args.connection.trim()
55
+ : undefined;
56
+
57
+ const queryParams = Array.isArray(args.params) ? args.params : undefined;
58
+
59
+ const auditResult = await this.auditService.audit(rawSql, connectionName || 'default', ctx);
60
+ if (!auditResult.passed) {
61
+ return `❌ SQL 审查未通过,已拒绝执行。\n原因: ${auditResult.reason}`;
62
+ }
63
+
64
+ let currentPassword = '';
65
+ try {
66
+ const { pool, config } = this.poolManager.getPool(connectionName);
67
+ currentPassword = config.password || '';
68
+
69
+ const maxRows = Number(ctx.config?.mysql?.maxRows ?? ctx.config?.['mysql.maxRows']) || 100;
70
+ const [rows] = await pool.query(rawSql, queryParams);
71
+
72
+ if (!Array.isArray(rows)) {
73
+ return JSON.stringify({
74
+ connection: config.name,
75
+ database: config.database || null,
76
+ affectedRows: (rows as any).affectedRows ?? 0,
77
+ info: (rows as any).info || '查询已完成'
78
+ }, null, 2);
79
+ }
80
+
81
+ const totalCount = rows.length;
82
+ const truncated = totalCount > maxRows;
83
+ const data = truncated ? rows.slice(0, maxRows) : rows;
84
+
85
+ return JSON.stringify({
86
+ connection: config.name,
87
+ database: config.database || null,
88
+ totalCount,
89
+ returnedCount: data.length,
90
+ truncated,
91
+ notice: truncated ? `结果集超过最大限制 ${maxRows} 条,已自动截断返回前 ${maxRows} 条记录。` : undefined,
92
+ data
93
+ }, null, 2);
94
+ } catch (err: any) {
95
+ const safeMessage = sanitizeErrorMessage(err, currentPassword);
96
+ ctx.logger.error(`MySQL 查询执行异常: ${safeMessage}`);
97
+ return `❌ MySQL 查询执行失败: ${safeMessage}`;
98
+ }
99
+ }
100
+ }
@@ -2,8 +2,7 @@ import type { ChannelPlugin, FreyaAttachment, FreyaCommand, FreyaContext } from
2
2
  import crypto from "node:crypto";
3
3
  import fs from "node:fs/promises";
4
4
  import path from "node:path";
5
- // @ts-ignore
6
- import qrcodeTerminal from "qrcode-terminal";
5
+ import QRCode from "qrcode";
7
6
 
8
7
  interface WeixinBotConfig {
9
8
  id: string;
@@ -279,48 +278,24 @@ export default class FreyaWeixinChannelPlugin implements ChannelPlugin {
279
278
  throw new Error("获取微信登录二维码失败:服务端未返回 qrcode");
280
279
  }
281
280
 
282
- const matrix: number[][] = [];
283
- qrcodeTerminal.generate(scanUrl, { small: false }, (code: string) => {
284
- const lines = code.split("\n");
285
- for (const line of lines) {
286
- const cleanLine = line.replace(/[\r\n]/g, "");
287
- if (!cleanLine) continue;
288
- const row: number[] = [];
289
- const re = /\x1b\[(40|47)m \x1b\[0m/g;
290
- let match;
291
- while ((match = re.exec(cleanLine)) !== null) {
292
- row.push(match[1] === "40" ? 1 : 0);
293
- }
294
- if (row.length > 0) {
295
- matrix.push(row);
296
- }
297
- }
281
+ const qrAscii = await QRCode.toString(scanUrl, {
282
+ type: "utf8",
283
+ errorCorrectionLevel: "M",
284
+ margin: 2
298
285
  });
299
286
 
300
- const size = matrix.length;
301
- let svgContent = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 ${size} ${size}" width="180" height="180" style="shape-rendering:crispEdges;">`;
302
- svgContent += `<rect width="${size}" height="${size}" fill="#ffffff"/>`;
303
- for (let y = 0; y < size; y++) {
304
- const row = matrix[y];
305
- for (let x = 0; x < row.length; x++) {
306
- if (row[x] === 1) {
307
- svgContent += `<rect x="${x}" y="${y}" width="1" height="1" fill="#000000"/>`;
308
- }
309
- }
310
- }
311
- svgContent += `</svg>`;
312
-
313
- const base64Svg = Buffer.from(svgContent).toString("base64");
314
- const qrDataUri = `data:image/svg+xml;base64,${base64Svg}`;
315
-
316
287
  this.pollWeixinQrStatus(ctx, accountId, config, qrcode).catch((err) => {
317
288
  ctx.logger.error(`微信账号 [${accountId}] 后台监听扫码绑定失败:`, err.message);
318
289
  });
319
290
 
320
- return `⚠️ **微信账号 [${accountId}] 登录二维码已成功生成!**\n\n` +
291
+ return (
292
+ `⚠️ **微信账号 [${accountId}] 登录二维码已成功生成!**\n\n` +
321
293
  `**[微信扫码] 请使用微信扫描下方二维码绑定账号 [${accountId}]**:\n\n` +
322
- `![微信登录二维码](${qrDataUri})\n\n` +
323
- `*(提示:若二维码未能正常显示,您可以直接点击 [打开微信二维码网页](${scanUrl}) 扫码绑定)*`;
294
+ "```text\n" +
295
+ qrAscii +
296
+ "\n```\n\n" +
297
+ `*(提示:若字符二维码未能正常显示或无法扫描,您可以直接点击 [打开微信二维码网页](${scanUrl}) 扫码绑定)*`
298
+ );
324
299
  } catch (err: any) {
325
300
  ctx.logger.error(`微信账号 [${accountId}] 拉取扫码登录失败:`, err.message);
326
301
  return `❌ 拉取微信登录二维码失败: ${err.message}`;