@dsh-plugin/dsh-synapse 0.1.0-dev.41044228975 → 0.1.0

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 (74) hide show
  1. package/LICENSE +661 -661
  2. package/README.md +0 -14
  3. package/package.json +1 -1
  4. package/vendor/gugle-chat-bot/LICENSE +661 -661
  5. package/vendor/gugle-chat-bot/package.json +57 -57
  6. package/vendor/gugle-chat-bot/src/allow-rule.yml +9345 -9345
  7. package/vendor/gugle-chat-bot/src/api.json +4284 -4284
  8. package/vendor/gugle-chat-bot/src/command/index.ts +141 -141
  9. package/vendor/gugle-chat-bot/src/config/features.ts +547 -547
  10. package/vendor/gugle-chat-bot/src/config/index.ts +184 -184
  11. package/vendor/gugle-chat-bot/src/config/manager.ts +356 -356
  12. package/vendor/gugle-chat-bot/src/constants/index.ts +13 -13
  13. package/vendor/gugle-chat-bot/src/custom.ts +548 -548
  14. package/vendor/gugle-chat-bot/src/event/index.ts +118 -118
  15. package/vendor/gugle-chat-bot/src/features/admin/index.ts +414 -414
  16. package/vendor/gugle-chat-bot/src/features/bili/image/impl/video.ts +39 -39
  17. package/vendor/gugle-chat-bot/src/features/bili/image/index.ts +37 -37
  18. package/vendor/gugle-chat-bot/src/features/bili/index.ts +65 -65
  19. package/vendor/gugle-chat-bot/src/features/bili/template/style/video.css +97 -97
  20. package/vendor/gugle-chat-bot/src/features/bili/template/video.html +56 -56
  21. package/vendor/gugle-chat-bot/src/features/discord-bridge/index.ts +1128 -1128
  22. package/vendor/gugle-chat-bot/src/features/discord-bridge/markdown.ts +105 -105
  23. package/vendor/gugle-chat-bot/src/features/discord-bridge/slash.ts +355 -355
  24. package/vendor/gugle-chat-bot/src/features/discord-bridge/source.ts +96 -96
  25. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/message.html +14 -14
  26. package/vendor/gugle-chat-bot/src/features/discord-bridge/template/style/message.css +144 -144
  27. package/vendor/gugle-chat-bot/src/features/github/api.ts +93 -93
  28. package/vendor/gugle-chat-bot/src/features/github/binding.ts +161 -161
  29. package/vendor/gugle-chat-bot/src/features/github/image/impl/issue.ts +132 -132
  30. package/vendor/gugle-chat-bot/src/features/github/image/impl/pr.ts +144 -144
  31. package/vendor/gugle-chat-bot/src/features/github/image/impl/release.ts +160 -160
  32. package/vendor/gugle-chat-bot/src/features/github/image/index.ts +310 -310
  33. package/vendor/gugle-chat-bot/src/features/github/index.ts +302 -302
  34. package/vendor/gugle-chat-bot/src/features/github/template/issue.html +118 -118
  35. package/vendor/gugle-chat-bot/src/features/github/template/pull_request.html +125 -125
  36. package/vendor/gugle-chat-bot/src/features/github/template/release.html +53 -53
  37. package/vendor/gugle-chat-bot/src/features/github/template/style/issue.css +468 -468
  38. package/vendor/gugle-chat-bot/src/features/github/template/style/pull_request.css +468 -468
  39. package/vendor/gugle-chat-bot/src/features/github/template/style/release.css +412 -412
  40. package/vendor/gugle-chat-bot/src/features/hermes/client.ts +184 -184
  41. package/vendor/gugle-chat-bot/src/features/hermes/config.ts +210 -210
  42. package/vendor/gugle-chat-bot/src/features/hermes/context.ts +67 -67
  43. package/vendor/gugle-chat-bot/src/features/hermes/history.ts +86 -86
  44. package/vendor/gugle-chat-bot/src/features/hermes/index.ts +1533 -1533
  45. package/vendor/gugle-chat-bot/src/features/hermes/message.ts +319 -319
  46. package/vendor/gugle-chat-bot/src/features/hermes/reaction.ts +181 -181
  47. package/vendor/gugle-chat-bot/src/features/hermes/render.ts +118 -118
  48. package/vendor/gugle-chat-bot/src/features/hermes/reply-units.ts +331 -331
  49. package/vendor/gugle-chat-bot/src/features/hermes/skills.ts +406 -406
  50. package/vendor/gugle-chat-bot/src/features/hermes/template/approval.html +30 -30
  51. package/vendor/gugle-chat-bot/src/features/hermes/template/progress.html +21 -21
  52. package/vendor/gugle-chat-bot/src/features/hermes/template/style/approval.css +81 -81
  53. package/vendor/gugle-chat-bot/src/features/hermes/template/style/progress.css +120 -120
  54. package/vendor/gugle-chat-bot/src/features/hermes/trigger.ts +86 -86
  55. package/vendor/gugle-chat-bot/src/features/hermes/types.ts +327 -327
  56. package/vendor/gugle-chat-bot/src/features/management/index.ts +156 -156
  57. package/vendor/gugle-chat-bot/src/features/minecraft/index.ts +127 -127
  58. package/vendor/gugle-chat-bot/src/features/modrinth/index.ts +71 -71
  59. package/vendor/gugle-chat-bot/src/features/parentheses/index.ts +117 -117
  60. package/vendor/gugle-chat-bot/src/features/peak-valley-timer/index.ts +451 -451
  61. package/vendor/gugle-chat-bot/src/features/poke/index.ts +24 -24
  62. package/vendor/gugle-chat-bot/src/features/version-tracker/index.ts +77 -77
  63. package/vendor/gugle-chat-bot/src/features/welcome/index.ts +57 -57
  64. package/vendor/gugle-chat-bot/src/github-ip.json +5814 -5814
  65. package/vendor/gugle-chat-bot/src/image/index.ts +278 -278
  66. package/vendor/gugle-chat-bot/src/index.ts +695 -695
  67. package/vendor/gugle-chat-bot/src/logger/index.ts +54 -54
  68. package/vendor/gugle-chat-bot/src/result.json +359 -359
  69. package/vendor/gugle-chat-bot/src/type/bili.d.ts +825 -825
  70. package/vendor/gugle-chat-bot/src/type/event.d.ts +146 -146
  71. package/vendor/gugle-chat-bot/src/type/github.d.ts +446 -446
  72. package/vendor/gugle-chat-bot/src/type/index.d.ts +254 -254
  73. package/vendor/gugle-chat-bot/tsconfig.json +46 -46
  74. package/scripts/dump-npm-latest.mjs +0 -30
