orbitchat 3.1.1 → 3.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (61) hide show
  1. package/README.md +79 -245
  2. package/bin/orbitchat.js +258 -729
  3. package/dist/assets/{ChartRenderer-CapBCpbV.js → ChartRenderer-C6fHQxUM.js} +1 -1
  4. package/dist/assets/{MermaidRenderer-DQJRirnm.js → MermaidRenderer-uOsrV112.js} +5 -5
  5. package/dist/assets/{MusicRenderer-9vnA54gg.js → MusicRenderer-C-8fXPzl.js} +2 -2
  6. package/dist/assets/{SVGRenderer-By7CKtqU.js → SVGRenderer-7yLFncSt.js} +1 -1
  7. package/dist/assets/{_basePickBy-Dm-zx8im.js → _basePickBy-BaAS07_g.js} +1 -1
  8. package/dist/assets/{_baseUniq-DLPvoy-d.js → _baseUniq-C0rd4rnF.js} +1 -1
  9. package/dist/assets/{architectureDiagram-VXUJARFQ-B1kMlTMW.js → architectureDiagram-VXUJARFQ-DB0b91Sa.js} +1 -1
  10. package/dist/assets/{blockDiagram-VD42YOAC-BlglDb-U.js → blockDiagram-VD42YOAC-BQ4Z_gCK.js} +1 -1
  11. package/dist/assets/{c4Diagram-YG6GDRKO-CaZm--Yy.js → c4Diagram-YG6GDRKO-Dtix_O1L.js} +1 -1
  12. package/dist/assets/channel-CuLY6R17.js +1 -0
  13. package/dist/assets/{chunk-4BX2VUAB-4Kq-2AyN.js → chunk-4BX2VUAB-BMK9KFRb.js} +1 -1
  14. package/dist/assets/{chunk-55IACEB6-FApueVFk.js → chunk-55IACEB6-DireVK0h.js} +1 -1
  15. package/dist/assets/{chunk-B4BG7PRW-CHox7be9.js → chunk-B4BG7PRW-BRwwDrXp.js} +1 -1
  16. package/dist/assets/{chunk-DI55MBZ5-N5sePLFo.js → chunk-DI55MBZ5-DeD4e72t.js} +1 -1
  17. package/dist/assets/{chunk-FMBD7UC4-BSbBs0xy.js → chunk-FMBD7UC4-D1-BcrE_.js} +1 -1
  18. package/dist/assets/{chunk-QN33PNHL-ByurI9-W.js → chunk-QN33PNHL-5dHN5THo.js} +1 -1
  19. package/dist/assets/{chunk-QZHKN3VN-Yy8VVut9.js → chunk-QZHKN3VN-BFj7kkUd.js} +1 -1
  20. package/dist/assets/{chunk-TZMSLE5B-B5oe8pT4.js → chunk-TZMSLE5B-B5JCOX6Y.js} +1 -1
  21. package/dist/assets/classDiagram-2ON5EDUG-CCd0SByT.js +1 -0
  22. package/dist/assets/classDiagram-v2-WZHVMYZB-CCd0SByT.js +1 -0
  23. package/dist/assets/clone-DRrrmN93.js +1 -0
  24. package/dist/assets/{cose-bilkent-S5V4N54A-DS_-k6qR.js → cose-bilkent-S5V4N54A-CmgTSX_t.js} +1 -1
  25. package/dist/assets/{dagre-6UL2VRFP-DgkwJjVY.js → dagre-6UL2VRFP-C3rzyEFR.js} +1 -1
  26. package/dist/assets/{diagram-PSM6KHXK-C9su64f9.js → diagram-PSM6KHXK-CyHeoxQN.js} +1 -1
  27. package/dist/assets/{diagram-QEK2KX5R-DY6-9Dbc.js → diagram-QEK2KX5R-2gCRyxH5.js} +1 -1
  28. package/dist/assets/{diagram-S2PKOQOG-_Wub-Eik.js → diagram-S2PKOQOG-BV4hBni4.js} +1 -1
  29. package/dist/assets/{erDiagram-Q2GNP2WA-Gwx9nSAM.js → erDiagram-Q2GNP2WA-DLJ0STwx.js} +1 -1
  30. package/dist/assets/{flowDiagram-NV44I4VS-DmrXTF_S.js → flowDiagram-NV44I4VS-DaJev1Dk.js} +1 -1
  31. package/dist/assets/{ganttDiagram-JELNMOA3-DymVUgsd.js → ganttDiagram-JELNMOA3-BVqSlqWd.js} +1 -1
  32. package/dist/assets/{gitGraphDiagram-V2S2FVAM-D3VSQFT4.js → gitGraphDiagram-V2S2FVAM-fILg4kGK.js} +1 -1
  33. package/dist/assets/{graph-BIe-N9yO.js → graph-CMQ9In6q.js} +1 -1
  34. package/dist/assets/{index-BiM6VNSE.js → index-Bp6Dtnjv.js} +37 -37
  35. package/dist/assets/{index-B-NnBj1Y.js → index-CeOmPokb.js} +116 -116
  36. package/dist/assets/index-w5RjGGjS.css +1 -0
  37. package/dist/assets/{infoDiagram-HS3SLOUP-D2DeUrgk.js → infoDiagram-HS3SLOUP-D7dyBxUc.js} +1 -1
  38. package/dist/assets/{journeyDiagram-XKPGCS4Q-GxFhv1XB.js → journeyDiagram-XKPGCS4Q-B5mS1ZV0.js} +1 -1
  39. package/dist/assets/{kanban-definition-3W4ZIXB7-BhT-ga9I.js → kanban-definition-3W4ZIXB7-BiWSAAjN.js} +1 -1
  40. package/dist/assets/{layout-38O87Ivt.js → layout-BME0mjSq.js} +1 -1
  41. package/dist/assets/{mindmap-definition-VGOIOE7T-DfKjmcaV.js → mindmap-definition-VGOIOE7T-CoH0z1ra.js} +1 -1
  42. package/dist/assets/{pieDiagram-ADFJNKIX-D7lDWRGN.js → pieDiagram-ADFJNKIX-Dq0SKRlZ.js} +1 -1
  43. package/dist/assets/{quadrantDiagram-AYHSOK5B-CPtZ8LgW.js → quadrantDiagram-AYHSOK5B-DwutakEK.js} +1 -1
  44. package/dist/assets/{requirementDiagram-UZGBJVZJ-h-H7R8sR.js → requirementDiagram-UZGBJVZJ-BzFetBkD.js} +1 -1
  45. package/dist/assets/{sankeyDiagram-TZEHDZUN-C6cn5g4m.js → sankeyDiagram-TZEHDZUN-BbOUy78i.js} +1 -1
  46. package/dist/assets/{sequenceDiagram-WL72ISMW-B6AwcQ4F.js → sequenceDiagram-WL72ISMW-1ZNpnZKs.js} +1 -1
  47. package/dist/assets/{stateDiagram-FKZM4ZOC-B4ne7z3I.js → stateDiagram-FKZM4ZOC-BAP0pbLV.js} +1 -1
  48. package/dist/assets/stateDiagram-v2-4FDKWEC3-HGuFIOmd.js +1 -0
  49. package/dist/assets/{timeline-definition-IT6M3QCI-BmjJnJ-r.js → timeline-definition-IT6M3QCI-CuyYlb9E.js} +1 -1
  50. package/dist/assets/treemap-GDKQZRPO-CUELWxdP.js +160 -0
  51. package/dist/assets/{xychartDiagram-PRI3JC2R-C3lVgboM.js → xychartDiagram-PRI3JC2R-CaZOodKt.js} +1 -1
  52. package/dist/index.html +2 -2
  53. package/orbitchat.sh +94 -36
  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
  61. package/dist/assets/treemap-GDKQZRPO-CBUbyJIX.js +0 -160
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,14 @@ 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
+ // ---- Minimal .env loader ----
27
24
 
