hermes-web-ui 0.1.6 → 0.1.7

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 (85) hide show
  1. package/README.md +75 -12
  2. package/dist/assets/ChannelsView-BWEvTQwM.css +1 -0
  3. package/dist/assets/ChannelsView-sOGWQqBR.js +1 -0
  4. package/dist/assets/ChatView-BYMsYkQ1.css +1 -0
  5. package/dist/assets/ChatView-Ck4IHVIc.js +142 -0
  6. package/dist/assets/Close-B7KfM3jl.js +45 -0
  7. package/dist/assets/FormItem-BdZ_3RxK.js +110 -0
  8. package/dist/assets/Input-C6bxvAIj.js +234 -0
  9. package/dist/assets/InputNumber-eI175yNc.js +13 -0
  10. package/dist/assets/JobsView-DkwaDky6.css +1 -0
  11. package/dist/assets/JobsView-wXKPSc8W.js +2 -0
  12. package/dist/assets/LogsView-B2TeFIUX.css +1 -0
  13. package/dist/assets/LogsView-Ck9HLcnc.js +1 -0
  14. package/dist/assets/MarkdownRenderer-B_Fo6783.js +23 -0
  15. package/dist/assets/MemoryView-BUR_XL9x.js +5 -0
  16. package/dist/assets/MemoryView-CHeaa1-C.css +1 -0
  17. package/dist/assets/Modal-DYN0wyo2.js +232 -0
  18. package/dist/assets/ModelsView-CLdtVeiJ.css +1 -0
  19. package/dist/assets/ModelsView-CkEpCpW4.js +1 -0
  20. package/dist/assets/Popover-BgNi3Q2J.js +117 -0
  21. package/dist/assets/Select-DevZ62i5.js +340 -0
  22. package/dist/assets/SettingRow-HrI0c-1s.css +1 -0
  23. package/dist/assets/SettingRow-bBgfSbZT.js +102 -0
  24. package/dist/assets/SettingsView-Cl1ts-YY.css +1 -0
  25. package/dist/assets/SettingsView-D_qQMAqN.js +352 -0
  26. package/dist/assets/SkillsView-BvNhRbMq.css +1 -0
  27. package/dist/assets/SkillsView-C68Fr3t5.js +1 -0
  28. package/dist/assets/Spin-C1dDkPv8.js +43 -0
  29. package/dist/assets/Suffix-B6bqA1w3.js +101 -0
  30. package/dist/assets/Tag-BBWtv463.js +71 -0
  31. package/dist/assets/Tooltip-D-FlJlcu.js +1 -0
  32. package/dist/assets/_plugin-vue_export-helper-AI_aJZ2_.js +49 -0
  33. package/dist/assets/app-DYgUa8EE.js +1 -0
  34. package/dist/assets/app-DZPFLxKB.js +1 -0
  35. package/dist/assets/chat-C87W9H3D.js +6 -0
  36. package/dist/assets/{context-DW8F1iIn.js → context-aD0DoQuE.js} +14 -14
  37. package/dist/assets/{index-DwVgwUIX.css → index-B7NziQ6I.css} +1 -1
  38. package/dist/assets/index-BxFv1xVi.js +307 -0
  39. package/dist/assets/jobs-D_98yklp.js +1 -0
  40. package/dist/assets/pinia-B7LrvQDh.js +1 -0
  41. package/dist/assets/preload-helper-D4M6sveU.js +1 -0
  42. package/dist/assets/{skills-CkDtgKB5.js → skills-nROrsAxi.js} +1 -1
  43. package/dist/assets/use-message-rwmUYd8q.js +1 -0
  44. package/dist/assets/vue.runtime.esm-bundler-LsUadeZi.js +3 -0
  45. package/dist/index.html +16 -16
  46. package/dist/server/index.js +4 -0
  47. package/dist/server/routes/config.d.ts +2 -0
  48. package/dist/server/routes/config.js +314 -0
  49. package/dist/server/routes/filesystem.js +121 -22
  50. package/dist/server/routes/weixin.d.ts +2 -0
  51. package/dist/server/routes/weixin.js +131 -0
  52. package/dist/server/shared/providers.d.ts +13 -0
  53. package/dist/server/shared/providers.js +210 -0
  54. package/package.json +6 -1
  55. package/dist/assets/ChatView-DmXZ5Q3Z.js +0 -18
  56. package/dist/assets/ChatView-cCVO1N9F.css +0 -1
  57. package/dist/assets/Dropdown-8pId5Qsj.js +0 -125
  58. package/dist/assets/Input-BZKSQ403.js +0 -234
  59. package/dist/assets/JobsView-BhwwXuLt.css +0 -1
  60. package/dist/assets/JobsView-CQf4Y7Mw.js +0 -123
  61. package/dist/assets/LogsView-BN_TkDPi.css +0 -1
  62. package/dist/assets/LogsView-CKyL3UT5.js +0 -1
  63. package/dist/assets/MarkdownRenderer-DNP-kPA8.js +0 -23
  64. package/dist/assets/MemoryView-BBwqM3vf.js +0 -5
  65. package/dist/assets/MemoryView-CK0PemlP.css +0 -1
  66. package/dist/assets/Modal-BtRuxNI4.js +0 -232
  67. package/dist/assets/Popover-C2CJscsj.js +0 -161
  68. package/dist/assets/Select-CithN5ti.js +0 -410
  69. package/dist/assets/SettingsView-BzBMKaLz.css +0 -1
  70. package/dist/assets/SettingsView-D1hOGA0Z.js +0 -1005
  71. package/dist/assets/SkillsView-86Z-HE_X.css +0 -1
  72. package/dist/assets/SkillsView-BfnrX5TQ.js +0 -1
  73. package/dist/assets/Spin-7g8GCzi5.js +0 -43
  74. package/dist/assets/Suffix-DI9irQ4f.js +0 -101
  75. package/dist/assets/Tooltip-BdrvORgU.js +0 -1
  76. package/dist/assets/_plugin-vue_export-helper-BrYOyDjU.js +0 -47
  77. package/dist/assets/app-JRkV5-Ft.js +0 -1
  78. package/dist/assets/chat-Cgdof9SF.js +0 -6
  79. package/dist/assets/index-DP1JeABS.js +0 -307
  80. package/dist/assets/jobs-DJ8ETzok.js +0 -1
  81. package/dist/assets/keysOf-Dvq9k1rv.js +0 -1
  82. package/dist/assets/pinia-tE0RcsDr.js +0 -1
  83. package/dist/assets/runtime-core.esm-bundler-yNW65ghW.js +0 -1
  84. package/dist/assets/use-message-Btr-O4Ih.js +0 -1
  85. /package/dist/assets/{client-kwQ0ijpp.js → client-Df_SqoZl.js} +0 -0
