orbitchat 3.1.1 → 3.1.3

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 (60) hide show
  1. package/README.md +79 -245
  2. package/bin/orbitchat.js +284 -729
  3. package/dist/assets/{ChartRenderer-CapBCpbV.js → ChartRenderer-Rhc3nvY3.js} +1 -1
  4. package/dist/assets/{MermaidRenderer-DQJRirnm.js → MermaidRenderer-BzYGDbGL.js} +5 -5
  5. package/dist/assets/{MusicRenderer-9vnA54gg.js → MusicRenderer-NnEIYAVw.js} +2 -2
  6. package/dist/assets/{SVGRenderer-By7CKtqU.js → SVGRenderer-_sr2u3jg.js} +1 -1
  7. package/dist/assets/{_basePickBy-Dm-zx8im.js → _basePickBy-gYhf5rnT.js} +1 -1
  8. package/dist/assets/{_baseUniq-DLPvoy-d.js → _baseUniq-Cbl9K6Wy.js} +1 -1
  9. package/dist/assets/{architectureDiagram-VXUJARFQ-B1kMlTMW.js → architectureDiagram-VXUJARFQ-PClktC-4.js} +1 -1
  10. package/dist/assets/{blockDiagram-VD42YOAC-BlglDb-U.js → blockDiagram-VD42YOAC-C0SW8BqM.js} +1 -1
  11. package/dist/assets/{c4Diagram-YG6GDRKO-CaZm--Yy.js → c4Diagram-YG6GDRKO-DSxXGReX.js} +1 -1
  12. package/dist/assets/channel-BqlT4tcZ.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-4Kq-2AyN.js → chunk-4BX2VUAB-BGhKp2Fa.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-FApueVFk.js → chunk-55IACEB6-lipYmTAW.js} +1 -1
  15. package/dist/assets/{chunk-B4BG7PRW-CHox7be9.js → chunk-B4BG7PRW-BbomVOnh.js} +1 -1
  16. package/dist/assets/{chunk-DI55MBZ5-N5sePLFo.js → chunk-DI55MBZ5-Dc5SGEEJ.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-BSbBs0xy.js → chunk-FMBD7UC4-8br3AiC5.js} +1 -1
  18. package/dist/assets/{chunk-QN33PNHL-ByurI9-W.js → chunk-QN33PNHL-DVUU2Ki_.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-Yy8VVut9.js → chunk-QZHKN3VN-DeCce7KS.js} +1 -1
  20. package/dist/assets/{chunk-TZMSLE5B-B5oe8pT4.js → chunk-TZMSLE5B-DGJHN-_V.js} +1 -1
  21. package/dist/assets/classDiagram-2ON5EDUG-D4Qktu8o.js +1 -0
  22. package/dist/assets/classDiagram-v2-WZHVMYZB-D4Qktu8o.js +1 -0
  23. package/dist/assets/clone-Dr4xHo_5.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-DS_-k6qR.js → cose-bilkent-S5V4N54A-B2ykdhqW.js} +1 -1
  25. package/dist/assets/{dagre-6UL2VRFP-DgkwJjVY.js → dagre-6UL2VRFP-UORxvWZD.js} +1 -1
  26. package/dist/assets/{diagram-PSM6KHXK-C9su64f9.js → diagram-PSM6KHXK-BQFC4vRN.js} +1 -1
  27. package/dist/assets/{diagram-QEK2KX5R-DY6-9Dbc.js → diagram-QEK2KX5R-CCSOfl0X.js} +1 -1
  28. package/dist/assets/{diagram-S2PKOQOG-_Wub-Eik.js → diagram-S2PKOQOG-D3PUtgH9.js} +1 -1
  29. package/dist/assets/{erDiagram-Q2GNP2WA-Gwx9nSAM.js → erDiagram-Q2GNP2WA-Bq9ZcVy_.js} +1 -1
  30. package/dist/assets/{flowDiagram-NV44I4VS-DmrXTF_S.js → flowDiagram-NV44I4VS-BBgWHY4c.js} +1 -1
  31. package/dist/assets/{ganttDiagram-JELNMOA3-DymVUgsd.js → ganttDiagram-JELNMOA3-CkPkSvB6.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-V2S2FVAM-D3VSQFT4.js → gitGraphDiagram-V2S2FVAM-hYLGJpbm.js} +1 -1
  33. package/dist/assets/{graph-BIe-N9yO.js → graph-DJgI4-QL.js} +1 -1
  34. package/dist/assets/{index-B-NnBj1Y.js → index-CkCAzH0b.js} +116 -116
  35. package/dist/assets/{index-BiM6VNSE.js → index-CtSNEVGd.js} +37 -37
  36. package/dist/assets/index-w5RjGGjS.css +1 -0
  37. package/dist/assets/{infoDiagram-HS3SLOUP-D2DeUrgk.js → infoDiagram-HS3SLOUP-DqPal58O.js} +1 -1
  38. package/dist/assets/{journeyDiagram-XKPGCS4Q-GxFhv1XB.js → journeyDiagram-XKPGCS4Q-ckP7tQ6L.js} +1 -1
  39. package/dist/assets/{kanban-definition-3W4ZIXB7-BhT-ga9I.js → kanban-definition-3W4ZIXB7-D5coRbbB.js} +1 -1
  40. package/dist/assets/{layout-38O87Ivt.js → layout-Dbu3AECQ.js} +1 -1
  41. package/dist/assets/{mindmap-definition-VGOIOE7T-DfKjmcaV.js → mindmap-definition-VGOIOE7T-Rh2iyVPx.js} +1 -1
  42. package/dist/assets/{pieDiagram-ADFJNKIX-D7lDWRGN.js → pieDiagram-ADFJNKIX-jhHLFKwA.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-AYHSOK5B-CPtZ8LgW.js → quadrantDiagram-AYHSOK5B-_KQn5Onj.js} +1 -1
  44. package/dist/assets/{requirementDiagram-UZGBJVZJ-h-H7R8sR.js → requirementDiagram-UZGBJVZJ-BGcyxuJP.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-TZEHDZUN-C6cn5g4m.js → sankeyDiagram-TZEHDZUN-HpFhSy4a.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-WL72ISMW-B6AwcQ4F.js → sequenceDiagram-WL72ISMW-CRcnJnhH.js} +1 -1
  47. package/dist/assets/{stateDiagram-FKZM4ZOC-B4ne7z3I.js → stateDiagram-FKZM4ZOC-CzYGK2Iy.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-4FDKWEC3-C-DbD7yi.js +1 -0
  49. package/dist/assets/{timeline-definition-IT6M3QCI-BmjJnJ-r.js → timeline-definition-IT6M3QCI-DtJxDVMX.js} +1 -1
  50. package/dist/assets/{treemap-GDKQZRPO-CBUbyJIX.js → treemap-GDKQZRPO-DC-6lh4E.js} +1 -1
  51. package/dist/assets/{xychartDiagram-PRI3JC2R-C3lVgboM.js → xychartDiagram-PRI3JC2R-DJ3uOpiG.js} +1 -1
  52. package/dist/index.html +2 -2
  53. package/orbitchat.sh +103 -35
  54. package/package.json +1 -1
  55. package/dist/assets/channel-BjUDQMqf.js +0 -1
  56. package/dist/assets/classDiagram-2ON5EDUG-BhHCvLfM.js +0 -1
  57. package/dist/assets/classDiagram-v2-WZHVMYZB-BhHCvLfM.js +0 -1
  58. package/dist/assets/clone-oQOn4Sap.js +0 -1
  59. package/dist/assets/index-BA5Mw76S.css +0 -1
  60. package/dist/assets/stateDiagram-v2-4FDKWEC3-C_qp8U90.js +0 -1
package/bin/orbitchat.js CHANGED
@@ -3,11 +3,8 @@
3
3
  * ORBIT Chat CLI
4
4
  *
5
5
  * Serves the chat-app as a standalone application with runtime configuration.
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
- *
9
- * The server acts as a proxy to hide API keys from the client by mapping
10
- * adapter names to actual API keys.
6
+ * Configuration is read from orbitchat.yaml.
7
+ * Secrets come from VITE_ADAPTER_KEYS / ORBIT_ADAPTER_KEYS env var.
11
8
  */
12
9
 
13
10
  import express from 'express';
@@ -23,19 +20,38 @@ import rateLimit from 'express-rate-limit';
23
20
  const __filename = fileURLToPath(import.meta.url);
24
21
  const __dirname = dirname(__filename);
25
22
 
26
- // ---- Minimal .env loader (CLI mode) ----
23
+ function getCliVersion() {
24
+ try {
25
+ const pkgPath = path.join(__dirname, '..', 'package.json');
26
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
27
+ return pkg.version || 'unknown';
28
+ } catch {
29
+ return 'unknown';
30
+ }
31
+ }
32
+
33
+ function printHelp() {
34
+ console.log(`orbitchat [options]
35
+
36
+ Options:
37
+ --port PORT Server port (default: 5173)
38
+ --host HOST Server host (default: localhost)
39
+ --open Open browser automatically
40
+ --config PATH Path to orbitchat.yaml (default: ./orbitchat.yaml)
41
+ --api-only Run API proxy only (no UI serving)
42
+ --cors-origin URL Allowed CORS origin in api-only mode (default: *)
43
+ --help, -h Show help message
44
+ --version, -v Show version number`);
45
+ }
46
+
47
+ // ---- Minimal .env loader ----
27
48
 
28
49
  function parseDotEnvValue(raw) {
29
50
  const trimmed = raw.trim();
30
51
  if (!trimmed) return '';
31
-
32
- if (
33
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
34
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
35
- ) {
52
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
36
53
  return trimmed.slice(1, -1);
37
54
  }
38
-
39
55
  return trimmed;
40
56
  }
41
57
 
@@ -43,217 +59,106 @@ function loadDotEnvFromFile(filePath) {
43
59
  if (!fs.existsSync(filePath)) return;
44
60
  const content = fs.readFileSync(filePath, 'utf8');
45
61
  const lines = content.split(/\r?\n/);
46
-
47
62
  for (let i = 0; i < lines.length; i++) {
48
63
  const trimmed = lines[i].trim();
49
64
  if (!trimmed || trimmed.startsWith('#')) continue;
50
-
51
65
  const equalsIndex = trimmed.indexOf('=');
52
66
  if (equalsIndex <= 0) continue;
53
-
54
67
  const key = trimmed.slice(0, equalsIndex).trim();
55
- if (!key) continue;
56
-
57
68
  if (process.env[key] !== undefined) continue;
58
-
59
69
  let valueRaw = trimmed.slice(equalsIndex + 1);
60
70
  const startsWithDouble = valueRaw.startsWith('"');
61
71
  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
- ) {
72
+ if ((startsWithDouble && !valueRaw.endsWith('"')) || (startsWithSingle && !valueRaw.endsWith("'"))) {
68
73
  const quote = startsWithDouble ? '"' : "'";
69
74
  while (i + 1 < lines.length) {
70
75
  i += 1;
71
76
  valueRaw += `\n${lines[i]}`;
72
- if (lines[i].trim().endsWith(quote)) {
73
- break;
74
- }
77
+ if (lines[i].trim().endsWith(quote)) break;
75
78
  }
76
79
  }
77
-
78
- const value = parseDotEnvValue(valueRaw);
79
- process.env[key] = value;
80
+ process.env[key] = parseDotEnvValue(valueRaw);
80
81
  }
81
82
  }
82
83
 
83
84
  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
85
  loadDotEnvFromFile(path.join(baseDir, '.env.local'));
86
+ loadDotEnvFromFile(path.join(baseDir, '.env'));
87
+ }
88
+
89
+ // ---- Deep Merge ----
90
+
91
+ function isObject(item) {
92
+ return typeof item === 'object' && item !== null && !Array.isArray(item);
87
93
  }