28
25
  function parseDotEnvValue(raw) {
29
26
  const trimmed = raw.trim();
30
27
  if (!trimmed) return '';
31
-
32
- if (
33
- (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
34
- (trimmed.startsWith("'") && trimmed.endsWith("'"))
35
- ) {
28
+ if ((trimmed.startsWith('"') && trimmed.endsWith('"')) || (trimmed.startsWith("'") && trimmed.endsWith("'"))) {
36
29
  return trimmed.slice(1, -1);
37
30
  }
38
-
39
31
  return trimmed;
40
32
  }
41
33
 
@@ -43,217 +35,106 @@ function loadDotEnvFromFile(filePath) {
43
35
  if (!fs.existsSync(filePath)) return;
44
36
  const content = fs.readFileSync(filePath, 'utf8');
45
37
  const lines = content.split(/\r?\n/);
46
-
47
38
  for (let i = 0; i < lines.length; i++) {
48
39
  const trimmed = lines[i].trim();
49
40
  if (!trimmed || trimmed.startsWith('#')) continue;
50
-
51
41
  const equalsIndex = trimmed.indexOf('=');
52
42
  if (equalsIndex <= 0) continue;
53
-
54
43
  const key = trimmed.slice(0, equalsIndex).trim();
55
- if (!key) continue;
56
-
57
44
  if (process.env[key] !== undefined) continue;
58
-
59
45
  let valueRaw = trimmed.slice(equalsIndex + 1);
60
46
  const startsWithDouble = valueRaw.startsWith('"');
61
47
  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
- ) {
48
+ if ((startsWithDouble && !valueRaw.endsWith('"')) || (startsWithSingle && !valueRaw.endsWith("'"))) {
68
49
  const quote = startsWithDouble ? '"' : "'";
69
50
  while (i + 1 < lines.length) {
70
51
  i += 1;
71
52
  valueRaw += `\n${lines[i]}`;
72
- if (lines[i].trim().endsWith(quote)) {
73
- break;
74
- }
53
+ if (lines[i].trim().endsWith(quote)) break;
75
54
  }
76
55
  }
77
-
78
- const value = parseDotEnvValue(valueRaw);
79
- process.env[key] = value;
56
+ process.env[key] = parseDotEnvValue(valueRaw);
80
57
  }
81
58
  }
82
59
 
83
60
  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
61
  loadDotEnvFromFile(path.join(baseDir, '.env.local'));
62
+ loadDotEnvFromFile(path.join(baseDir, '.env'));
87
63
  }
88
64
 
89
- // ---- Defaults (must match DEFAULTS in src/utils/runtimeConfig.ts) ----
65
+ // ---- Deep Merge ----
66
+
67
+ function isObject(item) {
68
+ return typeof item === 'object' && item !== null && !Array.isArray(item);
69
+ }
70
+
71
+ function deepMerge(target, source) {
72
+ if (!isObject(target) || !isObject(source)) return source;
73
+ const output = { ...target };
74
+ Object.keys(source).forEach(key => {
75
+ if (isObject(target[key]) && isObject(source[key])) {
76
+ output[key] = deepMerge(target[key], source[key]);
77
+ } else if (source[key] !== undefined) {
78
+ output[key] = source[key];
79
+ }
80
+ });
81
+ return output;
82
+ }
83
+
84
+ // ---- Defaults ----
90
85
 
91
86
  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,
87
+ application: {
88
+ name: 'ORBIT Chat',
89
+ description: "Explore ideas with ORBIT's AI copilots, share context, and build together.",
90
+ inputPlaceholder: 'Message ORBIT...',
91
+ settingsAboutMsg: 'ORBIT Chat',
92
+ locale: 'en-US',
93
+ },
94
+ debug: {
95
+ consoleDebug: false,
96
+ },
97
+ features: {
98
+ enableUpload: false,
99
+ enableAudioOutput: false,
100
+ enableAudioInput: false,
101
+ enableFeedbackButtons: false,
102
+ enableConversationThreads: true,
103
+ enableAutocomplete: false,
104
+ },
105
+ voice: {
106
+ silenceTimeoutMs: 4000,
107
+ recognitionLanguage: '',
108
+ },
109
+ github: {
110
+ showStats: true,
111
+ owner: 'schmitech',
112
+ repo: 'orbit',
113
+ },
108
114
  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: [],
115
+ limits: {
116
+ files: { perConversation: 5, maxSizeMB: 50, totalFiles: 100 },
117
+ conversations: { maxConversations: 10, messagesPerConversation: 1000, messagesPerThread: 1000, totalMessages: 10000 },
118
+ messages: { maxLength: 1000 },
119
+ },
120
+ guestLimits: {
121
+ files: { perConversation: 1, maxSizeMB: 10, totalFiles: 2 },
122
+ conversations: { maxConversations: 1, messagesPerConversation: 10, messagesPerThread: 10, totalMessages: 10 },
123
+ messages: { maxLength: 500 },
124
+ },
125
+ auth: { enabled: false, domain: '', clientId: '', audience: '' },
126
+ header: { enabled: false, logoUrl: '', logoUrlLight: '', logoUrlDark: '', brandName: '', bgColor: '', textColor: '', showBorder: true, navLinks: [] },
127
+ footer: { enabled: false, text: '', bgColor: '', textColor: '', showBorder: false, layout: 'stacked', align: 'center', topPadding: 'large', navLinks: [] },
128
+ adapters: [],
143
129
  };