@@ -1,181 +1,181 @@
1
- import fs from 'node:fs';
2
- import path from 'node:path';
3
- import { QQBot } from '@/index';
4
- import { getHermesConfig } from '@/features/hermes/config';
5
- import { writeConfigFile } from '@/config/manager';
6
-
7
- /**
8
- * AI「处理中」表情回应管理。
9
- *
10
- * 用户消息触发 Hermes 运行时,给触发消息贴上表情回应(QQ 走 NapCat
11
- * set_msg_emoji_like,Discord 走 message.react);完整回复完成(含查询技能的
12
- * 回喂轮)后摘除。
13
- *
14
- * 记录立即持久化到 data/hermes-reactions.json:若回应期间进程重启/宕机,
15
- * 下次启动时按残留记录逐条摘除并清空(cleanupQQ 由 after-start 钩子调用,
16
- * Discord 残留由 DiscordBridge 在 clientReady 后调用 listDiscord/drop 处理),
17
- * 避免「处理中」表情永久残留。
18
- */
19
-
20
- /** 一条待摘除的表情回应记录 */
21
- export interface PendingReaction {
22
- /** 唯一键:`qq:{messageId}` / `discord:{channelId}:{messageId}` */
23
- key: string;
24
- platform: 'qq' | 'discord';
25
- /** QQ: OneBot message_id 字符串;Discord: 消息 snowflake */
26
- messageId: string;
27
- /** Discord 频道 snowflake(QQ 记录无此字段) */
28
- channelId?: string;
29
- /** QQ: emoji_id;Discord: unicode emoji */
30
- emoji: string;
31
- createdAt: number;
32
- }
33
-
34
- interface ReactionStoreFile {
35
- version: number;
36
- reactions: PendingReaction[];
37
- }
38
-
39
- export class ReactionManager {
40
- private static instance?: ReactionManager;
41
-
42
- public static getInstance(): ReactionManager {
43
- if (!ReactionManager.instance) ReactionManager.instance = new ReactionManager();
44
- return ReactionManager.instance;
45
- }
46
-
47
- private readonly file = path.resolve(process.cwd(), 'data', 'hermes-reactions.json');
48
- private readonly records = new Map<string, PendingReaction>();
49
- private loaded = false;
50
-
51
- private constructor() {}
52
-
53
- /** 懒加载持久化记录(损坏按空处理) */
54
- private load(): void {
55
- if (this.loaded) return;
56
- this.loaded = true;
57
- try {
58
- if (!fs.existsSync(this.file)) return;
59
- const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8')) as ReactionStoreFile;
60
- if (!Array.isArray(raw?.reactions)) return;
61
- for (const record of raw.reactions) {
62
- if (record && typeof record.key === 'string' && typeof record.messageId === 'string') {
63
- this.records.set(record.key, record);
64
- }
65
- }
66
- } catch {
67
- // 文件损坏或格式错误,按空记录处理
68
- }
69
- }
70
-
71
- /** 立即落盘(原子写,复用 config/manager 的 writeConfigFile) */
72
- private persist(): void {
73
- try {
74
- writeConfigFile(this.file, { version: 1, reactions: [...this.records.values()] } satisfies ReactionStoreFile);
75
- } catch {
76
- // 持久化失败不阻塞主流程
77
- }
78
- }
79
-
80
- /**
81
- * QQ:给消息贴「处理中」表情,成功后立即持久化记录并返回记录键。
82
- * 返回 null 表示没贴上(功能关闭或 NapCat 拒绝,例如私聊消息通常不支持表情回应),
83
- * 调用方据此决定是否改用文字回执;具体原因写入日志便于排查。
84
- */
85
- public async addQQ(bot: QQBot, messageId: number): Promise<string | null> {
86
- const config = getHermesConfig();
87
- if (!config.reactionEnabled) {
88
- bot.logger?.debug(`[Hermes] 表情回应已关闭,跳过(message ${messageId})`);
89
- return null;
90
- }
91
- try {
92
- await bot.axiosInstance.post('/set_msg_emoji_like', {
93
- message_id: messageId,
94
- emoji_id: config.reactionEmojiQq,
95
- set: true
96
- });
97
- } catch (error) {
98
- bot.logger?.warn(
99
- `[Hermes] 表情回应失败(message ${messageId}): ${(error as Error).message}` +
100
- `;NapCat 对私聊消息通常不支持 set_msg_emoji_like`
101
- );
102
- return null;
103
- }
104
- const key = `qq:${messageId}`;
105
- this.load();
106
- this.records.set(key, {
107
- key,
108
- platform: 'qq',
109
- messageId: String(messageId),
110
- emoji: config.reactionEmojiQq,
111
- createdAt: Date.now()
112
- });
113
- this.persist();
114
- return key;
115
- }
116
-
117
- /**
118
- * Discord:message.react 成功后调用,立即持久化记录并返回记录键。
119
- */
120
- public persistDiscord(channelId: string, messageId: string, emoji: string): string {
121
- const key = `discord:${channelId}:${messageId}`;
122
- this.load();
123
- this.records.set(key, { key, platform: 'discord', messageId, channelId, emoji, createdAt: Date.now() });
124
- this.persist();
125
- return key;
126
- }
127
-
128
- /**
129
- * QQ:摘除表情(set=false)。无论摘除成败都移除持久化记录
130
- * (失败多为消息已被撤回等不可重试原因,残留记录只会在下次启动时再次失败)。
131
- */
132
- public async removeQQ(bot: QQBot | undefined, key: string): Promise<void> {
133
- this.load();
134
- const record = this.records.get(key);
135
- if (this.records.delete(key)) this.persist();
136
- if (!record || !bot) return;
137
- try {
138
- await bot.axiosInstance.post('/set_msg_emoji_like', {
139
- message_id: Number(record.messageId),
140
- emoji_id: record.emoji,
141
- set: false
142
- });
143
- } catch (error) {
144
- bot.logger?.warn(`[Hermes] 摘除表情回应失败(${key}): ${(error as Error).message}`);
145
- }
146
- }
147
-
148
- /** 移除一条记录(Discord 摘除动作由调用方完成后调用) */
149
- public drop(key: string): void {
150
- this.load();
151
- if (this.records.delete(key)) this.persist();
152
- }
153
-
154
- /** 全部残留的 Discord 记录(启动清理用,摘除后逐条 drop) */
155
- public listDiscord(): PendingReaction[] {
156
- this.load();
157
- return [...this.records.values()].filter(record => record.platform === 'discord');
158
- }
159
-
160
- /** 启动清理:摘除全部残留的 QQ 表情回应并清空对应记录 */
161
- public async cleanupQQ(bot: QQBot): Promise<void> {
162
- this.load();
163
- const stale = [...this.records.values()].filter(record => record.platform === 'qq');
164
- for (const record of stale) {
165
- try {
166
- await bot.axiosInstance.post('/set_msg_emoji_like', {
167
- message_id: Number(record.messageId),
168
- emoji_id: record.emoji,
169
- set: false
170
- });
171
- } catch {
172
- // 消息已撤回/不存在等:放弃摘除,仅清理记录
173
- }
174
- this.records.delete(record.key);
175
- }
176
- if (stale.length > 0) {
177
- this.persist();
178
- bot.logger?.info(`[Hermes] 已清理 ${stale.length} 条残留的 QQ 表情回应`);
179
- }
180
- }
181
- }
1
+ import fs from 'node:fs';
2
+ import path from 'node:path';
3
+ import { QQBot } from '@/index';
4
+ import { getHermesConfig } from '@/features/hermes/config';
5
+ import { writeConfigFile } from '@/config/manager';
6
+
7
+ /**
8
+ * AI「处理中」表情回应管理。
9
+ *
10
+ * 用户消息触发 Hermes 运行时,给触发消息贴上表情回应(QQ 走 NapCat
11
+ * set_msg_emoji_like,Discord 走 message.react);完整回复完成(含查询技能的
12
+ * 回喂轮)后摘除。
13
+ *
14
+ * 记录立即持久化到 data/hermes-reactions.json:若回应期间进程重启/宕机,
15
+ * 下次启动时按残留记录逐条摘除并清空(cleanupQQ 由 after-start 钩子调用,
16
+ * Discord 残留由 DiscordBridge 在 clientReady 后调用 listDiscord/drop 处理),
17
+ * 避免「处理中」表情永久残留。
18
+ */
19
+
20
+ /** 一条待摘除的表情回应记录 */
21
+ export interface PendingReaction {
22
+ /** 唯一键:`qq:{messageId}` / `discord:{channelId}:{messageId}` */
23
+ key: string;
24
+ platform: 'qq' | 'discord';
25
+ /** QQ: OneBot message_id 字符串;Discord: 消息 snowflake */
26
+ messageId: string;
27
+ /** Discord 频道 snowflake(QQ 记录无此字段) */
28
+ channelId?: string;
29
+ /** QQ: emoji_id;Discord: unicode emoji */
30
+ emoji: string;
31
+ createdAt: number;
32
+ }
33
+
34
+ interface ReactionStoreFile {
35
+ version: number;
36
+ reactions: PendingReaction[];
37
+ }
38
+
39
+ export class ReactionManager {
40
+ private static instance?: ReactionManager;
41
+
42
+ public static getInstance(): ReactionManager {
43
+ if (!ReactionManager.instance) ReactionManager.instance = new ReactionManager();
44
+ return ReactionManager.instance;
45
+ }
46
+
47
+ private readonly file = path.resolve(process.cwd(), 'data', 'hermes-reactions.json');
48
+ private readonly records = new Map<string, PendingReaction>();
49
+ private loaded = false;
50
+
51
+ private constructor() {}
52
+
53
+ /** 懒加载持久化记录(损坏按空处理) */
54
+ private load(): void {
55
+ if (this.loaded) return;
56
+ this.loaded = true;
57
+ try {
58
+ if (!fs.existsSync(this.file)) return;
59
+ const raw = JSON.parse(fs.readFileSync(this.file, 'utf-8')) as ReactionStoreFile;
60
+ if (!Array.isArray(raw?.reactions)) return;
61
+ for (const record of raw.reactions) {
62
+ if (record && typeof record.key === 'string' && typeof record.messageId === 'string') {
63
+ this.records.set(record.key, record);
64
+ }
65
+ }
66
+ } catch {
67
+ // 文件损坏或格式错误,按空记录处理
68
+ }
69
+ }
70
+
71
+ /** 立即落盘(原子写,复用 config/manager 的 writeConfigFile) */
72
+ private persist(): void {
73
+ try {
74
+ writeConfigFile(this.file, { version: 1, reactions: [...this.records.values()] } satisfies ReactionStoreFile);
75
+ } catch {
76
+ // 持久化失败不阻塞主流程
77
+ }
78
+ }
79
+
80
+ /**
81
+ * QQ:给消息贴「处理中」表情,成功后立即持久化记录并返回记录键。
82
+ * 返回 null 表示没贴上(功能关闭或 NapCat 拒绝,例如私聊消息通常不支持表情回应),
83
+ * 调用方据此决定是否改用文字回执;具体原因写入日志便于排查。
84
+ */
85
+ public async addQQ(bot: QQBot, messageId: number): Promise<string | null> {
86
+ const config = getHermesConfig();
87
+ if (!config.reactionEnabled) {
88
+ bot.logger?.debug(`[Hermes] 表情回应已关闭,跳过(message ${messageId})`);
89
+ return null;
90
+ }
91
+ try {
92
+ await bot.axiosInstance.post('/set_msg_emoji_like', {
93
+ message_id: messageId,
94
+ emoji_id: config.reactionEmojiQq,
95
+ set: true
96
+ });
97
+ } catch (error) {
98
+ bot.logger?.warn(
99
+ `[Hermes] 表情回应失败(message ${messageId}): ${(error as Error).message}` +
100
+ `;NapCat 对私聊消息通常不支持 set_msg_emoji_like`
101
+ );
102
+ return null;
103
+ }
104
+ const key = `qq:${messageId}`;
105
+ this.load();
106
+ this.records.set(key, {
107
+ key,
108
+ platform: 'qq',
109
+ messageId: String(messageId),
110
+ emoji: config.reactionEmojiQq,
111
+ createdAt: Date.now()
112
+ });
113
+ this.persist();
114
+ return key;
115
+ }
116
+
117
+ /**
118
+ * Discord:message.react 成功后调用,立即持久化记录并返回记录键。
119
+ */
120
+ public persistDiscord(channelId: string, messageId: string, emoji: string): string {
121
+ const key = `discord:${channelId}:${messageId}`;
122
+ this.load();
123
+ this.records.set(key, { key, platform: 'discord', messageId, channelId, emoji, createdAt: Date.now() });
124
+ this.persist();
125
+ return key;
126
+ }
127
+
128
+ /**
129
+ * QQ:摘除表情(set=false)。无论摘除成败都移除持久化记录
130
+ * (失败多为消息已被撤回等不可重试原因,残留记录只会在下次启动时再次失败)。
131
+ */
132
+ public async removeQQ(bot: QQBot | undefined, key: string): Promise<void> {
133
+ this.load();
134
+ const record = this.records.get(key);
135
+ if (this.records.delete(key)) this.persist();
136
+ if (!record || !bot) return;
137
+ try {
138
+ await bot.axiosInstance.post('/set_msg_emoji_like', {
139
+ message_id: Number(record.messageId),
140
+ emoji_id: record.emoji,
141
+ set: false
142
+ });
143
+ } catch (error) {
144
+ bot.logger?.warn(`[Hermes] 摘除表情回应失败(${key}): ${(error as Error).message}`);
145
+ }
146
+ }
147
+
148
+ /** 移除一条记录(Discord 摘除动作由调用方完成后调用) */
149
+ public drop(key: string): void {
150
+ this.load();
151
+ if (this.records.delete(key)) this.persist();
152
+ }
153
+
154
+ /** 全部残留的 Discord 记录(启动清理用,摘除后逐条 drop) */
155
+ public listDiscord(): PendingReaction[] {
156
+ this.load();
157
+ return [...this.records.values()].filter(record => record.platform === 'discord');
158
+ }
159
+
160
+ /** 启动清理:摘除全部残留的 QQ 表情回应并清空对应记录 */
161
+ public async cleanupQQ(bot: QQBot): Promise<void> {
162
+ this.load();
163
+ const stale = [...this.records.values()].filter(record => record.platform === 'qq');
164
+ for (const record of stale) {
165
+ try {
166
+ await bot.axiosInstance.post('/set_msg_emoji_like', {
167
+ message_id: Number(record.messageId),
168
+ emoji_id: record.emoji,
169
+ set: false
170
+ });
171
+ } catch {
172
+ // 消息已撤回/不存在等:放弃摘除,仅清理记录
173
+ }
174
+ this.records.delete(record.key);
175
+ }
176
+ if (stale.length > 0) {
177
+ this.persist();
178
+ bot.logger?.info(`[Hermes] 已清理 ${stale.length} 条残留的 QQ 表情回应`);
179
+ }
180
+ }
181
+ }
@@ -1,118 +1,118 @@
1
- import { Template, tryGenerateImage } from '@/image';
2
- import { getHermesConfig } from '@/features/hermes/config';
3
- import { RunCurrentTool, RunToolRecord } from '@/features/hermes/types';
4
-
5
- /**
6
- * Hermes 进度/审批卡片渲染(移植自 qq-hermes-bridge src/renderer.ts,MIT 协议,原作者 Amorter)。
7
- * HTML/CSS 拆为模板文件,渲染改用 src/image 的共享 Chrome + 串行截图队列。
8
- * 渲染失败返回 null,调用方降级为纯文本。
9
- */
10
-
11
- /** 进度卡片渲染数据 */
12
- export interface ProgressCardData {
13
- tools: RunToolRecord[];
14
- currentTool: RunCurrentTool | null;
15
- messageDelta?: string;
16
- elapsed: string;
17
- }
18
-
19
- /** 审批卡片渲染数据 */
20
- export interface ApprovalCardData {
21
- command: string;
22
- riskLevel: string;
23
- toolName?: string;
24
- runId?: string;
25
- preview?: string;
26
- }
27
-
28
- function escapeHtml(text: unknown): string {
29
- return String(text || '')
30
- .replace(/&/g, '&amp;')
31
- .replace(/</g, '&lt;')
32
- .replace(/>/g, '&gt;')
33
- .replace(/"/g, '&quot;');
34
- }
35
-
36
- function formatDuration(ms: number): string {
37
- if (ms < 1000) return `${ms}ms`;
38
- if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
- return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
40
- }
41
-
42
- function render(templateName: string, template: Template): Promise<string> {
43
- return new Promise<string>((resolve, reject) => {
44
- const templateFile = template.file();
45
- template
46
- .handler()
47
- .then(html => tryGenerateImage(resolve, reject, html, 500, templateFile))
48
- .catch(reject);
49
- });
50
- }
51
-
52
- /** 渲染进度卡片为 base64 图片;progressAsImage=false 或渲染失败时返回 null */
53
- export async function renderProgressImage(data: ProgressCardData): Promise<string | null> {
54
- const config = getHermesConfig();
55
- if (!config.progressAsImage) return null;
56
- try {
57
- const toolRows = data.tools
58
- .slice(-config.progressMaxTools)
59
- .map(tool => {
60
- const icon = tool.error ? '❌' : '✅';
61
- const duration = tool.duration ? ` (${formatDuration(tool.duration)})` : '';
62
- return `<div class="tool-row">
63
- <span class="icon">${icon}</span>
64
- <span class="name">${escapeHtml(tool.name)}</span>
65
- <span class="dur">${duration}</span>
66
- </div>
67
- ${tool.preview ? `<div class="preview">${escapeHtml(tool.preview).slice(0, 120)}</div>` : ''}`;
68
- })
69
- .join('\n');
70
-
71
- const currentHtml = data.currentTool
72
- ? `<div class="current-tool">
73
- <span class="spinner">⏳</span>
74
- <span class="name">${escapeHtml(data.currentTool.name)}</span>
75
- ${data.currentTool.preview ? `<div class="preview">${escapeHtml(data.currentTool.preview).slice(0, 120)}</div>` : ''}
76
- </div>`
77
- : '';
78
-
79
- const previewHtml = data.messageDelta
80
- ? `<div class="response-preview">${escapeHtml(data.messageDelta.slice(-300))}</div>`
81
- : '';
82
-
83
- const template = Template.load('progress', 'src/features/hermes/template')
84
- .arg('elapsed', escapeHtml(data.elapsed))
85
- .arg('tools', toolRows)
86
- .arg('current', currentHtml)
87
- .arg('preview', previewHtml)
88
- .arg('botName', escapeHtml(config.botName));
89
- return await render('progress', template);
90
- } catch {
91
- return null;
92
- }
93
- }
94
-
95
- /** 渲染审批卡片为 base64 图片;渲染失败时返回 null */
96
- export async function renderApprovalImage(data: ApprovalCardData): Promise<string | null> {
97
- try {
98
- const riskColors: Record<string, string> = { high: '#E94560', medium: '#F5C842', low: '#53D8FB' };
99
- const riskColor = riskColors[data.riskLevel] || riskColors.high!;
100
-
101
- const toolSection = data.toolName
102
- ? `<div class="section"><div class="label">工具</div><div class="value">${escapeHtml(data.toolName)}</div></div>`
103
- : '';
104
- const previewSection = data.preview
105
- ? `<div class="section"><div class="label">说明</div><div class="value">${escapeHtml(data.preview).slice(0, 200)}</div></div>`
106
- : '';
107
-
108
- const template = Template.load('approval', 'src/features/hermes/template')
109
- .arg('riskColor', riskColor)
110
- .arg('toolSection', toolSection)
111
- .arg('command', escapeHtml(data.command))
112
- .arg('previewSection', previewSection)
113
- .arg('runId', escapeHtml(data.runId?.slice(-8) ?? ''));
114
- return await render('approval', template);
115
- } catch {
116
- return null;
117
- }
118
- }
1
+ import { Template, tryGenerateImage } from '@/image';
2
+ import { getHermesConfig } from '@/features/hermes/config';
3
+ import { RunCurrentTool, RunToolRecord } from '@/features/hermes/types';
4
+
5
+ /**
6
+ * Hermes 进度/审批卡片渲染(移植自 qq-hermes-bridge src/renderer.ts,MIT 协议,原作者 Amorter)。
7
+ * HTML/CSS 拆为模板文件,渲染改用 src/image 的共享 Chrome + 串行截图队列。
8
+ * 渲染失败返回 null,调用方降级为纯文本。
9
+ */
10
+
11
+ /** 进度卡片渲染数据 */
12
+ export interface ProgressCardData {
13
+ tools: RunToolRecord[];
14
+ currentTool: RunCurrentTool | null;
15
+ messageDelta?: string;
16
+ elapsed: string;
17
+ }
18
+
19
+ /** 审批卡片渲染数据 */
20
+ export interface ApprovalCardData {
21
+ command: string;
22
+ riskLevel: string;
23
+ toolName?: string;
24
+ runId?: string;
25
+ preview?: string;
26
+ }
27
+
28
+ function escapeHtml(text: unknown): string {
29
+ return String(text || '')
30
+ .replace(/&/g, '&amp;')
31
+ .replace(/</g, '&lt;')
32
+ .replace(/>/g, '&gt;')
33
+ .replace(/"/g, '&quot;');
34
+ }
35
+
36
+ function formatDuration(ms: number): string {
37
+ if (ms < 1000) return `${ms}ms`;
38
+ if (ms < 60000) return `${(ms / 1000).toFixed(1)}s`;
39
+ return `${Math.floor(ms / 60000)}m${Math.round((ms % 60000) / 1000)}s`;
40
+ }
41
+
42
+ function render(templateName: string, template: Template): Promise<string> {
43
+ return new Promise<string>((resolve, reject) => {
44
+ const templateFile = template.file();
45
+ template
46
+ .handler()
47
+ .then(html => tryGenerateImage(resolve, reject, html, 500, templateFile))
48
+ .catch(reject);
49
+ });
50
+ }
51
+
52
+ /** 渲染进度卡片为 base64 图片;progressAsImage=false 或渲染失败时返回 null */
53
+ export async function renderProgressImage(data: ProgressCardData): Promise<string | null> {
54
+ const config = getHermesConfig();
55
+ if (!config.progressAsImage) return null;
56
+ try {
57
+ const toolRows = data.tools
58
+ .slice(-config.progressMaxTools)
59
+ .map(tool => {
60
+ const icon = tool.error ? '❌' : '✅';
61
+ const duration = tool.duration ? ` (${formatDuration(tool.duration)})` : '';
62
+ return `<div class="tool-row">
63
+ <span class="icon">${icon}</span>
64
+ <span class="name">${escapeHtml(tool.name)}</span>
65
+ <span class="dur">${duration}</span>
66
+ </div>
67
+ ${tool.preview ? `<div class="preview">${escapeHtml(tool.preview).slice(0, 120)}</div>` : ''}`;
68
+ })
69
+ .join('\n');
70
+
71
+ const currentHtml = data.currentTool
72
+ ? `<div class="current-tool">
73
+ <span class="spinner">⏳</span>
74
+ <span class="name">${escapeHtml(data.currentTool.name)}</span>
75
+ ${data.currentTool.preview ? `<div class="preview">${escapeHtml(data.currentTool.preview).slice(0, 120)}</div>` : ''}
76
+ </div>`
77
+ : '';
78
+
79
+ const previewHtml = data.messageDelta
80
+ ? `<div class="response-preview">${escapeHtml(data.messageDelta.slice(-300))}</div>`
81
+ : '';
82
+
83
+ const template = Template.load('progress', 'src/features/hermes/template')
84
+ .arg('elapsed', escapeHtml(data.elapsed))
85
+ .arg('tools', toolRows)
86
+ .arg('current', currentHtml)
87
+ .arg('preview', previewHtml)
88
+ .arg('botName', escapeHtml(config.botName));
89
+ return await render('progress', template);
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ /** 渲染审批卡片为 base64 图片;渲染失败时返回 null */
96
+ export async function renderApprovalImage(data: ApprovalCardData): Promise<string | null> {
97
+ try {
98
+ const riskColors: Record<string, string> = { high: '#E94560', medium: '#F5C842', low: '#53D8FB' };
99
+ const riskColor = riskColors[data.riskLevel] || riskColors.high!;
100
+
101
+ const toolSection = data.toolName
102
+ ? `<div class="section"><div class="label">工具</div><div class="value">${escapeHtml(data.toolName)}</div></div>`
103
+ : '';
104
+ const previewSection = data.preview
105
+ ? `<div class="section"><div class="label">说明</div><div class="value">${escapeHtml(data.preview).slice(0, 200)}</div></div>`
106
+ : '';
107
+
108
+ const template = Template.load('approval', 'src/features/hermes/template')
109
+ .arg('riskColor', riskColor)
110
+ .arg('toolSection', toolSection)
111
+ .arg('command', escapeHtml(data.command))
112
+ .arg('previewSection', previewSection)
113
+ .arg('runId', escapeHtml(data.runId?.slice(-8) ?? ''));
114
+ return await render('approval', template);
115
+ } catch {
116
+ return null;
117
+ }
118
+ }