88
94
 
89
- // ---- Defaults (must match DEFAULTS in src/utils/runtimeConfig.ts) ----
95
+ function deepMerge(target, source) {
96
+ if (!isObject(target) || !isObject(source)) return source;
97
+ const output = { ...target };
98
+ Object.keys(source).forEach(key => {
99
+ if (isObject(target[key]) && isObject(source[key])) {
100
+ output[key] = deepMerge(target[key], source[key]);
101
+ } else if (source[key] !== undefined) {
102
+ output[key] = source[key];
103
+ }
104
+ });
105
+ return output;
106
+ }
107
+
108
+ // ---- Defaults ----
90
109
 
91
110
  const DEFAULTS = {
92
- apiUrl: 'http://localhost:3000',
93
- defaultKey: 'default-key',
94
- applicationName: 'ORBIT Chat',
95
- applicationDescription: "Explore ideas with ORBIT's AI copilots, share context, and build together.",
96
- defaultInputPlaceholder: 'Message ORBIT...',
97
- consoleDebug: false,
98
- locale: 'en-US',
99
- enableUploadButton: false,
100
- enableAudioOutput: false,
101
- enableAudioInput: false,
102
- enableFeedbackButtons: false,
103
- enableConversationThreads: true,
104
- enableAutocomplete: false,
105
- voiceSilenceTimeoutMs: 4000,
106
- voiceRecognitionLanguage: '',
107
- showGitHubStats: true,
111
+ application: {
112
+ name: 'ORBIT Chat',
113
+ description: "Explore ideas with ORBIT's AI copilots, share context, and build together.",
114
+ inputPlaceholder: 'Message ORBIT...',
115
+ settingsAboutMsg: 'ORBIT Chat',
116
+ locale: 'en-US',
117
+ },
118
+ debug: {
119
+ consoleDebug: false,
120
+ },
121
+ features: {
122
+ enableUpload: false,
123
+ enableAudioOutput: false,
124
+ enableAudioInput: false,
125
+ enableFeedbackButtons: false,
126
+ enableConversationThreads: true,
127
+ enableAutocomplete: false,
128
+ },
129
+ voice: {
130
+ silenceTimeoutMs: 4000,
131
+ recognitionLanguage: '',
132
+ },
133
+ github: {
134
+ showStats: true,
135
+ owner: 'schmitech',
136
+ repo: 'orbit',
137
+ },
108
138
  outOfServiceMessage: null,
109
- githubOwner: 'schmitech',
110
- githubRepo: 'orbit',
111
- maxFilesPerConversation: 5,
112
- maxFileSizeMB: 50,
113
- maxTotalFiles: 100,
114
- maxConversations: 10,
115
- maxMessagesPerConversation: 1000,
116
- maxMessagesPerThread: 1000,
117
- maxTotalMessages: 10000,
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,
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: [],
139
+ limits: {
140
+ files: { perConversation: 5, maxSizeMB: 50, totalFiles: 100 },
141
+ conversations: { maxConversations: 10, messagesPerConversation: 1000, messagesPerThread: 1000, totalMessages: 10000 },
142
+ messages: { maxLength: 1000 },
143
+ },
144
+ guestLimits: {
145
+ files: { perConversation: 1, maxSizeMB: 10, totalFiles: 2 },
146
+ conversations: { maxConversations: 1, messagesPerConversation: 10, messagesPerThread: 10, totalMessages: 10 },
147
+ messages: { maxLength: 500 },
148
+ },
149
+ auth: { enabled: false, domain: '', clientId: '', audience: '' },
150
+ header: { enabled: false, logoUrl: '', logoUrlLight: '', logoUrlDark: '', brandName: '', bgColor: '', textColor: '', showBorder: true, navLinks: [] },
151
+ footer: { enabled: false, text: '', bgColor: '', textColor: '', showBorder: false, layout: 'stacked', align: 'center', topPadding: 'large', navLinks: [] },
152
+ adapters: [],
143
153
  };