144
130
 
145
131
  // ---- YAML config loading ----
146
132
 
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
133
  function loadYamlConfig(configPath) {
250
134
  try {
251
135
  if (fs.existsSync(configPath)) {
252
136
  const content = fs.readFileSync(configPath, 'utf8');
253
- const parsed = yaml.load(content);
254
- if (parsed && typeof parsed === 'object') {
255
- return parsed;
256
- }
137
+ return yaml.load(content);
257
138
  }
258
139
  } catch (error) {
259
140
  console.error(`Error: Failed to parse ${configPath}: ${error.message}`);
@@ -262,245 +143,100 @@ function loadYamlConfig(configPath) {
262
143
  return null;
263
144
  }
264
145
 
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
- }
146
+ // ---- Local asset handling ----
270
147
 
271
148
  function resolveLocalAssetPath(rawValue, yamlPath) {
272
149
  if (!rawValue || typeof rawValue !== 'string') return null;
273
150
  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
-
151
+ if (!value || /^[a-zA-Z][a-zA-Z\d+\-.]*:/.test(value) || value.startsWith('//')) return null;
152
+ const expandedValue = value.startsWith('~/') ? path.join(process.env.HOME || '', value.slice(2)) : value;
280
153
  const yamlDir = path.dirname(yamlPath);
281
- const candidates = path.isAbsolute(expandedValue)
282
- ? [expandedValue]
283
- : [path.resolve(yamlDir, expandedValue), path.resolve(process.cwd(), expandedValue)];
284
-
154
+ const candidates = path.isAbsolute(expandedValue) ? [expandedValue] : [path.resolve(yamlDir, expandedValue), path.resolve(process.cwd(), expandedValue)];
285
155
  for (const candidate of candidates) {
286
156
  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
- }
157
+ if (fs.existsSync(candidate) && fs.statSync(candidate).isFile()) return fs.realpathSync(candidate);
158
+ } catch { /* ignore */ }
293
159
  }