@@ -0,0 +1,314 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.configRoutes = void 0;
7
+ const router_1 = __importDefault(require("@koa/router"));
8
+ const promises_1 = require("fs/promises");
9
+ const promises_2 = require("fs/promises");
10
+ const path_1 = require("path");
11
+ const os_1 = require("os");
12
+ const js_yaml_1 = __importDefault(require("js-yaml"));
13
+ const hermes_cli_1 = require("../services/hermes-cli");
14
+ // Platform sections that require gateway restart after config change
15
+ const PLATFORM_SECTIONS = new Set([
16
+ 'telegram', 'discord', 'slack', 'whatsapp', 'matrix',
17
+ 'weixin', 'wecom', 'feishu', 'dingtalk',
18
+ ]);
19
+ const configPath = (0, path_1.resolve)((0, os_1.homedir)(), '.hermes/config.yaml');
20
+ const envPath = (0, path_1.resolve)((0, os_1.homedir)(), '.hermes/.env');
21
+ // Env var → (platform, configPath in PlatformConfig) mapping
22
+ // Matches hermes _apply_env_overrides() in gateway/config.py
23
+ const envPlatformMap = {
24
+ TELEGRAM_BOT_TOKEN: ['telegram', 'token'],
25
+ DISCORD_BOT_TOKEN: ['discord', 'token'],
26
+ SLACK_BOT_TOKEN: ['slack', 'token'],
27
+ MATRIX_ACCESS_TOKEN: ['matrix', 'token'],
28
+ MATRIX_HOMESERVER: ['matrix', 'extra.homeserver'],
29
+ FEISHU_APP_ID: ['feishu', 'extra.app_id'],
30
+ FEISHU_APP_SECRET: ['feishu', 'extra.app_secret'],
31
+ DINGTALK_CLIENT_ID: ['dingtalk', 'extra.client_id'],
32
+ DINGTALK_CLIENT_SECRET: ['dingtalk', 'extra.client_secret'],
33
+ // DingTalk has no _apply_env_overrides entry in hermes;
34
+ // the adapter reads these env vars directly at runtime.
35
+ DINGTALK_APP_KEY: ['dingtalk', 'extra.app_key'],
36
+ WECOM_BOT_ID: ['wecom', 'extra.bot_id'],
37
+ WECOM_SECRET: ['wecom', 'extra.secret'],
38
+ WEIXIN_TOKEN: ['weixin', 'token'],
39
+ WEIXIN_ACCOUNT_ID: ['weixin', 'extra.account_id'],
40
+ WEIXIN_BASE_URL: ['weixin', 'extra.base_url'],
41
+ WHATSAPP_ENABLED: ['whatsapp', 'enabled'],
42
+ };
43
+ // Reverse map: (platform, configPath) → env var
44
+ const platformEnvMap = {};
45
+ for (const [envVar, [platform, configPath]] of Object.entries(envPlatformMap)) {
46
+ if (!platformEnvMap[platform])
47
+ platformEnvMap[platform] = {};
48
+ platformEnvMap[platform][configPath] = envVar;
49
+ }
50
+ function parseEnv(raw) {
51
+ const env = {};
52
+ for (const line of raw.split('\n')) {
53
+ const trimmed = line.trim();
54
+ if (!trimmed || trimmed.startsWith('#'))
55
+ continue;
56
+ const eqIdx = trimmed.indexOf('=');
57
+ if (eqIdx === -1)
58
+ continue;
59
+ const key = trimmed.slice(0, eqIdx).trim();
60
+ const val = trimmed.slice(eqIdx + 1).trim();
61
+ if (val)
62
+ env[key] = val;
63
+ }
64
+ return env;
65
+ }
66
+ function setNested(obj, path, value) {
67
+ const parts = path.split('.');
68
+ let cur = obj;
69
+ for (let i = 0; i < parts.length - 1; i++) {
70
+ if (!cur[parts[i]])
71
+ cur[parts[i]] = {};
72
+ cur = cur[parts[i]];
73
+ }
74
+ cur[parts[parts.length - 1]] = value;
75
+ }
76
+ function getNested(obj, path) {
77
+ const parts = path.split('.');
78
+ let cur = obj;
79
+ for (const p of parts) {
80
+ if (!cur || typeof cur !== 'object')
81
+ return undefined;
82
+ cur = cur[p];
83
+ }
84
+ return cur;
85
+ }
86
+ async function readEnvPlatforms() {
87
+ try {
88
+ const raw = await (0, promises_1.readFile)(envPath, 'utf-8');
89
+ const env = parseEnv(raw);
90
+ const platforms = {};
91
+ for (const [envKey, [platform, cfgPath]] of Object.entries(envPlatformMap)) {
92
+ const val = env[envKey];
93
+ if (val === undefined || val === '')
94
+ continue;
95
+ if (!platforms[platform])
96
+ platforms[platform] = {};
97
+ let finalVal = val;
98
+ if (cfgPath === 'enabled')
99
+ finalVal = val === 'true';
100
+ setNested(platforms[platform], cfgPath, finalVal);
101
+ }
102
+ return platforms;
103
+ }
104
+ catch {
105
+ return {};
106
+ }
107
+ }
108
+ // Write a KEY=value to .env (matching hermes save_env_value behavior)
109
+ // If value is empty, remove the line instead
110
+ async function saveEnvValue(key, value) {
111
+ let raw;
112
+ try {
113
+ raw = await (0, promises_1.readFile)(envPath, 'utf-8');
114
+ }
115
+ catch {
116
+ raw = '';
117
+ }
118
+ const remove = !value;
119
+ const lines = raw.split('\n');
120
+ let found = false;
121
+ const result = [];
122
+ for (const line of lines) {
123
+ const trimmed = line.trim();
124
+ if (trimmed.startsWith('#')) {
125
+ // Check if there's a commented-out version of this key
126
+ if (trimmed.startsWith(`# ${key}=`)) {
127
+ if (!remove) {
128
+ result.push(`${key}=${value}`);
129
+ }
130
+ found = true;
131
+ }
132
+ else {
133
+ result.push(line);
134
+ }
135
+ }
136
+ else {
137
+ const eqIdx = trimmed.indexOf('=');
138
+ if (eqIdx !== -1 && trimmed.slice(0, eqIdx).trim() === key) {
139
+ if (!remove) {
140
+ result.push(`${key}=${value}`);
141
+ }
142
+ found = true;
143
+ }
144
+ else {
145
+ result.push(line);
146
+ }
147
+ }
148
+ }
149
+ if (!found && !remove) {
150
+ result.push(`${key}=${value}`);
151
+ }
152
+ // Remove trailing empty lines, keep exactly one trailing newline
153
+ let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n';
154
+ await (0, promises_1.writeFile)(envPath, output, 'utf-8');
155
+ // Set permissions to 0600 (owner only), matching hermes behavior
156
+ try {
157
+ await (0, promises_2.chmod)(envPath, 0o600);
158
+ }
159
+ catch { /* ignore */ }
160
+ }
161
+ async function readConfig() {
162
+ const raw = await (0, promises_1.readFile)(configPath, 'utf-8');
163
+ return js_yaml_1.default.load(raw) || {};
164
+ }
165
+ async function writeConfig(data) {
166
+ await (0, promises_1.copyFile)(configPath, configPath + '.bak');
167
+ const yamlStr = js_yaml_1.default.dump(data, {
168
+ lineWidth: -1,
169
+ noRefs: true,
170
+ quotingType: '"',
171
+ forceQuotes: false,
172
+ });
173
+ await (0, promises_1.writeFile)(configPath, yamlStr, 'utf-8');
174
+ }
175
+ exports.configRoutes = new router_1.default();
176
+ // GET /api/config — read config sections
177
+ exports.configRoutes.get('/api/config', async (ctx) => {
178
+ try {
179
+ const config = await readConfig();
180
+ // Merge .env platform credentials into platforms section
181
+ const envPlatforms = await readEnvPlatforms();
182
+ if (Object.keys(envPlatforms).length > 0) {
183
+ // Deep-merge: env values fill in missing, don't overwrite config.yaml
184
+ const existing = config.platforms || {};
185
+ for (const [platform, vals] of Object.entries(envPlatforms)) {
186
+ existing[platform] = { ...(existing[platform] || {}), ...vals };
187
+ }
188
+ config.platforms = existing;
189
+ }
190
+ const { section, sections } = ctx.query;
191
+ if (section) {
192
+ ctx.body = { [section]: config[section] || {} };
193
+ }
194
+ else if (sections) {
195
+ const keys = sections.split(',');
196
+ const result = {};
197
+ for (const key of keys) {
198
+ result[key.trim()] = config[key.trim()] || {};
199
+ }
200
+ ctx.body = result;
201
+ }
202
+ else {
203
+ ctx.body = config;
204
+ }
205
+ }
206
+ catch (err) {
207
+ ctx.status = 500;
208
+ ctx.body = { error: err.message };
209
+ }
210
+ });
211
+ // PUT /api/config — update a config section (writes to config.yaml)
212
+ exports.configRoutes.put('/api/config', async (ctx) => {
213
+ const { section, values } = ctx.request.body;
214
+ if (!section || !values) {
215
+ ctx.status = 400;
216
+ ctx.body = { error: 'Missing section or values' };
217
+ return;
218
+ }
219
+ try {
220
+ const config = await readConfig();
221
+ config[section] = { ...(config[section] || {}), ...values };
222
+ await writeConfig(config);
223
+ // Restart gateway for platform/channel config changes
224
+ if (PLATFORM_SECTIONS.has(section)) {
225
+ await (0, hermes_cli_1.restartGateway)();
226
+ }
227
+ ctx.body = { success: true };
228
+ }
229
+ catch (err) {
230
+ ctx.status = 500;
231
+ ctx.body = { error: err.message };
232
+ }
233
+ });
234
+ // PUT /api/config/credentials — save platform credentials to .env
235
+ // Body: { platform: string, values: Record<string, any> }
236
+ // values keys match PlatformConfig paths: 'token', 'extra.app_id', 'extra.app_secret', etc.
237
+ exports.configRoutes.put('/api/config/credentials', async (ctx) => {
238
+ const { platform, values } = ctx.request.body;
239
+ if (!platform || !values) {
240
+ ctx.status = 400;
241
+ ctx.body = { error: 'Missing platform or values' };
242
+ return;
243
+ }
244
+ try {
245
+ const envMap = platformEnvMap[platform];
246
+ if (!envMap) {
247
+ ctx.status = 400;
248
+ ctx.body = { error: `Unknown platform: ${platform}` };
249
+ return;
250
+ }
251
+ // Also clean up config.yaml platforms.<platform> to keep in sync
252
+ const config = await readConfig();
253
+ let configChanged = false;
254
+ // Flatten nested values: { extra: { app_id: '' } } → { 'extra.app_id': '' }
255
+ const flatValues = {};
256
+ for (const [key, val] of Object.entries(values)) {
257
+ if (key === 'extra' && val && typeof val === 'object') {
258
+ for (const [subKey, subVal] of Object.entries(val)) {
259
+ flatValues[`extra.${subKey}`] = subVal;
260
+ }
261
+ }
262
+ else {
263
+ flatValues[key] = val;
264
+ }
265
+ }
266
+ for (const [cfgPath, val] of Object.entries(flatValues)) {
267
+ const envVar = envMap[cfgPath];
268
+ if (!envVar)
269
+ continue;
270
+ if (val === undefined || val === null || val === '') {
271
+ await saveEnvValue(envVar, '');
272
+ // Remove from config.yaml too
273
+ const parts = cfgPath.split('.');
274
+ let obj = config.platforms?.[platform];
275
+ if (obj) {
276
+ if (parts.length === 1) {
277
+ delete obj[parts[0]];
278
+ }
279
+ else {
280
+ let cur = obj;
281
+ for (let i = 0; i < parts.length - 1; i++) {
282
+ if (!cur[parts[i]])
283
+ break;
284
+ cur = cur[parts[i]];
285
+ }
286
+ delete cur[parts[parts.length - 1]];
287
+ // Clean up empty extra
288
+ if (obj.extra && Object.keys(obj.extra).length === 0)
289
+ delete obj.extra;
290
+ }
291
+ if (Object.keys(obj).length === 0) {
292
+ if (!config.platforms)
293
+ config.platforms = {};
294
+ delete config.platforms[platform];
295
+ }
296
+ configChanged = true;
297
+ }
298
+ }
299
+ else {
300
+ await saveEnvValue(envVar, String(val));
301
+ }
302
+ }
303
+ if (configChanged) {
304
+ await writeConfig(config);
305
+ }
306
+ // Restart gateway for platform credential changes
307
+ await (0, hermes_cli_1.restartGateway)();
308
+ ctx.body = { success: true };
309
+ }
310
+ catch (err) {
311
+ ctx.status = 500;
312
+ ctx.body = { error: err.message };
313
+ }
314
+ });
@@ -41,9 +41,15 @@ async function fetchProviderModels(baseUrl, apiKey) {
41
41
  return [];
42
42
  }