144
154
 
145
155
  // ---- YAML config loading ----
146
156
 
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;
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
- }
248
-
249
157
  function loadYamlConfig(configPath) {
250
158
  try {
251
159
  if (fs.existsSync(configPath)) {
252
160
  const content = fs.readFileSync(configPath, 'utf8');
253
- const parsed = yaml.load(content);
254
- if (parsed && typeof parsed === 'object') {
255
- return parsed;
256
- }
161
+ return yaml.load(content);
257
162
  }
258
163
  } catch (error) {
259
164
  console.error(`Error: Failed to parse ${configPath}: ${error.message}`);
@@ -262,245 +167,100 @@ function loadYamlConfig(configPath) {
262
167
  return null;
263
168
  }
264
169
 
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
- }
170
+ // ---- Local asset handling ----
270
171
 
271
172
  function resolveLocalAssetPath(rawValue, yamlPath) {
272
173
  if (!rawValue || typeof rawValue !== 'string') return null;
273
174
  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
-
175
+ if (!value || /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value) || value.startsWith('//')) return null;
176
+ const expandedValue = value.startsWith('~/') ? path.join(process.env.HOME || '', value.slice(2)) : value;
280
177
  const yamlDir = path.dirname(yamlPath);
281
- const candidates = path.isAbsolute(expandedValue)
282
- ? [expandedValue]
283
- : [path.resolve(yamlDir, expandedValue), path.resolve(process.cwd(), expandedValue)];
284
-
178
+ const candidates = path.isAbsolute(expandedValue) ? [expandedValue] : [path.resolve(yamlDir, expandedValue), path.resolve(process.cwd(), expandedValue)];
285
179
  for (const candidate of candidates) {
286
180
  try {
287
- if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) {
288
- return fs.realpathSync(candidate);
289
- }
290
- } catch {
291
- // ignore invalid candidate and continue
292
- }
181
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return fs.realpathSync(candidate);
182
+ } catch { /* ignore */ }
293
183
  }