294
-
295
160
  return null;
296
161
  }
297
162
 
298
163
  // ---- Adapter loading (env secrets + YAML metadata) ----
299
164
 
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
-
165
+ function loadAdaptersForProxy(yamlAdapters) {
327
166
  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
- };
167
+ const fallbackApiUrl = 'http://localhost:3000';
168
+
169
+ if (Array.isArray(yamlAdapters)) {
170
+ for (const ya of yamlAdapters) {
171
+ if (!ya.name) continue;
172
+ adapters[ya.name] = {
173
+ apiKey: '',
174
+ apiUrl: ya.apiUrl || fallbackApiUrl,
175
+ description: ya.description,
176
+ notes: ya.notes,
177
+ model: ya.model
178
+ };
179
+ }
335
180
  }
336
181
 
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);
182
+ const envKeysRaw = process.env.VITE_ADAPTER_KEYS || process.env.ORBIT_ADAPTER_KEYS;
183
+ if (envKeysRaw) {
184
+ try {
185
+ const keys = JSON.parse(envKeysRaw);
186
+ for (const [name, value] of Object.entries(keys)) {
187
+ const isObjectValue = typeof value === 'object' && value !== null;
188
+ const apiKey = isObjectValue
189
+ ? String(value.apiKey || value.key || '')
190
+ : String(value);
191
+ const apiUrl = isObjectValue && value.apiUrl ? String(value.apiUrl) : undefined;
192
+ const description = isObjectValue && value.description ? String(value.description) : undefined;
193
+ const notes = isObjectValue && value.notes ? String(value.notes) : undefined;
194
+ const model = isObjectValue && value.model ? String(value.model) : undefined;
195
+
196
+ if (!adapters[name]) {
197
+ adapters[name] = {
198
+ apiKey,
199
+ apiUrl: apiUrl || fallbackApiUrl,
200
+ description,
201
+ notes,
202
+ model
203
+ };
204
+ } else {
205
+ adapters[name].apiKey = apiKey;
206
+ if (apiUrl) adapters[name].apiUrl = apiUrl;
207
+ if (description !== undefined) adapters[name].description = description;
208
+ if (notes !== undefined) adapters[name].notes = notes;
209
+ if (model !== undefined) adapters[name].model = model;
389
210
  }
390
- }
211
+ }
212
+ } catch { /* ignore */ }
391
213
  }