43
43
  }
44
+ // --- Hardcoded model catalogs (single source: src/shared/providers.ts) ---
45
+ const providers_1 = require("../shared/providers");
46
+ const PROVIDER_MODEL_CATALOG = (0, providers_1.buildProviderModelMap)();
44
47
  exports.fsRoutes = new router_1.default();
45
48
  const hermesDir = (0, path_1.resolve)((0, os_1.homedir)(), '.hermes');
46
49
  // --- Helpers ---
50
+ function escapeRegExp(s) {
51
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
52
+ }
47
53
  function extractDescription(content) {
48
54
  // SKILL.md format: YAML frontmatter between --- delimiters, then markdown body
49
55
  // Extract first non-empty, non-frontmatter, non-heading line as description
@@ -313,19 +319,32 @@ exports.fsRoutes.get('/api/available-models', async (ctx) => {
313
319
  token: entry.access_token,
314
320
  });
315
321
  }
316
- // Fetch all provider models in parallel
317
- const results = await Promise.allSettled(endpoints.map(async (ep) => {
318
- const models = await fetchProviderModels(ep.base_url, ep.token);
319
- return { ...ep, models };
320
- }));
322
+ // Resolve models: hardcoded catalog first, live probe as fallback
321
323
  const groups = [];
322
- for (const result of results) {
323
- if (result.status === 'fulfilled' && result.value.models.length > 0) {
324
- const { key, label, base_url, models } = result.value;
325
- groups.push({ provider: key, label, base_url, models });
324
+ const liveEndpoints = [];
325
+ for (const ep of endpoints) {
326
+ const catalogModels = PROVIDER_MODEL_CATALOG[ep.key];
327
+ if (catalogModels && catalogModels.length > 0) {
328
+ groups.push({ provider: ep.key, label: ep.label, base_url: ep.base_url, models: catalogModels });
326
329
  }
327
- else if (result.status === 'rejected') {
328
- console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`);
330
+ else {
331
+ liveEndpoints.push(ep);
332
+ }
333
+ }
334
+ // Only probe endpoints not in the catalog
335
+ if (liveEndpoints.length > 0) {
336
+ const results = await Promise.allSettled(liveEndpoints.map(async (ep) => {
337
+ const models = await fetchProviderModels(ep.base_url, ep.token);
338
+ return { ...ep, models };
339
+ }));
340
+ for (const result of results) {
341
+ if (result.status === 'fulfilled' && result.value.models.length > 0) {
342
+ const { key, label, base_url, models } = result.value;
343
+ groups.push({ provider: key, label, base_url, models });
344
+ }
345
+ else if (result.status === 'rejected') {
346
+ console.error(`[available-models] Failed: ${result.reason?.message || result.reason}`);
347
+ }
329
348
  }
330
349
  }
331
350
  // Fallback: if no providers returned models, fall back to config.yaml parsing
@@ -384,16 +403,22 @@ exports.fsRoutes.put('/api/config/model', async (ctx) => {
384
403
  });
385
404
  // POST /api/config/providers
386
405
  exports.fsRoutes.post('/api/config/providers', async (ctx) => {
387
- const { name, base_url, api_key, model } = ctx.request.body;
406
+ const { name, base_url, api_key, model, providerKey } = ctx.request.body;
388
407
  if (!name || !base_url || !model) {
389
408
  ctx.status = 400;
390
409
  ctx.body = { error: 'Missing name, base_url, or model' };
391
410
  return;
392
411
  }
412
+ if (!api_key) {
413
+ ctx.status = 400;
414
+ ctx.body = { error: 'Missing API key' };
415
+ return;
416
+ }
393
417
  try {
418
+ // 1. Write to config.yaml custom_providers
394
419
  await (0, promises_1.copyFile)(configPath, configPath + '.bak');
395
420
  let yaml = await safeReadFile(configPath) || '';
396
- const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key || ''}\n model: ${model}\n`;
421
+ const newEntry = `- name: ${name}\n base_url: ${base_url}\n api_key: ${api_key}\n model: ${model}\n`;
397
422
  if (/^custom_providers:/m.test(yaml)) {
398
423
  yaml = yaml.replace(/^(custom_providers:)/m, `$1\n${newEntry}`);
399
424
  }
@@ -401,6 +426,32 @@ exports.fsRoutes.post('/api/config/providers', async (ctx) => {
401
426
  yaml = yaml.trimEnd() + `\n\ncustom_providers:\n${newEntry}\n`;
402
427
  }
403
428
  await (0, promises_1.writeFile)(configPath, yaml, 'utf-8');
429
+ // 2. Write to auth.json credential_pool so GET /api/available-models sees it immediately
430
+ const poolKey = providerKey
431
+ || `custom:${name.trim().toLowerCase().replace(/ /g, '-')}`;
432
+ const auth = await loadAuthJson() || { credential_pool: {} };
433
+ if (!auth.credential_pool)
434
+ auth.credential_pool = {};
435
+ // Don't overwrite existing entries for built-in providers
436
+ if (!auth.credential_pool[poolKey]) {
437
+ auth.credential_pool[poolKey] = [];
438
+ }
439
+ auth.credential_pool[poolKey].push({
440
+ id: `${poolKey}-${Date.now()}`,
441
+ label: name,
442
+ base_url,
443
+ access_token: api_key,
444
+ last_status: null,
445
+ });
446
+ await (0, promises_1.writeFile)(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8');
447
+ // 3. Auto-switch model to the newly added provider
448
+ let yaml2 = await safeReadFile(configPath) || '';
449
+ const modelBlockMatch = yaml2.match(/^(model:\s*\n(?: .+\n)*)/m);
450
+ if (modelBlockMatch) {
451
+ const lines = [`model:`, ` default: ${model}`, ` provider: ${poolKey}`];
452
+ yaml2 = yaml2.replace(modelBlockMatch[1], lines.join('\n') + '\n');
453
+ await (0, promises_1.writeFile)(configPath, yaml2, 'utf-8');
454
+ }
404
455
  ctx.body = { success: true };
405
456
  }
406
457
  catch (err) {
@@ -408,16 +459,64 @@ exports.fsRoutes.post('/api/config/providers', async (ctx) => {
408
459
  ctx.body = { error: err.message };
409
460
  }
410
461
  });
411
- // DELETE /api/config/providers/:name
412
- exports.fsRoutes.delete('/api/config/providers/:name', async (ctx) => {
413
- const name = ctx.params.name;
462
+ // DELETE /api/config/providers/:poolKey
463
+ exports.fsRoutes.delete('/api/config/providers/:poolKey', async (ctx) => {
464
+ const poolKey = decodeURIComponent(ctx.params.poolKey);
414
465
  try {
415
- await (0, promises_1.copyFile)(configPath, configPath + '.bak');
416
- let yaml = await safeReadFile(configPath) || '';
417
- const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
418
- const blockRegex = new RegExp(` - name:\\s*${escaped}\\s*\\n(?: .+\\n)*`, 'g');
419
- yaml = yaml.replace(blockRegex, '');
420
- await (0, promises_1.writeFile)(configPath, yaml, 'utf-8');
466
+ const auth = await loadAuthJson();
467
+ if (!auth?.credential_pool) {
468
+ ctx.status = 404;
469
+ ctx.body = { error: 'No credential pool found' };
470
+ return;
471
+ }
472
+ const keys = Object.keys(auth.credential_pool);
473
+ // Guard: cannot delete the last provider
474
+ if (keys.length <= 1) {
475
+ ctx.status = 400;
476
+ ctx.body = { error: 'Cannot delete the last provider' };
477
+ return;
478
+ }
479
+ if (!(poolKey in auth.credential_pool)) {
480
+ ctx.status = 404;
481
+ ctx.body = { error: `Provider "${poolKey}" not found` };
482
+ return;
483
+ }
484
+ // Check if this is the current active provider
485
+ const yaml = await safeReadFile(configPath) || '';
486
+ const providerMatch = yaml.match(/^ provider:\s*(.+)$/m);
487
+ const isCurrent = providerMatch && providerMatch[1].trim() === poolKey;
488
+ // Save base_url before deleting (needed for config.yaml cleanup)
489
+ const deletedBaseUrl = auth.credential_pool[poolKey]?.[0]?.base_url;
490
+ // 1. Delete from auth.json
491
+ delete auth.credential_pool[poolKey];
492
+ await (0, promises_1.writeFile)(authPath, JSON.stringify(auth, null, 2) + '\n', 'utf-8');
493
+ // 2. Remove matching entry from config.yaml custom_providers
494
+ // Use base_url to match — more reliable than name (preset key ≠ display name)
495
+ if (deletedBaseUrl) {
496
+ await (0, promises_1.copyFile)(configPath, configPath + '.bak');
497
+ let newYaml = await safeReadFile(configPath) || '';
498
+ const entryRegex = new RegExp(`^- name:.*\\n(?:[ \\t]+.*\\n)*? base_url:\\s*${escapeRegExp(deletedBaseUrl)}\\s*\\n(?:[ \\t]+.*\\n)*`, 'gm');
499
+ newYaml = newYaml.replace(entryRegex, '').replace(/\n{3,}/g, '\n\n').trimEnd() + '\n';
500
+ await (0, promises_1.writeFile)(configPath, newYaml, 'utf-8');
501
+ }
502
+ // 3. If was the current provider, switch to first remaining
503
+ if (isCurrent) {
504
+ const remainingKeys = Object.keys(auth.credential_pool);
505
+ if (remainingKeys.length > 0) {
506
+ const fallback = remainingKeys[0];
507
+ const fallbackEntry = auth.credential_pool[fallback]?.[0];
508
+ const catalogModels = PROVIDER_MODEL_CATALOG[fallback] || [];
509
+ const fallbackModel = catalogModels[0] || fallbackEntry?.label || fallback;
510
+ await (0, promises_1.copyFile)(configPath, configPath + '.bak');
511
+ let newYaml = await safeReadFile(configPath) || '';
512
+ const modelBlockMatch = newYaml.match(/^(model:\s*\n(?: .+\n)*)/m);
513
+ if (modelBlockMatch) {
514
+ const lines = [`model:`, ` default: ${fallbackModel}`, ` provider: ${fallback}`];
515
+ newYaml = newYaml.replace(modelBlockMatch[1], lines.join('\n') + '\n');
516
+ await (0, promises_1.writeFile)(configPath, newYaml, 'utf-8');
517
+ }
518
+ }
519
+ }
421
520
  ctx.body = { success: true };
422
521
  }
423
522
  catch (err) {
@@ -0,0 +1,2 @@
1
+ import Router from '@koa/router';
2
+ export declare const weixinRoutes: Router<import("koa").DefaultState, import("koa").DefaultContext>;
@@ -0,0 +1,131 @@
1
+ "use strict";
2
+ var __importDefault = (this && this.__importDefault) || function (mod) {
3
+ return (mod && mod.__esModule) ? mod : { "default": mod };
4
+ };
5
+ Object.defineProperty(exports, "__esModule", { value: true });
6
+ exports.weixinRoutes = void 0;
7
+ const router_1 = __importDefault(require("@koa/router"));
8
+ const axios_1 = __importDefault(require("axios"));
9
+ const promises_1 = require("fs/promises");
10
+ const promises_2 = require("fs/promises");
11
+ const path_1 = require("path");
12
+ const os_1 = require("os");
13
+ const hermes_cli_1 = require("../services/hermes-cli");
14
+ const envPath = (0, path_1.resolve)((0, os_1.homedir)(), '.hermes/.env');
15
+ const ILINK_BASE = 'https://ilinkai.weixin.qq.com';
16
+ exports.weixinRoutes = new router_1.default();
17
+ // GET /api/weixin/qrcode — fetch QR code from Tencent iLink API
18
+ exports.weixinRoutes.get('/api/weixin/qrcode', async (ctx) => {
19
+ try {
20
+ const res = await axios_1.default.get(`${ILINK_BASE}/ilink/bot/get_bot_qrcode`, {
21
+ params: { bot_type: 3 },
22
+ timeout: 15000,
23
+ });
24
+ const data = res.data;
25
+ if (!data || !data.qrcode) {
26
+ ctx.status = 500;
27
+ ctx.body = { error: 'Failed to get QR code' };
28
+ return;
29
+ }
30
+ ctx.body = {
31
+ qrcode: data.qrcode,
32
+ qrcode_url: data.qrcode_img_content,
33
+ };
34
+ }
35
+ catch (err) {
36
+ ctx.status = 500;
37
+ ctx.body = { error: err.message || 'Failed to connect to iLink API' };
38
+ }
39
+ });
40
+ // GET /api/weixin/qrcode/status — poll QR scan status
41
+ exports.weixinRoutes.get('/api/weixin/qrcode/status', async (ctx) => {
42
+ const qrcode = ctx.query.qrcode;
43
+ if (!qrcode) {
44
+ ctx.status = 400;
45
+ ctx.body = { error: 'Missing qrcode parameter' };
46
+ return;
47
+ }
48
+ try {
49
+ const res = await axios_1.default.get(`${ILINK_BASE}/ilink/bot/get_qrcode_status`, {
50
+ params: { qrcode },
51
+ timeout: 35000,
52
+ });
53
+ const data = res.data;
54
+ const status = data?.status || 'wait';
55
+ ctx.body = { status };
56
+ // If confirmed, return credentials so frontend can save them
57
+ if (status === 'confirmed') {
58
+ ctx.body = {
59
+ status: 'confirmed',
60
+ account_id: data.ilink_bot_id,
61
+ token: data.bot_token,
62
+ base_url: data.baseurl,
63
+ };
64
+ }
65
+ }
66
+ catch (err) {
67
+ ctx.status = 500;
68
+ ctx.body = { error: err.message || 'Failed to poll QR status' };
69
+ }
70
+ });
71
+ // POST /api/weixin/save — save weixin credentials to .env
72
+ exports.weixinRoutes.post('/api/weixin/save', async (ctx) => {
73
+ const { account_id, token, base_url } = ctx.request.body;
74
+ if (!account_id || !token) {
75
+ ctx.status = 400;
76
+ ctx.body = { error: 'Missing account_id or token' };
77
+ return;
78
+ }
79
+ try {
80
+ let raw;
81
+ try {
82
+ raw = await (0, promises_1.readFile)(envPath, 'utf-8');
83
+ }
84
+ catch {
85
+ raw = '';
86
+ }
87
+ const entries = {
88
+ WEIXIN_ACCOUNT_ID: account_id,
89
+ WEIXIN_TOKEN: token,
90
+ };
91
+ if (base_url)
92
+ entries.WEIXIN_BASE_URL = base_url;
93
+ const lines = raw.split('\n');
94
+ const existingKeys = new Set();
95
+ const result = [];
96
+ for (const line of lines) {
97
+ const trimmed = line.trim();
98
+ if (trimmed.startsWith('#')) {
99
+ result.push(line);
100
+ continue;
101
+ }
102
+ const eqIdx = trimmed.indexOf('=');
103
+ if (eqIdx !== -1) {
104
+ const key = trimmed.slice(0, eqIdx).trim();
105
+ if (key in entries) {
106
+ result.push(`${key}=${entries[key]}`);
107
+ existingKeys.add(key);
108
+ continue;
109
+ }
110
+ }
111
+ result.push(line);
112
+ }
113
+ for (const [key, val] of Object.entries(entries)) {
114
+ if (!existingKeys.has(key)) {
115
+ result.push(`${key}=${val}`);
116
+ }
117
+ }
118
+ let output = result.join('\n').replace(/\n{3,}/g, '\n\n').replace(/\n+$/, '') + '\n';
119
+ await (0, promises_1.writeFile)(envPath, output, 'utf-8');
120
+ try {
121
+ await (0, promises_2.chmod)(envPath, 0o600);
122
+ }
123
+ catch { /* ignore */ }
124
+ await (0, hermes_cli_1.restartGateway)();
125
+ ctx.body = { success: true };
126
+ }
127
+ catch (err) {
128
+ ctx.status = 500;
129
+ ctx.body = { error: err.message };
130
+ }
131
+ });
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Provider registry — single source of truth for both frontend and backend.
3
+ * Synced from hermes-agent hermes_cli/models.py _PROVIDER_MODELS.
4
+ */
5
+ export interface ProviderPreset {
6
+ label: string;
7
+ value: string;
8
+ base_url: string;
9
+ models: string[];
10
+ }
11
+ export declare const PROVIDER_PRESETS: ProviderPreset[];
12
+ /** Build a Record<providerKey, models[]> for backend lookup */
13
+ export declare function buildProviderModelMap(): Record<string, string[]>;