294
-
295
184
  return null;
296
185
  }
297
186
 
298
187
  // ---- Adapter loading (env secrets + YAML metadata) ----
299
188
 
300
- function parseAdaptersFromEnv() {
301
- const envValue = process.env.ORBIT_ADAPTERS || process.env.VITE_ADAPTERS;
302
- if (!envValue) return [];
303
-
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
- }));
317
- } catch (error) {
318
- console.warn('Warning: Could not parse ORBIT_ADAPTERS/VITE_ADAPTERS:', error.message);
319
- return [];
320
- }
321
- }
322
-
323
- function loadAdaptersConfig() {
324
- const adapterList = parseAdaptersFromEnv();
325
- if (adapterList.length === 0) return null;
326
-
189
+ function loadAdaptersForProxy(yamlAdapters) {
327
190
  const adapters = {};
328
- for (const adapter of adapterList) {
329
- adapters[adapter.name] = {
330
- apiKey: adapter.apiKey || DEFAULTS.defaultKey,
331
- apiUrl: adapter.apiUrl || DEFAULTS.apiUrl,
332
- description: adapter.description,
333
- notes: adapter.notes,
334
- };
191
+ const fallbackApiUrl = 'http://localhost:3000';
192
+
193
+ if (Array.isArray(yamlAdapters)) {
194
+ for (const ya of yamlAdapters) {
195
+ if (!ya.name) continue;
196
+ adapters[ya.name] = {
197
+ apiKey: '',
198
+ apiUrl: ya.apiUrl || fallbackApiUrl,
199
+ description: ya.description,
200
+ notes: ya.notes,
201
+ model: ya.model
202
+ };
203
+ }
335
204
  }
336
205
 
337
- return Object.keys(adapters).length > 0 ? adapters : null;
338
- }
339
-
340
- function getDefaultAdapterFromEnv() {
341
- const adapterList = parseAdaptersFromEnv();
342
- return adapterList.length > 0 ? adapterList[0].name : null;
343
- }
344
-
345
- // ---- CLI arg parsing (server flags only) ----
346
-
347
- function parseArgs() {
348
- const args = process.argv.slice(2);
349
- const serverConfig = {
350
- port: 5173,
351
- host: 'localhost',
352
- open: false,
353
- configFile: null,
354
- apiOnly: false,
355
- corsOrigin: undefined,
356
- };
357
-
358
- for (let i = 0; i < args.length; i++) {
359
- const arg = args[i];
360
- switch (arg) {
361
- case '--port':
362
- serverConfig.port = parseInt(args[++i], 10);
363
- break;
364
- case '--host':
365
- serverConfig.host = args[++i];
366
- break;
367
- case '--open':
368
- serverConfig.open = true;
369
- break;
370
- case '--config':
371
- serverConfig.configFile = args[++i];
372
- break;
373
- case '--api-only':
374
- serverConfig.apiOnly = true;
375
- break;
376
- case '--cors-origin':
377
- serverConfig.corsOrigin = args[++i];
378
- break;
379
- case '--help':
380
- case '-h':
381
- case '--version':
382
- case '-v':
383
- break;
384
- default:
385
- if (arg.startsWith('--')) {
386
- console.error(`Unknown option: ${arg}`);
387
- console.error('Use --help for usage information');
388
- process.exit(1);
206
+ const envKeysRaw = process.env.VITE_ADAPTER_KEYS || process.env.ORBIT_ADAPTER_KEYS;
207
+ if (envKeysRaw) {
208
+ try {
209
+ const keys = JSON.parse(envKeysRaw);
210
+ for (const [name, value] of Object.entries(keys)) {
211
+ const isObjectValue = typeof value === 'object' && value !== null;
212
+ const apiKey = isObjectValue
213
+ ? String(value.apiKey || value.key || '')
214
+ : String(value);
215
+ const apiUrl = isObjectValue && value.apiUrl ? String(value.apiUrl) : undefined;
216
+ const description = isObjectValue && value.description ? String(value.description) : undefined;
217
+ const notes = isObjectValue && value.notes ? String(value.notes) : undefined;
218
+ const model = isObjectValue && value.model ? String(value.model) : undefined;
219
+
220
+ if (!adapters[name]) {
221
+ adapters[name] = {
222
+ apiKey,
223
+ apiUrl: apiUrl || fallbackApiUrl,
224
+ description,
225
+ notes,
226
+ model
227
+ };
228
+ } else {
229
+ adapters[name].apiKey = apiKey;
230
+ if (apiUrl) adapters[name].apiUrl = apiUrl;
231
+ if (description !== undefined) adapters[name].description = description;
232
+ if (notes !== undefined) adapters[name].notes = notes;
233
+ if (model !== undefined) adapters[name].model = model;
389
234
  }
390
- }
235
+ }
236
+ } catch { /* ignore */ }
391
237
  }
392
238
 
393
- return serverConfig;
394
- }
395
-
396
- // ---- HTML injection ----
397
-
398
- function injectConfig(html, config) {
399
- const configScript = `<script>window.ORBIT_CHAT_CONFIG = ${JSON.stringify(config)};</script>`;
400
-
401
- // Remove the placeholder script tag
402
- html = html.replace(
403
- /<script id="orbit-chat-config" type="application\/json">[\s\S]*?<\/script>/,
404
- '<!-- Config injected in head -->'
405
- );
406
-
407
- // Replace the title tag with the configured application name
408
- if (config.applicationName) {
409
- html = html.replace(
410
- /<title>.*?<\/title>/i,
411
- `<title>${config.applicationName}</title>`
412
- );
413
- html = html.replace(
414
- /<meta name="apple-mobile-web-app-title" content="[^"]*" \/>/i,
415
- `<meta name="apple-mobile-web-app-title" content="${config.applicationName}" />`
416
- );
239
+ const finalAdapters = {};
240
+ for (const [name, config] of Object.entries(adapters)) {
241
+ if (config.apiKey) finalAdapters[name] = config;
417
242
  }
418
-
419
- // Inject the config script at the START of <head>
420
- return html.replace(
421
- /<head>/i,
422
- '<head>\n ' + configScript
423
- );
424
- }
425
-
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 };
243
+ if (Object.keys(finalAdapters).length > 0) {
244
+ console.debug(`Loaded ${Object.keys(finalAdapters).length} adapters with API keys from environment.`);
245
+ }
246
+ return Object.keys(finalAdapters).length > 0 ? finalAdapters : null;
479
247
  }
480
248
 
481
249
  // ---- Express server ----
482
250
 
483
251
  function createServer(distPath, config, serverConfig = {}) {
484
252
  const app = express();
485
- const adapters = loadAdaptersConfig();
253
+ const adapters = loadAdaptersForProxy(config.adapters);
486
254
  const apiOnly = serverConfig.apiOnly || false;
487
255
  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
- );
493
256
 
494
257
  if (apiOnly) {
495
258
  const allowedOrigin = serverConfig.corsOrigin || '*';
496
259
  app.use((req, res, next) => {
497
260
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
498
261
  res.setHeader('Access-Control-Allow-Methods', 'GET, POST, PUT, DELETE, OPTIONS');
499
- res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name, Accept');
500
- res.setHeader('Access-Control-Expose-Headers', 'Content-Type');
501
- if (req.method === 'OPTIONS') {
502
- return res.sendStatus(204);
503
- }
262
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name, Accept, Authorization');
263
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
504
264
  next();
505
265
  });
506
266
  }
@@ -508,144 +268,59 @@ function createServer(distPath, config, serverConfig = {}) {
508
268
  if (Object.keys(localAssets).length > 0) {
509
269
  app.get('/__orbitchat_assets/:assetId', (req, res) => {
510
270
  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
- }
271
+ if (!assetPath || !fs.existsSync(assetPath)) return res.status(404).send('Asset not found');
517
272
  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
- });
273
+ res.sendFile(assetPath);
523
274
  });
524
275
  }