392
214
 
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
- );
215
+ const finalAdapters = {};
216
+ for (const [name, config] of Object.entries(adapters)) {
217
+ if (config.apiKey) finalAdapters[name] = config;
417
218
  }
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 };
219
+ if (Object.keys(finalAdapters).length > 0) {
220
+ console.debug(`Loaded ${Object.keys(finalAdapters).length} adapters with API keys from environment.`);
221
+ }
222
+ return Object.keys(finalAdapters).length > 0 ? finalAdapters : null;
479
223
  }
480
224
 
481
225
  // ---- Express server ----
482
226
 
483
227
  function createServer(distPath, config, serverConfig = {}) {
484
228
  const app = express();
485
- const adapters = loadAdaptersConfig();
229
+ const adapters = loadAdaptersForProxy(config.adapters);
486
230
  const apiOnly = serverConfig.apiOnly || false;
487
231
  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
232
 
494
233
  if (apiOnly) {
495
234
  const allowedOrigin = serverConfig.corsOrigin || '*';
496
235
  app.use((req, res, next) => {
497
236
  res.setHeader('Access-Control-Allow-Origin', allowedOrigin);
498
237
  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
- }
238
+ res.setHeader('Access-Control-Allow-Headers', 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name, Accept, Authorization');
239
+ if (req.method === 'OPTIONS') return res.sendStatus(204);
504
240
  next();
505
241
  });
506
242
  }
@@ -508,144 +244,59 @@ function createServer(distPath, config, serverConfig = {}) {
508
244
  if (Object.keys(localAssets).length > 0) {
509
245
  app.get('/__orbitchat_assets/:assetId', (req, res) => {
510
246
  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
- }
247
+ if (!assetPath || !fs.existsSync(assetPath)) return res.status(404).send('Asset not found');
517
248
  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
- });
249
+ res.sendFile(assetPath);
523
250
  });
