orbitchat 3.0.0 → 3.1.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.
Files changed (62) hide show
  1. package/README.md +93 -90
  2. package/bin/orbitchat.js +517 -430
  3. package/dist/assets/{ChartRenderer-B1mYF_kk.js → ChartRenderer-CapBCpbV.js} +1 -1
  4. package/dist/assets/{MermaidRenderer-Dhffx5mc.js → MermaidRenderer-DQJRirnm.js} +5 -5
  5. package/dist/assets/{MusicRenderer-DbXJ8e4-.js → MusicRenderer-9vnA54gg.js} +2 -2
  6. package/dist/assets/{SVGRenderer-EhamRX_K.js → SVGRenderer-By7CKtqU.js} +1 -1
  7. package/dist/assets/{_basePickBy-C2AibBqo.js → _basePickBy-Dm-zx8im.js} +1 -1
  8. package/dist/assets/{_baseUniq-D52pUSvn.js → _baseUniq-DLPvoy-d.js} +1 -1
  9. package/dist/assets/{architectureDiagram-VXUJARFQ-BLMvVsZu.js → architectureDiagram-VXUJARFQ-B1kMlTMW.js} +1 -1
  10. package/dist/assets/{blockDiagram-VD42YOAC-CTz0dv5u.js → blockDiagram-VD42YOAC-BlglDb-U.js} +1 -1
  11. package/dist/assets/{c4Diagram-YG6GDRKO-BjbN-GGh.js → c4Diagram-YG6GDRKO-CaZm--Yy.js} +1 -1
  12. package/dist/assets/channel-BjUDQMqf.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-DEMWGIsU.js → chunk-4BX2VUAB-4Kq-2AyN.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-BYaauHLT.js → chunk-55IACEB6-FApueVFk.js} +1 -1
  15. package/dist/assets/{chunk-B4BG7PRW-D3_GFcfd.js → chunk-B4BG7PRW-CHox7be9.js} +1 -1
  16. package/dist/assets/{chunk-DI55MBZ5-Dy2EuzfA.js → chunk-DI55MBZ5-N5sePLFo.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-C9XHmwza.js → chunk-FMBD7UC4-BSbBs0xy.js} +1 -1
  18. package/dist/assets/{chunk-QN33PNHL-BOCltIi9.js → chunk-QN33PNHL-ByurI9-W.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-CvA964eQ.js → chunk-QZHKN3VN-Yy8VVut9.js} +1 -1
  20. package/dist/assets/{chunk-TZMSLE5B-B-3rZz90.js → chunk-TZMSLE5B-B5oe8pT4.js} +1 -1
  21. package/dist/assets/classDiagram-2ON5EDUG-BhHCvLfM.js +1 -0
  22. package/dist/assets/classDiagram-v2-WZHVMYZB-BhHCvLfM.js +1 -0
  23. package/dist/assets/clone-oQOn4Sap.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-CzPip3he.js → cose-bilkent-S5V4N54A-DS_-k6qR.js} +1 -1
  25. package/dist/assets/{dagre-6UL2VRFP-CfCXYus3.js → dagre-6UL2VRFP-DgkwJjVY.js} +1 -1
  26. package/dist/assets/{diagram-PSM6KHXK-DyI2eDqp.js → diagram-PSM6KHXK-C9su64f9.js} +1 -1
  27. package/dist/assets/{diagram-QEK2KX5R-DuakIh40.js → diagram-QEK2KX5R-DY6-9Dbc.js} +1 -1
  28. package/dist/assets/{diagram-S2PKOQOG-C3-4IORM.js → diagram-S2PKOQOG-_Wub-Eik.js} +1 -1
  29. package/dist/assets/{erDiagram-Q2GNP2WA-Dt-nZEaD.js → erDiagram-Q2GNP2WA-Gwx9nSAM.js} +1 -1
  30. package/dist/assets/{flowDiagram-NV44I4VS-B-2wjyTQ.js → flowDiagram-NV44I4VS-DmrXTF_S.js} +1 -1
  31. package/dist/assets/{ganttDiagram-JELNMOA3-CHTvjPq0.js → ganttDiagram-JELNMOA3-DymVUgsd.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-V2S2FVAM-d5GWmgY4.js → gitGraphDiagram-V2S2FVAM-D3VSQFT4.js} +1 -1
  33. package/dist/assets/{graph-COgkbiU8.js → graph-BIe-N9yO.js} +1 -1
  34. package/dist/assets/index-B-NnBj1Y.js +624 -0
  35. package/dist/assets/index-BA5Mw76S.css +1 -0
  36. package/dist/assets/{index-DN2bBcCs.js → index-BiM6VNSE.js} +1 -1
  37. package/dist/assets/{infoDiagram-HS3SLOUP-30Hn0iLj.js → infoDiagram-HS3SLOUP-D2DeUrgk.js} +1 -1
  38. package/dist/assets/{journeyDiagram-XKPGCS4Q-CMOYXQ79.js → journeyDiagram-XKPGCS4Q-GxFhv1XB.js} +1 -1
  39. package/dist/assets/{kanban-definition-3W4ZIXB7-dt3376cq.js → kanban-definition-3W4ZIXB7-BhT-ga9I.js} +1 -1
  40. package/dist/assets/{layout-DH97sy5g.js → layout-38O87Ivt.js} +1 -1
  41. package/dist/assets/{mindmap-definition-VGOIOE7T-BcZHPAlA.js → mindmap-definition-VGOIOE7T-DfKjmcaV.js} +1 -1
  42. package/dist/assets/{pieDiagram-ADFJNKIX-DXjvgcgl.js → pieDiagram-ADFJNKIX-D7lDWRGN.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-AYHSOK5B-CICk2FdS.js → quadrantDiagram-AYHSOK5B-CPtZ8LgW.js} +1 -1
  44. package/dist/assets/{requirementDiagram-UZGBJVZJ-BdtxpQRA.js → requirementDiagram-UZGBJVZJ-h-H7R8sR.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-TZEHDZUN-BVNS9BQJ.js → sankeyDiagram-TZEHDZUN-C6cn5g4m.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-WL72ISMW-DF4fJTM7.js → sequenceDiagram-WL72ISMW-B6AwcQ4F.js} +1 -1
  47. package/dist/assets/{stateDiagram-FKZM4ZOC-C8H8HujX.js → stateDiagram-FKZM4ZOC-B4ne7z3I.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-4FDKWEC3-C_qp8U90.js +1 -0
  49. package/dist/assets/{timeline-definition-IT6M3QCI-DUSG5Vfy.js → timeline-definition-IT6M3QCI-BmjJnJ-r.js} +1 -1
  50. package/dist/assets/treemap-GDKQZRPO-CBUbyJIX.js +160 -0
  51. package/dist/assets/{xychartDiagram-PRI3JC2R-CFPJj8WK.js → xychartDiagram-PRI3JC2R-C3lVgboM.js} +1 -1
  52. package/dist/index.html +2 -2
  53. package/orbitchat.sh +305 -0
  54. package/package.json +6 -2
  55. package/dist/assets/channel-ryTtfXZE.js +0 -1
  56. package/dist/assets/classDiagram-2ON5EDUG-VbShFIzz.js +0 -1
  57. package/dist/assets/classDiagram-v2-WZHVMYZB-VbShFIzz.js +0 -1
  58. package/dist/assets/clone-BlhdKVDQ.js +0 -1
  59. package/dist/assets/index-DtztdW2a.js +0 -643
  60. package/dist/assets/index-lGqyWNWb.css +0 -1
  61. package/dist/assets/stateDiagram-v2-4FDKWEC3-DwtU033b.js +0 -1
  62. package/dist/assets/treemap-GDKQZRPO-1PI2PY4S.js +0 -160
package/bin/orbitchat.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * ORBIT Chat CLI
4
- *
4
+ *
5
5
  * Serves the chat-app as a standalone application with runtime configuration.
6
- * Configuration can be provided via CLI arguments, config file, or environment variables.
7
- *
6
+ * Configuration is read from orbitchat.yaml (CWD by default, overridable via --config).
7
+ * Secrets (adapter API keys) come from VITE_ADAPTERS / ORBIT_ADAPTERS env var.
8
+ *
8
9
  * The server acts as a proxy to hide API keys from the client by mapping
9
10
  * adapter names to actual API keys.
10
11
  */
@@ -12,34 +13,101 @@
12
13
  import express from 'express';
13
14
  import fs from 'fs';
14
15
  import path from 'path';
15
- import { homedir } from 'os';
16
16
  import { execSync } from 'child_process';
17
17
  import { fileURLToPath } from 'url';
18
18
  import { dirname } from 'path';
19
19
  import { createProxyMiddleware } from 'http-proxy-middleware';
20
+ import yaml from 'js-yaml';
21
+ import rateLimit from 'express-rate-limit';
20
22
 
21
23
  const __filename = fileURLToPath(import.meta.url);
22
24
  const __dirname = dirname(__filename);
23
25
 
24
- // Default configuration matching env.example
25
- // Note: GitHub stats/owner/repo are hardcoded and only configurable via build-time env vars
26
- const DEFAULT_CONFIG = {
26
+ // ---- Minimal .env loader (CLI mode) ----
27
+
28
+ function parseDotEnvValue(raw) {
29
+ const trimmed = raw.trim();
30
+ if (!trimmed) return '';
31
+
32
+ if (
33
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
34
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
35
+ ) {
36
+ return trimmed.slice(1, -1);
37
+ }
38
+
39
+ return trimmed;
40
+ }
41
+
42
+ function loadDotEnvFromFile(filePath) {
43
+ if (!fs.existsSync(filePath)) return;
44
+ const content = fs.readFileSync(filePath, 'utf8');
45
+ const lines = content.split(/\r?\n/);
46
+
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const trimmed = lines[i].trim();
49
+ if (!trimmed || trimmed.startsWith('#')) continue;
50
+
51
+ const equalsIndex = trimmed.indexOf('=');
52
+ if (equalsIndex <= 0) continue;
53
+
54
+ const key = trimmed.slice(0, equalsIndex).trim();
55
+ if (!key) continue;
56
+
57
+ if (process.env[key] !== undefined) continue;
58
+
59
+ let valueRaw = trimmed.slice(equalsIndex + 1);
60
+ const startsWithDouble = valueRaw.startsWith('"');
61
+ const startsWithSingle = valueRaw.startsWith("'");
62
+
63
+ // Support simple multiline quoted values, useful for formatted JSON values.
64
+ if (
65
+ (startsWithDouble && !valueRaw.endsWith('"')) ||
66
+ (startsWithSingle && !valueRaw.endsWith("'"))
67
+ ) {
68
+ const quote = startsWithDouble ? '"' : "'";
69
+ while (i + 1 < lines.length) {
70
+ i += 1;
71
+ valueRaw += `\n${lines[i]}`;
72
+ if (lines[i].trim().endsWith(quote)) {
73
+ break;
74
+ }
75
+ }
76
+ }
77
+
78
+ const value = parseDotEnvValue(valueRaw);
79
+ process.env[key] = value;
80
+ }
81
+ }
82
+
83
+ function loadDotEnv(baseDir) {
84
+ // Same precedence idea as Vite: .env then .env.local; do not override exported vars.
85
+ loadDotEnvFromFile(path.join(baseDir, '.env'));
86
+ loadDotEnvFromFile(path.join(baseDir, '.env.local'));
87
+ }
88
+
89
+ // ---- Defaults (must match DEFAULTS in src/utils/runtimeConfig.ts) ----
90
+
91
+ const DEFAULTS = {
27
92
  apiUrl: 'http://localhost:3000',
28
93
  defaultKey: 'default-key',
29
94
  applicationName: 'ORBIT Chat',
30
95
  applicationDescription: "Explore ideas with ORBIT's AI copilots, share context, and build together.",
31
96
  defaultInputPlaceholder: 'Message ORBIT...',
32
- useLocalApi: false,
33
- localApiPath: undefined,
34
97
  consoleDebug: false,
98
+ locale: 'en-US',
35
99
  enableUploadButton: false,
36
100
  enableAudioOutput: false,
37
101
  enableAudioInput: false,
38
102
  enableFeedbackButtons: false,
103
+ enableConversationThreads: true,
39
104
  enableAutocomplete: false,
40
105
  voiceSilenceTimeoutMs: 4000,
41
106
  voiceRecognitionLanguage: '',
107
+ showGitHubStats: true,
42
108
  outOfServiceMessage: null,
109
+ githubOwner: 'schmitech',
110
+ githubRepo: 'orbit',
43
111
  maxFilesPerConversation: 5,
44
112
  maxFileSizeMB: 50,
45
113
  maxTotalFiles: 100,
@@ -48,61 +116,219 @@ const DEFAULT_CONFIG = {
48
116
  maxMessagesPerThread: 1000,
49
117
  maxTotalMessages: 10000,
50
118
  maxMessageLength: 1000,
119
+ guestMaxConversations: 1,
120
+ guestMaxMessagesPerConversation: 10,
121
+ guestMaxTotalMessages: 10,
122
+ guestMaxMessagesPerThread: 10,
123
+ guestMaxFilesPerConversation: 1,
124
+ guestMaxTotalFiles: 2,
125
+ guestMaxMessageLength: 500,
126
+ guestMaxFileSizeMB: 10,
51
127
  settingsAboutMsg: 'ORBIT Chat',
128
+ enableAuth: false,
129
+ authDomain: '',
130
+ authClientId: '',
131
+ authAudience: '',
132
+ enableHeader: false,
133
+ headerLogoUrl: '',
134
+ headerBrandName: '',
135
+ headerBgColor: '',
136
+ headerTextColor: '',
137
+ headerNavLinks: [],
138
+ enableFooter: false,
139
+ footerText: '',
140
+ footerBgColor: '',
141
+ footerTextColor: '',
142
+ footerNavLinks: [],
52
143
  };
53
144
 
54
- function parseAdaptersListFromEnv() {
55
- const envValue = process.env.ORBIT_ADAPTERS || process.env.VITE_ADAPTERS;
56
- if (!envValue) {
57
- return [];
145
+ // ---- YAML config loading ----
146
+
147
+ function flattenYamlConfig(y) {
148
+ const f = {};
149
+ if (y.application) {
150
+ const a = y.application;
151
+ if (a.name !== undefined) f.applicationName = a.name;
152
+ if (a.description !== undefined) f.applicationDescription = a.description;
153
+ if (a.inputPlaceholder !== undefined) f.defaultInputPlaceholder = a.inputPlaceholder;
154
+ if (a.settingsAboutMsg !== undefined) f.settingsAboutMsg = a.settingsAboutMsg;
155
+ if (a.locale !== undefined) f.locale = a.locale;
156
+ }
157
+ if (y.api) {
158
+ if (y.api.url !== undefined) f.apiUrl = y.api.url;
159
+ if (y.api.defaultAdapter !== undefined) f.defaultKey = y.api.defaultAdapter;
160
+ }
161
+ if (y.debug) {
162
+ if (y.debug.consoleDebug !== undefined) f.consoleDebug = y.debug.consoleDebug;
163
+ }
164
+ if (y.features) {
165
+ const fe = y.features;
166
+ if (fe.enableUpload !== undefined) f.enableUploadButton = fe.enableUpload;
167
+ if (fe.enableAudioOutput !== undefined) f.enableAudioOutput = fe.enableAudioOutput;
168
+ if (fe.enableAudioInput !== undefined) f.enableAudioInput = fe.enableAudioInput;
169
+ if (fe.enableFeedbackButtons !== undefined) f.enableFeedbackButtons = fe.enableFeedbackButtons;
170
+ if (fe.enableConversationThreads !== undefined) f.enableConversationThreads = fe.enableConversationThreads;
171
+ if (fe.enableAutocomplete !== undefined) f.enableAutocomplete = fe.enableAutocomplete;
172
+ }
173
+ if (y.voice) {
174
+ if (y.voice.silenceTimeoutMs !== undefined) f.voiceSilenceTimeoutMs = y.voice.silenceTimeoutMs;
175
+ if (y.voice.recognitionLanguage !== undefined) f.voiceRecognitionLanguage = y.voice.recognitionLanguage;
176
+ }
177
+ if (y.github) {
178
+ if (y.github.showStats !== undefined) f.showGitHubStats = y.github.showStats;
179
+ if (y.github.owner !== undefined) f.githubOwner = y.github.owner;
180
+ if (y.github.repo !== undefined) f.githubRepo = y.github.repo;
181
+ }
182
+ if (y.outOfServiceMessage !== undefined) f.outOfServiceMessage = y.outOfServiceMessage;
183
+ if (y.limits) {
184
+ const l = y.limits;
185
+ if (l.files) {
186
+ if (l.files.perConversation !== undefined) f.maxFilesPerConversation = l.files.perConversation;
187
+ if (l.files.maxSizeMB !== undefined) f.maxFileSizeMB = l.files.maxSizeMB;
188
+ if (l.files.totalFiles !== undefined) f.maxTotalFiles = l.files.totalFiles;
189
+ }
190
+ if (l.conversations) {
191
+ if (l.conversations.maxConversations !== undefined) f.maxConversations = l.conversations.maxConversations;
192
+ if (l.conversations.messagesPerConversation !== undefined) f.maxMessagesPerConversation = l.conversations.messagesPerConversation;
193
+ if (l.conversations.messagesPerThread !== undefined) f.maxMessagesPerThread = l.conversations.messagesPerThread;
194
+ if (l.conversations.totalMessages !== undefined) f.maxTotalMessages = l.conversations.totalMessages;
195
+ }
196
+ if (l.messages) {
197
+ if (l.messages.maxLength !== undefined) f.maxMessageLength = l.messages.maxLength;
198
+ }
199
+ }
200
+ if (y.guestLimits) {
201
+ const g = y.guestLimits;
202
+ if (g.files) {
203
+ if (g.files.perConversation !== undefined) f.guestMaxFilesPerConversation = g.files.perConversation;
204
+ if (g.files.maxSizeMB !== undefined) f.guestMaxFileSizeMB = g.files.maxSizeMB;
205
+ if (g.files.totalFiles !== undefined) f.guestMaxTotalFiles = g.files.totalFiles;
206
+ }
207
+ if (g.conversations) {
208
+ if (g.conversations.maxConversations !== undefined) f.guestMaxConversations = g.conversations.maxConversations;
209
+ if (g.conversations.messagesPerConversation !== undefined) f.guestMaxMessagesPerConversation = g.conversations.messagesPerConversation;
210
+ if (g.conversations.messagesPerThread !== undefined) f.guestMaxMessagesPerThread = g.conversations.messagesPerThread;
211
+ if (g.conversations.totalMessages !== undefined) f.guestMaxTotalMessages = g.conversations.totalMessages;
212
+ }
213
+ if (g.messages) {
214
+ if (g.messages.maxLength !== undefined) f.guestMaxMessageLength = g.messages.maxLength;
215
+ }
216
+ }
217
+ if (y.auth) {
218
+ if (y.auth.enabled !== undefined) f.enableAuth = y.auth.enabled;
219
+ }
220
+ if (y.header) {
221
+ const h = y.header;
222
+ if (h.enabled !== undefined) f.enableHeader = h.enabled;
223
+ if (h.logoUrl !== undefined) f.headerLogoUrl = h.logoUrl;
224
+ if (h.brandName !== undefined) f.headerBrandName = h.brandName;
225
+ if (h.bgColor !== undefined) f.headerBgColor = h.bgColor;
226
+ if (h.textColor !== undefined) f.headerTextColor = h.textColor;
227
+ if (h.navLinks !== undefined) f.headerNavLinks = h.navLinks;
58
228
  }
229
+ if (y.footer) {
230
+ const ft = y.footer;
231
+ if (ft.enabled !== undefined) f.enableFooter = ft.enabled;
232
+ if (ft.text !== undefined) f.footerText = ft.text;
233
+ if (ft.bgColor !== undefined) f.footerBgColor = ft.bgColor;
234
+ if (ft.textColor !== undefined) f.footerTextColor = ft.textColor;
235
+ if (ft.navLinks !== undefined) f.footerNavLinks = ft.navLinks;
236
+ }
237
+ // Adapters from YAML: include metadata (name, description, notes, apiUrl) but NOT apiKey
238
+ if (y.adapters !== undefined) {
239
+ f.adapters = y.adapters.map(a => ({
240
+ name: a.name,
241
+ ...(a.apiUrl ? { apiUrl: a.apiUrl } : {}),
242
+ ...(a.description ? { description: a.description } : {}),
243
+ ...(a.notes ? { notes: a.notes } : {}),
244
+ }));
245
+ }
246
+ return f;
247
+ }
59
248
 
249
+ function loadYamlConfig(configPath) {
60
250
  try {
61
- const parsed = JSON.parse(envValue);
62
- if (!Array.isArray(parsed)) {
63
- console.warn('Warning: ORBIT_ADAPTERS/VITE_ADAPTERS must be a JSON array');
64
- return [];
251
+ if (fs.existsSync(configPath)) {
252
+ const content = fs.readFileSync(configPath, 'utf8');
253
+ const parsed = yaml.load(content);
254
+ if (parsed && typeof parsed === 'object') {
255
+ return parsed;
256
+ }
65
257
  }
258
+ } catch (error) {
259
+ console.error(`Error: Failed to parse ${configPath}: ${error.message}`);
260
+ process.exit(1);
261
+ }
262
+ return null;
263
+ }
66
264
 
67
- const adapters = [];
68
- for (const adapter of parsed) {
69
- if (!adapter.name) {
70
- console.warn('Warning: Each adapter must have a "name" property');
71
- continue;
265
+ // ---- Local asset handling (e.g. header logo file paths) ----
266
+
267
+ function isUrlLike(value) {
268
+ return /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value) || value.startsWith('//');
269
+ }
270
+
271
+ function resolveLocalAssetPath(rawValue, yamlPath) {
272
+ if (!rawValue || typeof rawValue !== 'string') return null;
273
+ const value = rawValue.trim();
274
+ if (!value || isUrlLike(value)) return null;
275
+
276
+ const expandedValue = value.startsWith('~/')
277
+ ? path.join(process.env.HOME || '', value.slice(2))
278
+ : value;
279
+
280
+ const yamlDir = path.dirname(yamlPath);
281
+ const candidates = path.isAbsolute(expandedValue)
282
+ ? [expandedValue]
283
+ : [path.resolve(yamlDir, expandedValue), path.resolve(process.cwd(), expandedValue)];
284
+
285
+ for (const candidate of candidates) {
286
+ try {
287
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
288
+ return fs.realpathSync(candidate);
72
289
  }
73
- adapters.push({
74
- name: adapter.name,
75
- apiKey: adapter.apiKey || DEFAULT_CONFIG.defaultKey,
76
- apiUrl: adapter.apiUrl || DEFAULT_CONFIG.apiUrl,
77
- description: adapter.description || adapter.summary,
78
- notes: adapter.notes,
79
- });
290
+ } catch {
291
+ // ignore invalid candidate and continue
80
292
  }
293
+ }
294
+
295
+ return null;
296
+ }
297
+
298
+ // ---- Adapter loading (env secrets + YAML metadata) ----
299
+
300
+ function parseAdaptersFromEnv() {
301
+ const envValue = process.env.ORBIT_ADAPTERS || process.env.VITE_ADAPTERS;
302
+ if (!envValue) return [];
81
303
 
82
- return adapters;
304
+ try {
305
+ const parsed = JSON.parse(envValue);
306
+ if (!Array.isArray(parsed)) {
307
+ console.warn('Warning: ORBIT_ADAPTERS/VITE_ADAPTERS must be a JSON array');
308
+ return [];
309
+ }
310
+ return parsed.filter(a => a.name).map(a => ({
311
+ name: a.name,
312
+ apiKey: a.apiKey || DEFAULTS.defaultKey,
313
+ apiUrl: a.apiUrl || DEFAULTS.apiUrl,
314
+ description: a.description || a.summary,
315
+ notes: a.notes,
316
+ }));
83
317
  } catch (error) {
84
318
  console.warn('Warning: Could not parse ORBIT_ADAPTERS/VITE_ADAPTERS:', error.message);
85
319
  return [];
86
320
  }
87
321
  }
88
322
 
89
- /**
90
- * Load adapter mappings from environment variable
91
- * Format: JSON array of adapter objects
92
- * Example: ORBIT_ADAPTERS='[{"name":"Simple Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
93
- * @returns {object|null} - Adapters object or null if not found/invalid
94
- */
95
323
  function loadAdaptersConfig() {
96
- const adapterList = parseAdaptersListFromEnv();
97
- if (adapterList.length === 0) {
98
- return null;
99
- }
324
+ const adapterList = parseAdaptersFromEnv();
325
+ if (adapterList.length === 0) return null;
100
326
 
101
327
  const adapters = {};
102
328
  for (const adapter of adapterList) {
103
329
  adapters[adapter.name] = {
104
- apiKey: adapter.apiKey || DEFAULT_CONFIG.defaultKey,
105
- apiUrl: adapter.apiUrl || DEFAULT_CONFIG.apiUrl,
330
+ apiKey: adapter.apiKey || DEFAULTS.defaultKey,
331
+ apiUrl: adapter.apiUrl || DEFAULTS.apiUrl,
106
332
  description: adapter.description,
107
333
  notes: adapter.notes,
108
334
  };
@@ -112,113 +338,26 @@ function loadAdaptersConfig() {
112
338
  }
113
339
 
114
340
  function getDefaultAdapterFromEnv() {
115
- const adapterList = parseAdaptersListFromEnv();
341
+ const adapterList = parseAdaptersFromEnv();
116
342
  return adapterList.length > 0 ? adapterList[0].name : null;
117
343
  }
118
344
 
119
- /**
120
- * Parse command-line arguments
121
- */
345
+ // ---- CLI arg parsing (server flags only) ----
346
+
122
347
  function parseArgs() {
123
348
  const args = process.argv.slice(2);
124
- const config = { ...DEFAULT_CONFIG };
125
349
  const serverConfig = {
126
350
  port: 5173,
127
351
  host: 'localhost',
128
352
  open: false,
129
353
  configFile: null,
130
354
  apiOnly: false,
355
+ corsOrigin: undefined,
131
356
  };
132
357
 
133
358
  for (let i = 0; i < args.length; i++) {
134
359
  const arg = args[i];
135
-
136
360
  switch (arg) {
137
- case '--api-url':
138
- config.apiUrl = args[++i];
139
- break;
140
- case '--api-key':
141
- config.defaultKey = args[++i];
142
- break;
143
- case '--default-adapter':
144
- config.defaultKey = args[++i];
145
- break;
146
- case '--default-key':
147
- console.warn('Warning: --default-key is deprecated. Use --default-adapter instead.');
148
- config.defaultKey = args[++i];
149
- break;
150
- case '--application-name':
151
- config.applicationName = args[++i];
152
- break;
153
- case '--application-description':
154
- config.applicationDescription = args[++i];
155
- break;
156
- case '--default-input-placeholder':
157
- config.defaultInputPlaceholder = args[++i];
158
- break;
159
- case '--use-local-api':
160
- config.useLocalApi = true;
161
- break;
162
- case '--local-api-path':
163
- config.localApiPath = args[++i];
164
- break;
165
- case '--console-debug':
166
- config.consoleDebug = true;
167
- break;
168
- case '--enable-upload':
169
- config.enableUploadButton = true;
170
- break;
171
- case '--enable-audio':
172
- config.enableAudioOutput = true;
173
- break;
174
- case '--enable-audio-input':
175
- config.enableAudioInput = true;
176
- break;
177
- case '--enable-feedback':
178
- config.enableFeedbackButtons = true;
179
- break;
180
- case '--enable-autocomplete':
181
- config.enableAutocomplete = true;
182
- break;
183
- case '--voice-silence-timeout-ms':
184
- config.voiceSilenceTimeoutMs = parseInt(args[++i], 10);
185
- break;
186
- case '--voice-recognition-lang':
187
- config.voiceRecognitionLanguage = args[++i];
188
- break;
189
- case '--enable-api-middleware':
190
- // Ignored — middleware mode is now always enabled
191
- break;
192
- case '--out-of-service-message':
193
- config.outOfServiceMessage = args[++i];
194
- break;
195
- case '--max-files-per-conversation':
196
- config.maxFilesPerConversation = parseInt(args[++i], 10);
197
- break;
198
- case '--max-file-size-mb':
199
- config.maxFileSizeMB = parseInt(args[++i], 10);
200
- break;
201
- case '--max-total-files':
202
- config.maxTotalFiles = parseInt(args[++i], 10);
203
- break;
204
- case '--max-conversations':
205
- config.maxConversations = parseInt(args[++i], 10);
206
- break;
207
- case '--max-messages-per-conversation':
208
- config.maxMessagesPerConversation = parseInt(args[++i], 10);
209
- break;
210
- case '--max-messages-per-thread':
211
- config.maxMessagesPerThread = parseInt(args[++i], 10);
212
- break;
213
- case '--max-total-messages':
214
- config.maxTotalMessages = parseInt(args[++i], 10);
215
- break;
216
- case '--max-message-length':
217
- config.maxMessageLength = parseInt(args[++i], 10);
218
- break;
219
- case '--settings-about-msg':
220
- config.settingsAboutMsg = args[++i];
221
- break;
222
361
  case '--port':
223
362
  serverConfig.port = parseInt(args[++i], 10);
224
363
  break;
@@ -228,22 +367,19 @@ function parseArgs() {
228
367
  case '--open':
229
368
  serverConfig.open = true;
230
369
  break;
370
+ case '--config':
371
+ serverConfig.configFile = args[++i];
372
+ break;
231
373
  case '--api-only':
232
374
  serverConfig.apiOnly = true;
233
375
  break;
234
376
  case '--cors-origin':
235
377
  serverConfig.corsOrigin = args[++i];
236
378
  break;
237
- case '--config':
238
- serverConfig.configFile = args[++i];
239
- break;
240
379
  case '--help':
241
380
  case '-h':
242
- // Handled in main() function
243
- break;
244
381
  case '--version':
245
382
  case '-v':
246
- // Handled in main() function
247
383
  break;
248
384
  default:
249
385
  if (arg.startsWith('--')) {
@@ -254,126 +390,15 @@ function parseArgs() {
254
390
  }
255
391
  }
256
392
 
257
- return { config, serverConfig };
258
- }
259
-
260
- /**
261
- * Load configuration from file
262
- */
263
- function loadConfigFile(filePath) {
264
- try {
265
- if (fs.existsSync(filePath)) {
266
- const content = fs.readFileSync(filePath, 'utf8');
267
- return JSON.parse(content);
268
- }
269
- } catch (error) {
270
- console.warn(`Warning: Could not load config file ${filePath}:`, error.message);
271
- }
272
- return null;
273
- }
274
-
275
- /**
276
- * Load configuration from environment variables
277
- */
278
- function loadConfigFromEnv() {
279
- const envConfig = {};
280
-
281
- // Map VITE_* environment variables to config keys
282
- const envMap = {
283
- VITE_API_URL: 'apiUrl',
284
- VITE_DEFAULT_KEY: 'defaultKey',
285
- VITE_APPLICATION_NAME: 'applicationName',
286
- VITE_APPLICATION_DESCRIPTION: 'applicationDescription',
287
- VITE_DEFAULT_INPUT_PLACEHOLDER: 'defaultInputPlaceholder',
288
- VITE_USE_LOCAL_API: 'useLocalApi',
289
- VITE_LOCAL_API_PATH: 'localApiPath',
290
- VITE_CONSOLE_DEBUG: 'consoleDebug',
291
- VITE_ENABLE_UPLOAD: 'enableUploadButton',
292
- VITE_ENABLE_AUDIO_OUTPUT: 'enableAudioOutput',
293
- VITE_ENABLE_AUDIO_INPUT: 'enableAudioInput',
294
- VITE_ENABLE_FEEDBACK: 'enableFeedbackButtons',
295
- VITE_ENABLE_AUTOCOMPLETE: 'enableAutocomplete',
296
- VITE_VOICE_SILENCE_TIMEOUT_MS: 'voiceSilenceTimeoutMs',
297
- VITE_VOICE_RECOGNITION_LANG: 'voiceRecognitionLanguage',
298
- VITE_OUT_OF_SERVICE_MESSAGE: 'outOfServiceMessage',
299
- VITE_MAX_FILES_PER_CONVERSATION: 'maxFilesPerConversation',
300
- VITE_MAX_FILE_SIZE_MB: 'maxFileSizeMB',
301
- VITE_MAX_TOTAL_FILES: 'maxTotalFiles',
302
- VITE_MAX_CONVERSATIONS: 'maxConversations',
303
- VITE_MAX_MESSAGES_PER_CONVERSATION: 'maxMessagesPerConversation',
304
- VITE_MAX_MESSAGES_PER_THREAD: 'maxMessagesPerThread',
305
- VITE_MAX_TOTAL_MESSAGES: 'maxTotalMessages',
306
- VITE_MAX_MESSAGE_LENGTH: 'maxMessageLength',
307
- VITE_SETTINGS_ABOUT_MSG: 'settingsAboutMsg',
308
- };
309
-
310
- for (const [envKey, configKey] of Object.entries(envMap)) {
311
- const value = process.env[envKey];
312
- if (value !== undefined) {
313
- if (configKey === 'useLocalApi' || configKey === 'consoleDebug' ||
314
- configKey === 'enableUploadButton' || configKey === 'enableAudioOutput' ||
315
- configKey === 'enableAudioInput' || configKey === 'enableFeedbackButtons' || configKey === 'enableAutocomplete') {
316
- envConfig[configKey] = value === 'true';
317
- } else if (configKey === 'voiceSilenceTimeoutMs') {
318
- const parsed = parseInt(value, 10);
319
- if (!isNaN(parsed)) {
320
- envConfig[configKey] = parsed;
321
- }
322
- } else if (configKey.includes('max') && configKey !== 'maxFileSizeMB') {
323
- const parsed = parseInt(value, 10);
324
- if (!isNaN(parsed)) {
325
- envConfig[configKey] = parsed;
326
- }
327
- } else {
328
- envConfig[configKey] = value;
329
- }
330
- }
331
- }
332
-
333
- return envConfig;
393
+ return serverConfig;
334
394
  }
335
395
 
336
- /**
337
- * Merge configurations in priority order: CLI args > config file > env vars > defaults
338
- * Note: GitHub stats/owner/repo are not included in runtime config - they're hardcoded
339
- * and only configurable via build-time env vars for developers who fork.
340
- */
341
- function mergeConfig(cliConfig, serverConfig) {
342
- // Start with defaults
343
- let config = { ...DEFAULT_CONFIG };
344
-
345
- // Load from environment variables (excluding GitHub config)
346
- const envConfig = loadConfigFromEnv();
347
- config = { ...config, ...envConfig };
348
-
349
- // Load from config file (excluding GitHub config)
350
- const configDir = path.join(homedir(), '.orbit-chat-app');
351
- const defaultConfigFile = path.join(configDir, 'config.json');
352
- const configFile = serverConfig.configFile || defaultConfigFile;
353
-
354
- const fileConfig = loadConfigFile(configFile);
355
- if (fileConfig) {
356
- // Remove GitHub config from file config if present
357
- const { showGitHubStats, githubOwner, githubRepo, ...fileConfigWithoutGitHub } = fileConfig;
358
- config = { ...config, ...fileConfigWithoutGitHub };
359
- }
360
-
361
- // CLI arguments override everything (excluding GitHub config)
362
- const { showGitHubStats, githubOwner, githubRepo, ...cliConfigWithoutGitHub } = cliConfig;
363
- config = { ...config, ...cliConfigWithoutGitHub };
364
-
365
- return config;
366
- }
396
+ // ---- HTML injection ----
367
397
 
368
- /**
369
- * Inject configuration into HTML
370
- * The config script MUST be placed in <head> BEFORE the main JS module loads,
371
- * otherwise window.ORBIT_CHAT_CONFIG won't be available when the app initializes.
372
- */
373
398
  function injectConfig(html, config) {
374
399
  const configScript = `<script>window.ORBIT_CHAT_CONFIG = ${JSON.stringify(config)};</script>`;
375
400
 
376
- // Remove the placeholder script tag (it's in body, too late)
401
+ // Remove the placeholder script tag
377
402
  html = html.replace(
378
403
  /<script id="orbit-chat-config" type="application\/json">[\s\S]*?<\/script>/,
379
404
  '<!-- Config injected in head -->'
@@ -385,34 +410,87 @@ function injectConfig(html, config) {
385
410
  /<title>.*?<\/title>/i,
386
411
  `<title>${config.applicationName}</title>`
387
412
  );
388
- // Also update apple-mobile-web-app-title meta tag
389
413
  html = html.replace(
390
414
  /<meta name="apple-mobile-web-app-title" content="[^"]*" \/>/i,
391
415
  `<meta name="apple-mobile-web-app-title" content="${config.applicationName}" />`
392
416
  );
393
417
  }
394
418
 
395
- // Inject the config script at the START of <head>, before any other scripts
396
- // This ensures window.ORBIT_CHAT_CONFIG is available when the main JS bundle loads
419
+ // Inject the config script at the START of <head>
397
420
  return html.replace(
398
421
  /<head>/i,
399
422
  '<head>\n ' + configScript
400
423
  );
401
424
  }
402
425
 
403
- /**
404
- * Create Express server
405
- *
406
- * @param {string|null} distPath - Path to built UI assets (null in api-only mode)
407
- * @param {object} config - Merged application config
408
- * @param {object} serverConfig - Server config (port, host, apiOnly, etc.)
409
- */
426
+ // ---- Rate limiting ----
427
+
428
+ function createRateLimiters(rateLimitConfig) {
429
+ if (!rateLimitConfig || rateLimitConfig.enabled === false) return null;
430
+
431
+ const windowMs = rateLimitConfig.windowMs || 60000;
432
+ const maxRequests = rateLimitConfig.maxRequests || 30;
433
+ const chatWindowMs = rateLimitConfig.chat?.windowMs || 60000;
434
+ const chatMaxRequests = rateLimitConfig.chat?.maxRequests || 10;
435
+
436
+ const keyGenerator = (req) =>
437
+ req.ip || req.headers['x-forwarded-for'] || 'unknown';
438
+
439
+ const api = rateLimit({
440
+ windowMs,
441
+ max: maxRequests,
442
+ keyGenerator,
443
+ standardHeaders: 'draft-7',
444
+ legacyHeaders: false,
445
+ validate: { default: true, keyGeneratorIpFallback: false },
446
+ skip: (req) => req.method === 'OPTIONS' || req.path === '/adapters',
447
+ handler: (req, res) => {
448
+ const retryAfterMs = res.getHeader('RateLimit-Reset')
449
+ ? Number(res.getHeader('RateLimit-Reset')) * 1000
450
+ : windowMs;
451
+ res.status(429).json({
452
+ error: 'Too many requests',
453
+ message: `Rate limit exceeded. Try again in ${Math.ceil(retryAfterMs / 1000)} seconds.`,
454
+ retryAfterMs,
455
+ });
456
+ },
457
+ });
458
+
459
+ const chat = rateLimit({
460
+ windowMs: chatWindowMs,
461
+ max: chatMaxRequests,
462
+ keyGenerator,
463
+ standardHeaders: 'draft-7',
464
+ legacyHeaders: false,
465
+ validate: { default: true, keyGeneratorIpFallback: false },
466
+ handler: (req, res) => {
467
+ const retryAfterMs = res.getHeader('RateLimit-Reset')
468
+ ? Number(res.getHeader('RateLimit-Reset')) * 1000
469
+ : chatWindowMs;
470
+ res.status(429).json({
471
+ error: 'Too many requests',
472
+ message: `Chat rate limit exceeded. Try again in ${Math.ceil(retryAfterMs / 1000)} seconds.`,
473
+ retryAfterMs,
474
+ });
475
+ },
476
+ });
477
+
478
+ return { api, chat };
479
+ }
480
+
481
+ // ---- Express server ----
482
+
410
483
  function createServer(distPath, config, serverConfig = {}) {
411
484
  const app = express();
412
485
  const adapters = loadAdaptersConfig();
413
486
  const apiOnly = serverConfig.apiOnly || false;
487
+ const localAssets = serverConfig.localAssets || {};
488
+ const yamlAdapterMetadata = new Map(
489
+ Array.isArray(config.adapters)
490
+ ? config.adapters.filter(a => a && a.name).map(a => [a.name, a])
491
+ : []
492
+ );
414
493
 
415
- // In api-only mode, add CORS middleware so external UIs on other origins can call the API
416
494
  if (apiOnly) {
417
495
  const allowedOrigin = serverConfig.corsOrigin || '*';
418
496
  app.use((req, res, next) => {
@@ -427,10 +505,57 @@ function createServer(distPath, config, serverConfig = {}) {
427
505
  });
428
506
  }
429
507
 
508
+ if (Object.keys(localAssets).length > 0) {
509
+ app.get('/__orbitchat_assets/:assetId', (req, res) => {
510
+ const assetPath = localAssets[req.params.assetId];
511
+ if (!assetPath) {
512
+ return res.status(404).send('Asset not found');
513
+ }
514
+ if (!fs.existsSync(assetPath)) {
515
+ return res.status(404).send('Asset not found');
516
+ }
517
+ res.setHeader('Cache-Control', 'public, max-age=300');
518
+ res.sendFile(assetPath, (error) => {
519
+ if (error && !res.headersSent) {
520
+ res.status(500).send('Failed to serve asset');
521
+ }
522
+ });
523
+ });
524
+ }
525
+
526
+ // Guest rate limiting — after CORS, before proxy. Skips authenticated requests.
527
+ const limiters = createRateLimiters(serverConfig.rateLimit);
528
+ if (limiters) {
529
+ app.use('/api', (req, res, next) => {
530
+ if (req.headers.authorization) return next();
531
+ limiters.api(req, res, next);
532
+ });
533
+ app.use('/api', (req, res, next) => {
534
+ if (req.headers.authorization) return next();
535
+ if (req.method === 'POST' && (/\/chat/i.test(req.path) || /\/stream/i.test(req.path))) {
536
+ return limiters.chat(req, res, next);
537
+ }
538
+ next();
539
+ });
540
+ }
541
+
430
542
  // API proxy endpoints - MUST be before body parsers
431
543
  if (adapters) {
432
- // Pre-create proxy instances for each adapter to avoid memory leaks
433
- // Creating proxies on every request adds event listeners that accumulate
544
+ // Merge adapter metadata from YAML so UI labels/notes are consistent with dev mode.
545
+ for (const [adapterName, adapter] of Object.entries(adapters)) {
546
+ const metadata = yamlAdapterMetadata.get(adapterName);
547
+ if (!metadata) continue;
548
+ if (!adapter.description && metadata.description) {
549
+ adapter.description = metadata.description;
550
+ }
551
+ if (!adapter.notes && metadata.notes) {
552
+ adapter.notes = metadata.notes;
553
+ }
554
+ if (!adapter.apiUrl && metadata.apiUrl) {
555
+ adapter.apiUrl = metadata.apiUrl;
556
+ }
557
+ }
558
+
434
559
  const proxyInstances = {};
435
560
  for (const [adapterName, adapter] of Object.entries(adapters)) {
436
561
  if (!adapter.apiKey || !adapter.apiUrl) {
@@ -440,33 +565,26 @@ function createServer(distPath, config, serverConfig = {}) {
440
565
  proxyInstances[adapterName] = createProxyMiddleware({
441
566
  target: adapter.apiUrl,
442
567
  changeOrigin: true,
443
- // Restore /api prefix for backend paths that need it (files, threads)
444
568
  pathRewrite: (path) => {
445
569
  if (path.startsWith('/files') || path.startsWith('/threads')) {
446
570
  return '/api' + path;
447
571
  }
448
572
  return path;
449
573
  },
450
- // Set headers directly - this is more reliable than onProxyReq for some cases
451
574
  headers: {
452
575
  'X-API-Key': adapter.apiKey,
453
576
  },
454
- // Critical for SSE streaming - disable response buffering
455
577
  selfHandleResponse: false,
456
578
  onProxyReq: (proxyReq, req) => {
457
- // Remove adapter name header
458
579
  proxyReq.removeHeader('x-adapter-name');
459
- // Ensure API key is set (backup to headers option above)
460
580
  proxyReq.setHeader('X-API-Key', adapter.apiKey);
461
- // Preserve important headers
462
- const headersToPreserve = ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length'];
581
+ const headersToPreserve = ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length', 'authorization'];
463
582
  headersToPreserve.forEach(header => {
464
583
  const value = req.headers[header];
465
584
  if (value) {
466
585
  proxyReq.setHeader(header, value);
467
586
  }
468
587
  });
469
- // Copy all other headers
470
588
  Object.keys(req.headers).forEach(key => {
471
589
  const lowerKey = key.toLowerCase();
472
590
  if (!['x-adapter-name', 'host', 'connection', 'transfer-encoding'].includes(lowerKey)) {
@@ -478,18 +596,14 @@ function createServer(distPath, config, serverConfig = {}) {
478
596
  });
479
597
  },
480
598
  onProxyRes: (proxyRes, req, res) => {
481
- // Handle CORS if needed
482
599
  proxyRes.headers['access-control-allow-origin'] = '*';
483
600
  proxyRes.headers['access-control-allow-methods'] = 'GET, POST, PUT, DELETE, OPTIONS';
484
601
  proxyRes.headers['access-control-allow-headers'] = 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name';
485
602
 
486
- // Critical for SSE streaming - disable buffering
487
603
  const contentType = proxyRes.headers['content-type'] || '';
488
604
  if (contentType.includes('text/event-stream')) {
489
- // Disable caching and buffering for SSE
490
605
  proxyRes.headers['cache-control'] = 'no-cache';
491
606
  proxyRes.headers['x-accel-buffering'] = 'no';
492
- // Flush response immediately
493
607
  if (res.flushHeaders) {
494
608
  res.flushHeaders();
495
609
  }
@@ -501,25 +615,62 @@ function createServer(distPath, config, serverConfig = {}) {
501
615
  res.status(500).json({ error: 'Proxy error', message: err.message });
502
616
  }
503
617
  },
504
- ws: false, // Disable WebSocket proxying
505
- logLevel: 'silent', // Reduce logging
618
+ ws: false,
619
+ logLevel: 'silent',
620
+ });
621
+ }
622
+
623
+ // Fetch model info from ORBIT backend for each adapter (lazy, short-lived cache).
624
+ let modelsLastFetchedAt = 0;
625
+ let modelsFetchInFlight = null;
626
+ const MODELS_CACHE_TTL_MS = 30000;
627
+ async function fetchAdapterModels(force = false) {
628
+ const now = Date.now();
629
+ if (!force && (now - modelsLastFetchedAt) < MODELS_CACHE_TTL_MS) return;
630
+ if (modelsFetchInFlight) return modelsFetchInFlight;
631
+
632
+ modelsFetchInFlight = (async () => {
633
+ const fetches = Object.entries(adapters).map(async ([, adapter]) => {
634
+ if (!adapter.apiUrl || !adapter.apiKey) return;
635
+ try {
636
+ const url = `${adapter.apiUrl.replace(/\/+$/, '')}/admin/adapters/info`;
637
+ const resp = await fetch(url, {
638
+ headers: { 'X-API-Key': adapter.apiKey },
639
+ signal: AbortSignal.timeout(5000),
640
+ });
641
+ if (resp.ok) {
642
+ const info = await resp.json();
643
+ const model = typeof info?.model === 'string' ? info.model.trim() : '';
644
+ adapter.model = model || null;
645
+ }
646
+ } catch {
647
+ // Silently ignore — model will be omitted
648
+ }
649
+ });
650
+ await Promise.all(fetches);
651
+ modelsLastFetchedAt = Date.now();
652
+ })().finally(() => {
653
+ modelsFetchInFlight = null;
506
654
  });
655
+
656
+ return modelsFetchInFlight;
507
657
  }
508
658
 
509
- // Endpoint to list available adapters (only expose names, not URLs or keys)
510
- app.get('/api/adapters', (req, res) => {
659
+ app.get('/api/adapters', async (req, res) => {
660
+ const cacheControl = typeof req.headers['cache-control'] === 'string' ? req.headers['cache-control'] : '';
661
+ const forceRefresh = req.query?.refresh === '1' || cacheControl.includes('no-cache');
662
+ await fetchAdapterModels(forceRefresh);
511
663
  const adapterList = Object.keys(adapters).map(name => ({
512
664
  name,
513
665
  description: adapters[name]?.description,
514
666
  notes: adapters[name]?.notes,
667
+ model: adapters[name]?.model || null,
515
668
  }));
669
+ res.setHeader('Cache-Control', 'no-store');
516
670
  res.json({ adapters: adapterList });
517
671
  });
518
672
 
519
- // Proxy middleware for API requests - must be before body parsers to preserve request stream
520
- // Note: Uses /api path instead of /api/proxy for security (hides proxy nature)
521
673
  app.use('/api', (req, res, next) => {
522
- // Skip the /api/adapters route - it's handled separately above
523
674
  if (req.path === '/adapters') {
524
675
  return next('route');
525
676
  }
@@ -539,14 +690,10 @@ function createServer(distPath, config, serverConfig = {}) {
539
690
  });
540
691
  }
541
692
 
542
- // Middleware for parsing JSON - after proxy routes to preserve request body stream
543
693
  app.use(express.json());
544
694
  app.use(express.urlencoded({ extended: true }));
545
695
 
546
- // --- UI serving (skipped in api-only mode) ---
547
696
  if (!apiOnly && distPath) {
548
- // IMPORTANT: Handle index.html BEFORE express.static to inject runtime config
549
- // express.static would serve the file without config injection otherwise
550
697
  app.get(['/', '/index.html'], (req, res) => {
551
698
  try {
552
699
  const indexPath = path.join(distPath, 'index.html');
@@ -560,24 +707,15 @@ function createServer(distPath, config, serverConfig = {}) {
560
707
  }
561
708
  });
562
709
 
563
- // Serve static files (JS, CSS, images, etc.) - index.html is handled above
564
- app.use(express.static(distPath, {
565
- index: false, // Don't serve index.html automatically - we handle it above
566
- }));
710
+ app.use(express.static(distPath, { index: false }));
567
711
 
568
- // SPA fallback - serve index.html for all non-file routes (client-side routing)
569
712
  app.get('/{*splat}', (req, res, next) => {
570
- // Skip API routes
571
713
  if (req.path.startsWith('/api/')) {
572
714
  return next();
573
715
  }
574
-
575
- // Skip requests for files with extensions (let them 404)
576
716
  if (path.extname(req.path)) {
577
717
  return res.status(404).send('Not Found');
578
718
  }
579
-
580
- // Serve index.html for SPA routes
581
719
  try {
582
720
  const indexPath = path.join(distPath, 'index.html');
583
721
  let content = fs.readFileSync(indexPath, 'utf8');
@@ -594,173 +732,116 @@ function createServer(distPath, config, serverConfig = {}) {
594
732
  return app;
595
733
  }
596
734
 
597
- /**
598
- * Get MIME type for file extension
599
- */
600
- function getMimeType(filePath) {
601
- const ext = path.extname(filePath).toLowerCase();
602
- const mimeTypes = {
603
- '.html': 'text/html',
604
- '.js': 'application/javascript',
605
- '.mjs': 'application/javascript',
606
- '.json': 'application/json',
607
- '.css': 'text/css',
608
- '.png': 'image/png',
609
- '.jpg': 'image/jpeg',
610
- '.jpeg': 'image/jpeg',
611
- '.gif': 'image/gif',
612
- '.svg': 'image/svg+xml',
613
- '.ico': 'image/x-icon',
614
- '.woff': 'font/woff',
615
- '.woff2': 'font/woff2',
616
- '.ttf': 'font/ttf',
617
- '.eot': 'application/vnd.ms-fontobject',
618
- };
619
- return mimeTypes[ext] || 'application/octet-stream';
620
- }
735
+ // ---- Utilities ----
621
736
 
622
- /**
623
- * Open browser
624
- */
625
737
  function openBrowser(url) {
626
738
  const platform = process.platform;
627
739
  let command;
628
-
629
- if (platform === 'darwin') {
630
- command = `open "${url}"`;
631
- } else if (platform === 'linux') {
632
- command = `xdg-open "${url}"`;
633
- } else if (platform === 'win32') {
634
- command = `start "${url}"`;
635
- } else {
636
- return;
637
- }
740
+ if (platform === 'darwin') command = `open "${url}"`;
741
+ else if (platform === 'linux') command = `xdg-open "${url}"`;
742
+ else if (platform === 'win32') command = `start "${url}"`;
743
+ else return;
638
744
 
639
- try {
640
- execSync(command, { stdio: 'ignore' });
641
- } catch (error) {
642
- // Ignore errors
643
- }
745
+ try { execSync(command, { stdio: 'ignore' }); } catch { /* ignore */ }
644
746
  }
645
747
 
646
- /**
647
- * Get version from package.json
648
- */
649
748
  function getVersion() {
650
749
  try {
651
750
  const packagePath = path.join(__dirname, '..', 'package.json');
652
751
  const packageContent = fs.readFileSync(packagePath, 'utf8');
653
- const packageJson = JSON.parse(packageContent);
654
- return packageJson.version || 'unknown';
655
- } catch (error) {
656
- return 'unknown';
657
- }
752
+ return JSON.parse(packageContent).version || 'unknown';
753
+ } catch { return 'unknown'; }
658
754
  }
659
755
 
660
- /**
661
- * Print version
662
- */
663
- function printVersion() {
664
- console.log(getVersion());
665
- }
666
-
667
- /**
668
- * Print help message
669
- */
670
756
  function printHelp() {
671
757
  console.log(`
672
758
  ORBIT Chat CLI
673
759
 
674
760
  Usage: orbitchat [options]
675
761
 
762
+ All application settings are configured in orbitchat.yaml (see orbitchat.yaml.example).
763
+ Secrets (adapter API keys) go in VITE_ADAPTERS / ORBIT_ADAPTERS env var.
764
+
676
765
  Options:
677
- --api-url URL API URL (default: http://localhost:3000)
678
- --default-adapter NAME Default adapter to preselect (middleware mode)
679
- --api-key KEY Default API key (default: default-key)
680
- --application-name NAME Application name shown in browser tab (default: ORBIT Chat)
681
- --application-description TEXT Subtitle shown under the welcome heading
682
- --default-input-placeholder TEXT Message input placeholder text (default: Message ORBIT...)
683
- --use-local-api Use local API build (default: false)
684
- --local-api-path PATH Path to local API
685
- --console-debug Enable console debug (default: false)
686
- --enable-upload Enable upload button (default: false)
687
- --enable-audio Enable audio output button (default: false)
688
- --enable-audio-input Enable microphone input button (default: false)
689
- --enable-feedback Enable feedback buttons (default: false)
690
- --enable-autocomplete Enable autocomplete suggestions (default: false)
691
- --voice-silence-timeout-ms N Auto-stop voice capture after N ms of silence (default: 4000)
692
- --voice-recognition-lang LANG BCP-47 language code for speech recognition (default: browser locale)
693
- --enable-api-middleware Ignored (middleware mode is always enabled)
694
- --out-of-service-message TEXT Show maintenance screen blocking access
695
- --max-files-per-conversation N Max files per conversation (default: 5)
696
- --max-file-size-mb N Max file size in MB (default: 50)
697
- --max-total-files N Max total files (default: 100, 0 = unlimited)
698
- --max-conversations N Max conversations (default: 10, 0 = unlimited)
699
- --max-messages-per-conversation N Max messages per conversation (default: 1000, 0 = unlimited)
700
- --max-messages-per-thread N Max messages per thread (default: 1000, 0 = unlimited)
701
- --max-total-messages N Max total messages (default: 10000, 0 = unlimited)
702
- --max-message-length N Max message length (default: 1000)
703
- --settings-about-msg TEXT About message in settings page (default: ORBIT Chat)
704
- --port PORT Server port (default: 5173)
705
- --host HOST Server host (default: localhost)
706
- --open Open browser automatically
707
- --api-only Run API proxy only (no UI serving, no build required)
708
- --cors-origin ORIGIN Allowed CORS origin in api-only mode (default: *)
709
- --config PATH Path to config file (default: ~/.orbit-chat-app/config.json)
710
- --help, -h Show this help message
711
- --version, -v Show version number
712
-
713
- Configuration Priority:
714
- 1. CLI arguments
715
- 2. Config file (~/.orbit-chat-app/config.json)
716
- 3. Environment variables (VITE_*)
717
- 4. Default values
718
-
719
- Environment Variables for Middleware Mode:
720
- ORBIT_ADAPTERS or VITE_ADAPTERS JSON array of adapter configurations
721
- Example: '[{"name":"Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
766
+ --port PORT Server port (default: 5173)
767
+ --host HOST Server host (default: localhost)
768
+ --open Open browser automatically
769
+ --config PATH Path to orbitchat.yaml (default: ./orbitchat.yaml)
770
+ --api-only Run API proxy only (no UI serving)
771
+ --cors-origin URL Allowed CORS origin in api-only mode (default: *)
772
+ --help, -h Show this help message
773
+ --version, -v Show version number
774
+
775
+ Environment Variables:
776
+ ORBIT_ADAPTERS or VITE_ADAPTERS JSON array of adapter configurations (secrets)
777
+ Example: '[{"name":"Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
722
778
 
723
779
  Examples:
724
- orbitchat --api-url http://localhost:3000 --port 8080
725
- orbitchat --api-key my-key --open
726
- orbitchat --enable-audio --enable-audio-input --enable-upload --console-debug
727
- orbitchat --config /path/to/config.json
728
- ORBIT_ADAPTERS='[{"name":"Chat","apiKey":"mykey","apiUrl":"https://api.example.com"}]' orbitchat
729
- orbitchat --api-only --port 5174
780
+ orbitchat --port 8080
781
+ orbitchat --config /path/to/orbitchat.yaml --open
730
782
  orbitchat --api-only --cors-origin http://localhost:3001
731
783
  `);
732
-
733
784
  }
734
785
 
735
- /**
736
- * Main function
737
- */
786
+ // ---- Main ----
787
+
738
788
  function main() {
739
- // Check for version flag first
740
789
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
741
- printVersion();
790
+ console.log(getVersion());
742
791
  return;
743
792
  }
744
-
745
- // Check for help flag
746
793
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
747
794
  printHelp();
748
795
  return;
749
796
  }
750
797
 
751
- const { config: cliConfig, serverConfig } = parseArgs();
752
- const config = mergeConfig(cliConfig, serverConfig);
798
+ const serverConfig = parseArgs();
799
+ loadDotEnv(process.cwd());
800
+
801
+ // Load YAML config
802
+ const yamlPath = serverConfig.configFile || path.join(process.cwd(), 'orbitchat.yaml');
803
+ const yamlObj = loadYamlConfig(yamlPath);
804
+ const yamlFlat = yamlObj ? flattenYamlConfig(yamlObj) : {};
753
805
 
806
+ if (yamlObj) {
807
+ console.debug(`Loaded config from ${yamlPath}`);
808
+ }
809
+
810
+ // Merge: DEFAULTS < YAML config < auth secrets from env
811
+ const config = { ...DEFAULTS, ...yamlFlat };
812
+ const localAssets = {};
813
+
814
+ // Support local filesystem paths for header logo URL.
815
+ const resolvedHeaderLogoPath = resolveLocalAssetPath(config.headerLogoUrl, yamlPath);
816
+ if (resolvedHeaderLogoPath) {
817
+ const mtimeMs = fs.statSync(resolvedHeaderLogoPath).mtimeMs || Date.now();
818
+ localAssets.header_logo = resolvedHeaderLogoPath;
819
+ config.headerLogoUrl = `/__orbitchat_assets/header_logo?v=${Math.floor(mtimeMs)}`;
820
+ }
821
+
822
+ // Auth secrets from env
823
+ if (process.env.VITE_AUTH_DOMAIN) config.authDomain = process.env.VITE_AUTH_DOMAIN;
824
+ if (process.env.VITE_AUTH_CLIENT_ID) config.authClientId = process.env.VITE_AUTH_CLIENT_ID;
825
+ if (process.env.VITE_AUTH_AUDIENCE) config.authAudience = process.env.VITE_AUTH_AUDIENCE;
826
+
827
+ // Default adapter fallback
754
828
  const trimmedDefaultKey = (config.defaultKey || '').trim();
755
- if (!trimmedDefaultKey || trimmedDefaultKey === DEFAULT_CONFIG.defaultKey) {
829
+ if (!trimmedDefaultKey || trimmedDefaultKey === DEFAULTS.defaultKey) {
756
830
  const fallbackAdapter = getDefaultAdapterFromEnv();
757
831
  if (fallbackAdapter) {
758
832
  config.defaultKey = fallbackAdapter;
759
- console.debug(`ℹ️ Using '${fallbackAdapter}' as the default adapter (first entry from VITE_ADAPTERS).`);
760
833
  }
761
834
  }
762
835
 
763
- // Find dist directory (not required in api-only mode)
836
+ // Guest rate limiting (server-only, never sent to browser)
837
+ if (yamlObj && yamlObj.guestLimits?.rateLimit) {
838
+ serverConfig.rateLimit = yamlObj.guestLimits.rateLimit;
839
+ }
840
+ if (Object.keys(localAssets).length > 0) {
841
+ serverConfig.localAssets = localAssets;
842
+ }
843
+
844
+ // Find dist directory
764
845
  const distPath = path.join(__dirname, '..', 'dist');
765
846
 
766
847
  if (!serverConfig.apiOnly && !fs.existsSync(distPath)) {
@@ -768,7 +849,6 @@ function main() {
768
849
  process.exit(1);
769
850
  }
770
851
 
771
- // Create and start server
772
852
  const app = createServer(
773
853
  serverConfig.apiOnly ? null : distPath,
774
854
  config,
@@ -785,9 +865,18 @@ function main() {
785
865
  console.debug('Configuration:');
786
866
  console.debug(` Mode: ${serverConfig.apiOnly ? 'API-only (no UI)' : 'Full (API + UI)'}`);
787
867
  console.debug(` API URL: ${config.apiUrl}`);
788
- console.debug(` Default Key/Adapter: ${config.defaultKey || '(not set)'}`);
868
+ console.debug(` Default Adapter: ${config.defaultKey || '(not set)'}`);
789
869
  console.debug(` Port: ${serverConfig.port}`);
790
870
  console.debug(` Host: ${serverConfig.host}`);
871
+ if (yamlObj) {
872
+ console.debug(` Config: ${yamlPath}`);
873
+ }
874
+ if (resolvedHeaderLogoPath) {
875
+ console.debug(` Header logo file: ${resolvedHeaderLogoPath}`);
876
+ }
877
+ if (serverConfig.rateLimit && serverConfig.rateLimit.enabled !== false) {
878
+ console.debug(` Guest Rate Limiting: enabled (${serverConfig.rateLimit.maxRequests || 30} req/${(serverConfig.rateLimit.windowMs || 60000) / 1000}s, chat: ${serverConfig.rateLimit.chat?.maxRequests || 10} req/${(serverConfig.rateLimit.chat?.windowMs || 60000) / 1000}s)`);
879
+ }
791
880
  const startupAdapters = loadAdaptersConfig();
792
881
  if (startupAdapters) {
793
882
  console.debug(` Available Adapters: ${Object.keys(startupAdapters).join(', ')}`);
@@ -795,21 +884,19 @@ function main() {
795
884
  console.debug(` Warning: No adapters configured. Set ORBIT_ADAPTERS or VITE_ADAPTERS environment variable.`);
796
885
  }
797
886
  console.debug('');
798
-
887
+
799
888
  if (serverConfig.open) {
800
889
  openBrowser(url);
801
890
  }
802
891
  });
803
892
 
804
- // Handle graceful shutdown
805
893
  process.on('SIGINT', () => {
806
894
  console.debug('\n\nShutting down server...');
807
895
  process.exit(0);
808
896
  });
809
897
  }
810
898
 
811
- // Run if called directly (ES module equivalent of require.main === module)
812
- // For ES modules, we check if this file is being executed directly
899
+ // Run if called directly
813
900
  const isMainModule = process.argv[1] && (
814
901
  import.meta.url === `file://${process.argv[1]}` ||
815
902
  import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) ||
@@ -821,4 +908,4 @@ if (isMainModule) {
821
908
  main();
822
909
  }
823
910
 
824
- export { main, parseArgs, mergeConfig, createServer, loadAdaptersConfig };
911
+ export { main, createServer, loadAdaptersConfig };