525
276
 
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);
277
+ // Guest rate limiting
278
+ if (serverConfig.rateLimit?.enabled !== false) {
279
+ const rl = serverConfig.rateLimit || {};
280
+ const apiLimiter = rateLimit({
281
+ windowMs: rl.windowMs || 60000, max: rl.maxRequests || 30,
282
+ skip: (req) => req.method === 'OPTIONS' || req.path === '/adapters',
283
+ handler: (req, res) => res.status(429).json({ error: 'Too many requests' }),
284
+ });
285
+ const chatLimiter = rateLimit({
286
+ windowMs: rl.chat?.windowMs || 60000, max: rl.chat?.maxRequests || 10,
287
+ handler: (req, res) => res.status(429).json({ error: 'Chat rate limit exceeded' }),
532
288
  });
289
+ app.use('/api', (req, res, next) => { if (req.headers.authorization) return next(); apiLimiter(req, res, next); });
533
290
  app.use('/api', (req, res, next) => {
534
291
  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
- }
292
+ if (req.method === 'POST' && (/\/chat/i.test(req.path) || /\/stream/i.test(req.path))) return chatLimiter(req, res, next);
538
293
  next();
539
294
  });
540
295
  }
541
296
 
542
- // API proxy endpoints - MUST be before body parsers
543
297
  if (adapters) {
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
-
559
- const proxyInstances = {};
560
- for (const [adapterName, adapter] of Object.entries(adapters)) {
561
- if (!adapter.apiKey || !adapter.apiUrl) {
562
- console.warn(`[Proxy] Skipping adapter '${adapterName}': missing apiKey or apiUrl`);
563
- continue;
564
- }
565
- proxyInstances[adapterName] = createProxyMiddleware({
566
- target: adapter.apiUrl,
567
- changeOrigin: true,
568
- pathRewrite: (path) => {
569
- if (path.startsWith('/files') || path.startsWith('/threads')) {
570
- return '/api' + path;
571
- }
572
- return path;
573
- },
574
- headers: {
575
- 'X-API-Key': adapter.apiKey,
576
- },
577
- selfHandleResponse: false,
578
- onProxyReq: (proxyReq, req) => {
579
- proxyReq.removeHeader('x-adapter-name');
580
- proxyReq.setHeader('X-API-Key', adapter.apiKey);
581
- const headersToPreserve = ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length', 'authorization'];
582
- headersToPreserve.forEach(header => {
583
- const value = req.headers[header];
584
- if (value) {
585
- proxyReq.setHeader(header, value);
586
- }
587
- });
588
- Object.keys(req.headers).forEach(key => {
589
- const lowerKey = key.toLowerCase();
590
- if (!['x-adapter-name', 'host', 'connection', 'transfer-encoding'].includes(lowerKey)) {
591
- const value = req.headers[key];
592
- if (value && !headersToPreserve.includes(lowerKey)) {
593
- proxyReq.setHeader(key, value);
594
- }
595
- }
596
- });
597
- },
598
- onProxyRes: (proxyRes, req, res) => {
599
- proxyRes.headers['access-control-allow-origin'] = '*';
600
- proxyRes.headers['access-control-allow-methods'] = 'GET, POST, PUT, DELETE, OPTIONS';
601
- proxyRes.headers['access-control-allow-headers'] = 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name';
602
-
603
- const contentType = proxyRes.headers['content-type'] || '';
604
- if (contentType.includes('text/event-stream')) {
605
- proxyRes.headers['cache-control'] = 'no-cache';
606
- proxyRes.headers['x-accel-buffering'] = 'no';
607
- if (res.flushHeaders) {
608
- res.flushHeaders();
609
- }
610
- }
611
- },
612
- onError: (err, req, res) => {
613
- console.error('Proxy error:', err);
614
- if (!res.headersSent) {
615
- res.status(500).json({ error: 'Proxy error', message: err.message });
616
- }
617
- },
618
- ws: false,
619
- logLevel: 'silent',
620
- });
621
- }
622
-
623
- // Fetch model info from ORBIT backend for each adapter (lazy, short-lived cache).
298
+ // Lazy model hydration for adapter cards (mirrors vite dev behavior).
624
299
  let modelsLastFetchedAt = 0;
625
300
  let modelsFetchInFlight = null;
626
301
  const MODELS_CACHE_TTL_MS = 30000;
627
- async function fetchAdapterModels(force = false) {
302
+
303
+ async function fetchAdapterModels(adapterMap, force = false) {
628
304
  const now = Date.now();
629
305
  if (!force && (now - modelsLastFetchedAt) < MODELS_CACHE_TTL_MS) return;
630
306
  if (modelsFetchInFlight) return modelsFetchInFlight;
631
307
 
632
308
  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;
309
+ const fetches = Object.entries(adapterMap).map(async ([, adapter]) => {
310
+ if (!adapter.apiUrl || !adapter.apiKey) return;
311
+ try {
312
+ const url = `${String(adapter.apiUrl).replace(/\/+$/, '')}/admin/adapters/info`;
313
+ const resp = await fetch(url, {
314
+ headers: { 'X-API-Key': adapter.apiKey },
315
+ signal: AbortSignal.timeout(5000),
316
+ });
317
+ if (resp.ok) {
318
+ const info = await resp.json();
319
+ adapter.model = typeof info?.model === 'string' ? info.model.trim() || undefined : undefined;
320
+ }
321
+ } catch {
322
+ // Best-effort only; cards can render without model metadata.
645
323
  }
646
- } catch {
647
- // Silently ignore — model will be omitted
648
- }
649
324
  });
650
325
  await Promise.all(fetches);
651
326
  modelsLastFetchedAt = Date.now();
@@ -656,256 +331,136 @@ function createServer(distPath, config, serverConfig = {}) {
656
331
  return modelsFetchInFlight;
657
332
  }
658
333
 
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);
663
- const adapterList = Object.keys(adapters).map(name => ({
334
+ const buildAdapterList = (adapterMap) =>
335
+ Object.keys(adapterMap).map(name => ({
664
336
  name,
665
- description: adapters[name]?.description,
666
- notes: adapters[name]?.notes,
667
- model: adapters[name]?.model || null,
337
+ description: adapterMap[name].description,
338
+ notes: adapterMap[name].notes,
339
+ model: adapterMap[name].model || null
668
340
  }));
669
- res.setHeader('Cache-Control', 'no-store');
670
- res.json({ adapters: adapterList });
671
- });
672
341
 
673
- app.use('/api', (req, res, next) => {
674
- if (req.path === '/adapters') {
675
- return next('route');
676
- }
677
- const adapterName = req.headers['x-adapter-name'];
342
+ app.get('/api/adapters', (req, res) => {
343
+ const cacheControlHeader = typeof req.headers['cache-control'] === 'string' ? req.headers['cache-control'] : '';
344
+ const forceRefresh = req.url?.includes('refresh=1') || cacheControlHeader.includes('no-cache');
678
345
 
679
- if (!adapterName) {
680
- return res.status(400).json({ error: 'X-Adapter-Name header is required' });
681
- }
346
+ fetchAdapterModels(adapters, forceRefresh).then(() => {
347
+ res.setHeader('Cache-Control', 'no-store');
348
+ res.json({ adapters: buildAdapterList(adapters) });
349
+ }).catch(() => {
350
+ res.setHeader('Cache-Control', 'no-store');
351
+ res.json({ adapters: buildAdapterList(adapters) });
352
+ });
353
+ });
682
354
 
683
- const proxy = proxyInstances[adapterName];
684
- if (!proxy) {
685
- console.error(`[Proxy] Adapter '${adapterName}' not found. Available: ${Object.keys(proxyInstances).join(', ')}`);
686
- return res.status(404).json({ error: `Adapter '${adapterName}' not found` });
687
- }
355
+ const dynamicProxy = createProxyMiddleware({
356
+ target: 'http://localhost:3000', // Default fallback
357
+ router: (req) => {
358
+ const adapterName = req.headers['x-adapter-name'];
359
+ return adapters[adapterName]?.apiUrl;
360
+ },
361
+ changeOrigin: true,
362
+ pathRewrite: (p) => p.startsWith('/files') || p.startsWith('/threads') ? '/api' + p : p,
363
+ on: {
364
+ proxyReq: (proxyReq, reqIncoming) => {
365
+ const adapterName = reqIncoming.headers['x-adapter-name'];
366
+ const adapter = adapters[adapterName];
367
+ if (adapter) {
368
+ proxyReq.setHeader('X-API-Key', adapter.apiKey);
369
+ }
370
+ proxyReq.removeHeader('x-adapter-name');
371
+ ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length', 'authorization'].forEach(h => {
372
+ if (reqIncoming.headers[h]) proxyReq.setHeader(h, reqIncoming.headers[h]);
373
+ });
374
+ },
375
+ error: (err, _req, resProxy) => {
376
+ console.error('[Proxy] Proxy error:', err);
377
+ if (!resProxy.headersSent) {
378
+ resProxy.status(500).json({ error: 'Proxy error', message: err.message });
379
+ }
380
+ }
381
+ },
382
+ logLevel: 'silent',
383
+ });
688
384
 
689
- proxy(req, res, next);
385
+ app.use('/api', (req, res, next) => {
386
+ if (req.path === '/adapters') return next('route');
387
+ const adapterName = req.headers['x-adapter-name'];
388
+ if (!adapterName) return res.status(400).json({ error: 'X-Adapter-Name header is required' });
389
+ if (!adapters[adapterName]) return res.status(404).json({ error: `Adapter '${adapterName}' not found` });
390
+ dynamicProxy(req, res, next);
690
391
  });
691
392
  }
692
393
 
693
394
  app.use(express.json());
694
- app.use(express.urlencoded({ extended: true }));
695
-
696
395
  if (!apiOnly && distPath) {
697
- app.get(['/', '/index.html'], (req, res) => {
698
- try {
699
- const indexPath = path.join(distPath, 'index.html');
700
- let content = fs.readFileSync(indexPath, 'utf8');
701
- content = injectConfig(content, config);
702
- res.setHeader('Content-Type', 'text/html');
703
- res.send(content);
704
- } catch (error) {
705
- console.error('Error serving index.html:', error);
706
- res.status(500).send('Internal Server Error');
707
- }
708
- });
709
-
710
396
  app.use(express.static(distPath, { index: false }));
711
-
712
- app.get('/{*splat}', (req, res, next) => {
713
- if (req.path.startsWith('/api/')) {
714
- return next();
715
- }
716
- if (path.extname(req.path)) {
717
- return res.status(404).send('Not Found');
718
- }
719
- try {
720
- const indexPath = path.join(distPath, 'index.html');
721
- let content = fs.readFileSync(indexPath, 'utf8');
722
- content = injectConfig(content, config);
723
- res.setHeader('Content-Type', 'text/html');
724
- res.send(content);
725
- } catch (error) {
726
- console.error('Error serving index.html:', error);
727
- res.status(500).send('Internal Server Error');
728
- }
397
+ app.get(/(.*)/, (req, res) => {
398
+ if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
399
+ const indexPath = path.join(distPath, 'index.html');
400
+ let content = fs.readFileSync(indexPath, 'utf8');
401
+ content = content.replace(/<script id="orbit-chat-config" type="application\/json">[\s\S]*?<\/script>/, '<!-- Config injected -->');
402
+ const configScript = `<script>window.ORBIT_CHAT_CONFIG = ${JSON.stringify(config)};</script>`;
403
+ content = content.replace(/<head>/i, '<head>\n ' + configScript);
404
+ if (config.application?.name) content = content.replace(/<title>.*?<\/title>/i, `<title>${config.application.name}</title>`);
405
+ res.setHeader('Content-Type', 'text/html');
406
+ res.send(content);
729
407
  });
730
408
  }
731
-
732
409
  return app;
733
410
  }
734
411
 
735
- // ---- Utilities ----
736
-
737
- function openBrowser(url) {
738
- const platform = process.platform;
739
- let command;
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;
744
-
745
- try { execSync(command, { stdio: 'ignore' }); } catch { /* ignore */ }
746
- }
747
-
748
- function getVersion() {
749
- try {
750
- const packagePath = path.join(__dirname, '..', 'package.json');
751
- const packageContent = fs.readFileSync(packagePath, 'utf8');
752
- return JSON.parse(packageContent).version || 'unknown';
753
- } catch { return 'unknown'; }
754
- }
755
-
756
- function printHelp() {
757
- console.log(`
758
- ORBIT Chat CLI
759
-
760
- Usage: orbitchat [options]
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
-
765
- Options:
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"}]'
778
-
779
- Examples:
780
- orbitchat --port 8080
781
- orbitchat --config /path/to/orbitchat.yaml --open
782
- orbitchat --api-only --cors-origin http://localhost:3001
783
- `);
784
- }
785
-
786
412
  // ---- Main ----
787
413
 
788
414
  function main() {
789
- if (process.argv.includes('--version') || process.argv.includes('-v')) {
790
- console.log(getVersion());
791
- return;
792
- }
793
- if (process.argv.includes('--help') || process.argv.includes('-h')) {
794
- printHelp();
795
- return;
415
+ const args = process.argv.slice(2);
416
+ const serverConfig = { port: 5173, host: 'localhost', open: false, configFile: null, apiOnly: false, corsOrigin: '*' };
417
+ for (let i = 0; i < args.length; i++) {
418
+ if (args[i] === '--port') serverConfig.port = parseInt(args[++i], 10);
419
+ else if (args[i] === '--host') serverConfig.host = args[++i];
420
+ else if (args[i] === '--open') serverConfig.open = true;
421
+ else if (args[i] === '--config') serverConfig.configFile = args[++i];
422
+ else if (args[i] === '--api-only') serverConfig.apiOnly = true;
423
+ else if (args[i] === '--cors-origin') serverConfig.corsOrigin = args[++i];
424
+ else if (args[i] === '--help' || args[i] === '-h') { printHelp(); return; }
425
+ else if (args[i] === '--version' || args[i] === '-v') { console.log(getCliVersion()); return; }
796
426
  }
797
427
 
798
- const serverConfig = parseArgs();
799
428
  loadDotEnv(process.cwd());
800
-
801
- // Load YAML config
802
429
  const yamlPath = serverConfig.configFile || path.join(process.cwd(), 'orbitchat.yaml');
803
430
  const yamlObj = loadYamlConfig(yamlPath);
804
- const yamlFlat = yamlObj ? flattenYamlConfig(yamlObj) : {};
431
+ let config = deepMerge(DEFAULTS, yamlObj || {});
805
432
 
806
- if (yamlObj) {
807
- console.debug(`Loaded config from ${yamlPath}`);
808
- }
433
+ // Overlay secret env vars if they exist
434
+ if (process.env.VITE_AUTH_DOMAIN) config.auth.domain = process.env.VITE_AUTH_DOMAIN;
435
+ if (process.env.VITE_AUTH_CLIENT_ID) config.auth.clientId = process.env.VITE_AUTH_CLIENT_ID;
436
+ if (process.env.VITE_AUTH_AUDIENCE) config.auth.audience = process.env.VITE_AUTH_AUDIENCE;
809
437
 
810
- // Merge: DEFAULTS < YAML config < auth secrets from env
811
- const config = { ...DEFAULTS, ...yamlFlat };
812
438
  const localAssets = {};
439
+ const mapHeaderLogoAsset = (fieldName, assetId) => {
440
+ const resPath = resolveLocalAssetPath(config.header?.[fieldName], yamlPath);
441
+ if (!resPath) return;
442
+ localAssets[assetId] = resPath;
443
+ config.header[fieldName] = `/__orbitchat_assets/${assetId}?v=${Date.now()}`;
444
+ };
813
445
 
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
- }
446
+ mapHeaderLogoAsset('logoUrl', 'header_logo');
447
+ mapHeaderLogoAsset('logoUrlLight', 'header_logo_light');
448
+ mapHeaderLogoAsset('logoUrlDark', 'header_logo_dark');
821
449
 
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
828
- const trimmedDefaultKey = (config.defaultKey || '').trim();
829
- if (!trimmedDefaultKey || trimmedDefaultKey === DEFAULTS.defaultKey) {
830
- const fallbackAdapter = getDefaultAdapterFromEnv();
831
- if (fallbackAdapter) {
832
- config.defaultKey = fallbackAdapter;
833
- }
834
- }
835
-
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
845
450
  const distPath = path.join(__dirname, '..', 'dist');
451
+ const app = createServer(distPath, config, { ...serverConfig, rateLimit: yamlObj?.guestLimits?.rateLimit, localAssets });
846
452
 
847
- if (!serverConfig.apiOnly && !fs.existsSync(distPath)) {
848
- console.error('Error: dist directory not found. Please run "npm run build" first.');
849
- process.exit(1);
850
- }
851
-
852
- const app = createServer(
853
- serverConfig.apiOnly ? null : distPath,
854
- config,
855
- serverConfig
856
- );
857
-
858
- app.listen(serverConfig.port, serverConfig.host, () => {
859
- const url = `http://${serverConfig.host}:${serverConfig.port}`;
860
- if (serverConfig.apiOnly) {
861
- console.debug(`\n🚀 ORBIT API Proxy is running at ${url}\n`);
862
- } else {
863
- console.debug(`\n🚀 ORBIT Chat App is running at ${url}\n`);
864
- }
865
- console.debug('Configuration:');
866
- console.debug(` Mode: ${serverConfig.apiOnly ? 'API-only (no UI)' : 'Full (API + UI)'}`);
867
- console.debug(` API URL: ${config.apiUrl}`);
868
- console.debug(` Default Adapter: ${config.defaultKey || '(not set)'}`);
869
- console.debug(` Port: ${serverConfig.port}`);
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
- }
880
- const startupAdapters = loadAdaptersConfig();
881
- if (startupAdapters) {
882
- console.debug(` Available Adapters: ${Object.keys(startupAdapters).join(', ')}`);
883
- } else {
884
- console.debug(` Warning: No adapters configured. Set ORBIT_ADAPTERS or VITE_ADAPTERS environment variable.`);
885
- }
886
- console.debug('');
887
-
888
- if (serverConfig.open) {
889
- openBrowser(url);
890
- }
453
+ const server = app.listen(serverConfig.port, serverConfig.host, () => {
454
+ console.debug(`🚀 ORBIT Chat is running at http://${serverConfig.host}:${serverConfig.port}`);
455
+ if (serverConfig.open) execSync(`open http://${serverConfig.host}:${serverConfig.port}`);
891
456
  });
892
-
893
- process.on('SIGINT', () => {
894
- console.debug('\n\nShutting down server...');
895
- process.exit(0);
896
- });
897
- }
898
-
899
- // Run if called directly
900
- const isMainModule = process.argv[1] && (
901
- import.meta.url === `file://${process.argv[1]}` ||
902
- import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) ||
903
- path.basename(process.argv[1]) === 'orbitchat' ||
904
- path.basename(process.argv[1]) === 'orbitchat.js'
905
- );
906
-
907
- if (isMainModule) {
908
- main();
457
+ // http-proxy may register multiple close listeners when routing across many adapters.
458
+ // Raise the listener cap to avoid noisy false-positive MaxListeners warnings.
459
+ if (typeof server.setMaxListeners === 'function') {
460
+ server.setMaxListeners(0);
461
+ }
909
462
  }
910
463
 
911
- export { main, createServer, loadAdaptersConfig };
464
+ const isMainModule = process.argv[1] && (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || path.basename(process.argv[1]) === 'orbitchat');
465
+ if (isMainModule) main();
466
+ export { main, createServer };