524
251
  }
525
252
 
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);
253
+ // Guest rate limiting
254
+ if (serverConfig.rateLimit?.enabled !== false) {
255
+ const rl = serverConfig.rateLimit || {};
256
+ const apiLimiter = rateLimit({
257
+ windowMs: rl.windowMs || 60000, max: rl.maxRequests || 30,
258
+ skip: (req) => req.method === 'OPTIONS' || req.path === '/adapters',
259
+ handler: (req, res) => res.status(429).json({ error: 'Too many requests' }),
260
+ });
261
+ const chatLimiter = rateLimit({
262
+ windowMs: rl.chat?.windowMs || 60000, max: rl.chat?.maxRequests || 10,
263
+ handler: (req, res) => res.status(429).json({ error: 'Chat rate limit exceeded' }),
532
264
  });
265
+ app.use('/api', (req, res, next) => { if (req.headers.authorization) return next(); apiLimiter(req, res, next); });
533
266
  app.use('/api', (req, res, next) => {
534
267
  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
- }
268
+ if (req.method === 'POST' && (/\/chat/i.test(req.path) || /\/stream/i.test(req.path))) return chatLimiter(req, res, next);
538
269
  next();
539
270
  });
540
271
  }
541
272
 
542
- // API proxy endpoints - MUST be before body parsers
543
273
  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).
274
+ // Lazy model hydration for adapter cards (mirrors vite dev behavior).
624
275
  let modelsLastFetchedAt = 0;
625
276
  let modelsFetchInFlight = null;
626
277
  const MODELS_CACHE_TTL_MS = 30000;
627
- async function fetchAdapterModels(force = false) {
278
+
279
+ async function fetchAdapterModels(adapterMap, force = false) {
628
280
  const now = Date.now();
629
281
  if (!force && (now - modelsLastFetchedAt) < MODELS_CACHE_TTL_MS) return;
630
282
  if (modelsFetchInFlight) return modelsFetchInFlight;
631
283
 
632
284
  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;
285
+ const fetches = Object.entries(adapterMap).map(async ([, adapter]) => {
286
+ if (!adapter.apiUrl || !adapter.apiKey) return;
287
+ try {
288
+ const url = `${String(adapter.apiUrl).replace(/\/+$/, '')}/admin/adapters/info`;
289
+ const resp = await fetch(url, {
290
+ headers: { 'X-API-Key': adapter.apiKey },
291
+ signal: AbortSignal.timeout(5000),
292
+ });
293
+ if (resp.ok) {
294
+ const info = await resp.json();
295
+ adapter.model = typeof info?.model === 'string' ? info.model.trim() || undefined : undefined;
296
+ }
297
+ } catch {
298
+ // Best-effort only; cards can render without model metadata.
645
299
  }
646
- } catch {
647
- // Silently ignore — model will be omitted
648
- }
649
300
  });
650
301
  await Promise.all(fetches);
651
302
  modelsLastFetchedAt = Date.now();
@@ -656,256 +307,134 @@ function createServer(distPath, config, serverConfig = {}) {
656
307
  return modelsFetchInFlight;
657
308
  }
658
309
 
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 => ({
310
+ const buildAdapterList = (adapterMap) =>
311
+ Object.keys(adapterMap).map(name => ({
664
312
  name,
665
- description: adapters[name]?.description,
666
- notes: adapters[name]?.notes,
667
- model: adapters[name]?.model || null,
313
+ description: adapterMap[name].description,
314
+ notes: adapterMap[name].notes,
315
+ model: adapterMap[name].model || null
668
316
  }));
669
- res.setHeader('Cache-Control', 'no-store');
670
- res.json({ adapters: adapterList });
671
- });
672
317
 
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'];
318
+ app.get('/api/adapters', (req, res) => {
319
+ const cacheControlHeader = typeof req.headers['cache-control'] === 'string' ? req.headers['cache-control'] : '';
320
+ const forceRefresh = req.url?.includes('refresh=1') || cacheControlHeader.includes('no-cache');
678
321
 
679
- if (!adapterName) {
680
- return res.status(400).json({ error: 'X-Adapter-Name header is required' });
681
- }
322
+ fetchAdapterModels(adapters, forceRefresh).then(() => {
323
+ res.setHeader('Cache-Control', 'no-store');
324
+ res.json({ adapters: buildAdapterList(adapters) });
325
+ }).catch(() => {
326
+ res.setHeader('Cache-Control', 'no-store');
327
+ res.json({ adapters: buildAdapterList(adapters) });
328
+ });
329
+ });
682
330
 
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
- }
331
+ const dynamicProxy = createProxyMiddleware({
332
+ target: 'http://localhost:3000', // Default fallback
333
+ router: (req) => {
334
+ const adapterName = req.headers['x-adapter-name'];
335
+ return adapters[adapterName]?.apiUrl;
336
+ },
337
+ changeOrigin: true,
338
+ pathRewrite: (p) => p.startsWith('/files') || p.startsWith('/threads') ? '/api' + p : p,
339
+ on: {
340
+ proxyReq: (proxyReq, reqIncoming) => {
341
+ const adapterName = reqIncoming.headers['x-adapter-name'];
342
+ const adapter = adapters[adapterName];
343
+ if (adapter) {
344
+ proxyReq.setHeader('X-API-Key', adapter.apiKey);
345
+ }
346
+ proxyReq.removeHeader('x-adapter-name');
347
+ ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length', 'authorization'].forEach(h => {
348
+ if (reqIncoming.headers[h]) proxyReq.setHeader(h, reqIncoming.headers[h]);
349
+ });
350
+ },
351
+ error: (err, _req, resProxy) => {
352
+ console.error('[Proxy] Proxy error:', err);
353
+ if (!resProxy.headersSent) {
354
+ resProxy.status(500).json({ error: 'Proxy error', message: err.message });
355
+ }
356
+ }
357
+ },
358
+ logLevel: 'silent',
359
+ });
688
360
 
689
- proxy(req, res, next);
361
+ app.use('/api', (req, res, next) => {
362
+ if (req.path === '/adapters') return next('route');
363
+ const adapterName = req.headers['x-adapter-name'];
364
+ if (!adapterName) return res.status(400).json({ error: 'X-Adapter-Name header is required' });
365
+ if (!adapters[adapterName]) return res.status(404).json({ error: `Adapter '${adapterName}' not found` });
366
+ dynamicProxy(req, res, next);
690
367
  });
691
368
  }
692
369
 
693
370
  app.use(express.json());
694
- app.use(express.urlencoded({ extended: true }));
695
-
696
371
  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
372
  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
- }
373
+ app.get(/(.*)/, (req, res) => {
374
+ if (req.path.startsWith('/api/')) return res.status(404).json({ error: 'Not found' });
375
+ const indexPath = path.join(distPath, 'index.html');
376
+ let content = fs.readFileSync(indexPath, 'utf8');
377
+ content = content.replace(/<script id="orbit-chat-config" type="application\/json">[\s\S]*?<\/script>/, '<!-- Config injected -->');
378
+ const configScript = `<script>window.ORBIT_CHAT_CONFIG = ${JSON.stringify(config)};</script>`;
379
+ content = content.replace(/<head>/i, '<head>\n ' + configScript);
380
+ if (config.application?.name) content = content.replace(/<title>.*?<\/title>/i, `<title>${config.application.name}</title>`);
381
+ res.setHeader('Content-Type', 'text/html');
382
+ res.send(content);
729
383
  });
730
384
  }
731
-
732
385
  return app;
733
386
  }
734
387
 
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
388
  // ---- Main ----
787
389
 
788
390
  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;
391
+ const args = process.argv.slice(2);
392
+ const serverConfig = { port: 5173, host: 'localhost', open: false, configFile: null, apiOnly: false };
393
+ for (let i = 0; i < args.length; i++) {
394
+ if (args[i] === '--port') serverConfig.port = parseInt(args[++i], 10);
395
+ else if (args[i] === '--host') serverConfig.host = args[++i];
396
+ else if (args[i] === '--open') serverConfig.open = true;
397
+ else if (args[i] === '--config') serverConfig.configFile = args[++i];
398
+ else if (args[i] === '--api-only') serverConfig.apiOnly = true;
399
+ else if (args[i] === '--help' || args[i] === '-h') { console.log('ORBIT Chat CLI Help...'); return; }
796
400
  }
797
401
 
798
- const serverConfig = parseArgs();
799
402
  loadDotEnv(process.cwd());
800
-
801
- // Load YAML config
802
403
  const yamlPath = serverConfig.configFile || path.join(process.cwd(), 'orbitchat.yaml');
803
404
  const yamlObj = loadYamlConfig(yamlPath);
804
- const yamlFlat = yamlObj ? flattenYamlConfig(yamlObj) : {};
405
+ let config = deepMerge(DEFAULTS, yamlObj || {});
805
406
 
806
- if (yamlObj) {
807
- console.debug(`Loaded config from ${yamlPath}`);
808
- }
407
+ // Overlay secret env vars if they exist
408
+ if (process.env.VITE_AUTH_DOMAIN) config.auth.domain = process.env.VITE_AUTH_DOMAIN;
409
+ if (process.env.VITE_AUTH_CLIENT_ID) config.auth.clientId = process.env.VITE_AUTH_CLIENT_ID;
410
+ if (process.env.VITE_AUTH_AUDIENCE) config.auth.audience = process.env.VITE_AUTH_AUDIENCE;
809
411
 
810
- // Merge: DEFAULTS < YAML config < auth secrets from env
811
- const config = { ...DEFAULTS, ...yamlFlat };
812
412
  const localAssets = {};
413
+ const mapHeaderLogoAsset = (fieldName, assetId) => {
414
+ const resPath = resolveLocalAssetPath(config.header?.[fieldName], yamlPath);
415
+ if (!resPath) return;
416
+ localAssets[assetId] = resPath;
417
+ config.header[fieldName] = `/__orbitchat_assets/${assetId}?v=${Date.now()}`;
418
+ };
813
419
 
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
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
- }
420
+ mapHeaderLogoAsset('logoUrl', 'header_logo');
421
+ mapHeaderLogoAsset('logoUrlLight', 'header_logo_light');
422
+ mapHeaderLogoAsset('logoUrlDark', 'header_logo_dark');
843
423
 
844
- // Find dist directory
845
424
  const distPath = path.join(__dirname, '..', 'dist');
425
+ const app = createServer(distPath, config, { ...serverConfig, rateLimit: yamlObj?.guestLimits?.rateLimit, localAssets });
846
426
 
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
- }
427
+ const server = app.listen(serverConfig.port, serverConfig.host, () => {
428
+ console.debug(`🚀 ORBIT Chat is running at http://${serverConfig.host}:${serverConfig.port}`);
429
+ if (serverConfig.open) execSync(`open http://${serverConfig.host}:${serverConfig.port}`);
891
430
  });
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();
431
+ // http-proxy may register multiple close listeners when routing across many adapters.
432
+ // Raise the listener cap to avoid noisy false-positive MaxListeners warnings.
433
+ if (typeof server.setMaxListeners === 'function') {
434
+ server.setMaxListeners(0);
435
+ }
909
436
  }
910
437
 
911
- export { main, createServer, loadAdaptersConfig };
438
+ const isMainModule = process.argv[1] && (import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) || path.basename(process.argv[1]) === 'orbitchat');
439
+ if (isMainModule) main();
440
+ export { main, createServer };