orbitchat 2.14.1 → 3.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/README.md +60 -90
  2. package/bin/orbitchat.js +408 -429
  3. package/dist/assets/ChartRenderer-BEwTOgnq.js +80 -0
  4. package/dist/assets/KaTeX_AMS-Regular-BQhdFMY1.woff2 +0 -0
  5. package/dist/assets/KaTeX_AMS-Regular-DMm9YOAa.woff +0 -0
  6. package/dist/assets/KaTeX_AMS-Regular-DRggAlZN.ttf +0 -0
  7. package/dist/assets/KaTeX_Caligraphic-Bold-ATXxdsX0.ttf +0 -0
  8. package/dist/assets/KaTeX_Caligraphic-Bold-BEiXGLvX.woff +0 -0
  9. package/dist/assets/KaTeX_Caligraphic-Bold-Dq_IR9rO.woff2 +0 -0
  10. package/dist/assets/KaTeX_Caligraphic-Regular-CTRA-rTL.woff +0 -0
  11. package/dist/assets/KaTeX_Caligraphic-Regular-Di6jR-x-.woff2 +0 -0
  12. package/dist/assets/KaTeX_Caligraphic-Regular-wX97UBjC.ttf +0 -0
  13. package/dist/assets/KaTeX_Fraktur-Bold-BdnERNNW.ttf +0 -0
  14. package/dist/assets/KaTeX_Fraktur-Bold-BsDP51OF.woff +0 -0
  15. package/dist/assets/KaTeX_Fraktur-Bold-CL6g_b3V.woff2 +0 -0
  16. package/dist/assets/KaTeX_Fraktur-Regular-CB_wures.ttf +0 -0
  17. package/dist/assets/KaTeX_Fraktur-Regular-CTYiF6lA.woff2 +0 -0
  18. package/dist/assets/KaTeX_Fraktur-Regular-Dxdc4cR9.woff +0 -0
  19. package/dist/assets/KaTeX_Main-Bold-Cx986IdX.woff2 +0 -0
  20. package/dist/assets/KaTeX_Main-Bold-Jm3AIy58.woff +0 -0
  21. package/dist/assets/KaTeX_Main-Bold-waoOVXN0.ttf +0 -0
  22. package/dist/assets/KaTeX_Main-BoldItalic-DxDJ3AOS.woff2 +0 -0
  23. package/dist/assets/KaTeX_Main-BoldItalic-DzxPMmG6.ttf +0 -0
  24. package/dist/assets/KaTeX_Main-BoldItalic-SpSLRI95.woff +0 -0
  25. package/dist/assets/KaTeX_Main-Italic-3WenGoN9.ttf +0 -0
  26. package/dist/assets/KaTeX_Main-Italic-BMLOBm91.woff +0 -0
  27. package/dist/assets/KaTeX_Main-Italic-NWA7e6Wa.woff2 +0 -0
  28. package/dist/assets/KaTeX_Main-Regular-B22Nviop.woff2 +0 -0
  29. package/dist/assets/KaTeX_Main-Regular-Dr94JaBh.woff +0 -0
  30. package/dist/assets/KaTeX_Main-Regular-ypZvNtVU.ttf +0 -0
  31. package/dist/assets/KaTeX_Math-BoldItalic-B3XSjfu4.ttf +0 -0
  32. package/dist/assets/KaTeX_Math-BoldItalic-CZnvNsCZ.woff2 +0 -0
  33. package/dist/assets/KaTeX_Math-BoldItalic-iY-2wyZ7.woff +0 -0
  34. package/dist/assets/KaTeX_Math-Italic-DA0__PXp.woff +0 -0
  35. package/dist/assets/KaTeX_Math-Italic-flOr_0UB.ttf +0 -0
  36. package/dist/assets/KaTeX_Math-Italic-t53AETM-.woff2 +0 -0
  37. package/dist/assets/KaTeX_SansSerif-Bold-CFMepnvq.ttf +0 -0
  38. package/dist/assets/KaTeX_SansSerif-Bold-D1sUS0GD.woff2 +0 -0
  39. package/dist/assets/KaTeX_SansSerif-Bold-DbIhKOiC.woff +0 -0
  40. package/dist/assets/KaTeX_SansSerif-Italic-C3H0VqGB.woff2 +0 -0
  41. package/dist/assets/KaTeX_SansSerif-Italic-DN2j7dab.woff +0 -0
  42. package/dist/assets/KaTeX_SansSerif-Italic-YYjJ1zSn.ttf +0 -0
  43. package/dist/assets/KaTeX_SansSerif-Regular-BNo7hRIc.ttf +0 -0
  44. package/dist/assets/KaTeX_SansSerif-Regular-CS6fqUqJ.woff +0 -0
  45. package/dist/assets/KaTeX_SansSerif-Regular-DDBCnlJ7.woff2 +0 -0
  46. package/dist/assets/KaTeX_Script-Regular-C5JkGWo-.ttf +0 -0
  47. package/dist/assets/KaTeX_Script-Regular-D3wIWfF6.woff2 +0 -0
  48. package/dist/assets/KaTeX_Script-Regular-D5yQViql.woff +0 -0
  49. package/dist/assets/KaTeX_Size1-Regular-C195tn64.woff +0 -0
  50. package/dist/assets/KaTeX_Size1-Regular-Dbsnue_I.ttf +0 -0
  51. package/dist/assets/KaTeX_Size1-Regular-mCD8mA8B.woff2 +0 -0
  52. package/dist/assets/KaTeX_Size2-Regular-B7gKUWhC.ttf +0 -0
  53. package/dist/assets/KaTeX_Size2-Regular-Dy4dx90m.woff2 +0 -0
  54. package/dist/assets/KaTeX_Size2-Regular-oD1tc_U0.woff +0 -0
  55. package/dist/assets/KaTeX_Size3-Regular-CTq5MqoE.woff +0 -0
  56. package/dist/assets/KaTeX_Size3-Regular-DgpXs0kz.ttf +0 -0
  57. package/dist/assets/KaTeX_Size4-Regular-BF-4gkZK.woff +0 -0
  58. package/dist/assets/KaTeX_Size4-Regular-DWFBv043.ttf +0 -0
  59. package/dist/assets/KaTeX_Size4-Regular-Dl5lxZxV.woff2 +0 -0
  60. package/dist/assets/KaTeX_Typewriter-Regular-C0xS9mPB.woff +0 -0
  61. package/dist/assets/KaTeX_Typewriter-Regular-CO6r4hn1.woff2 +0 -0
  62. package/dist/assets/KaTeX_Typewriter-Regular-D3Ib7_Hf.ttf +0 -0
  63. package/dist/assets/MermaidRenderer-Cwj5RQ3g.js +260 -0
  64. package/dist/assets/MusicRenderer-jiCOczly.js +18 -0
  65. package/dist/assets/SVGRenderer-B7MdKpL3.js +6 -0
  66. package/dist/assets/_basePickBy-CYc6FJVo.js +1 -0
  67. package/dist/assets/_baseUniq-Db9U29CG.js +1 -0
  68. package/dist/assets/arc-BSnyvXPh.js +1 -0
  69. package/dist/assets/architectureDiagram-VXUJARFQ-ByDEpAtB.js +36 -0
  70. package/dist/assets/band-CquvqAHh.js +1 -0
  71. package/dist/assets/blockDiagram-VD42YOAC-DeU2cMg9.js +122 -0
  72. package/dist/assets/c4Diagram-YG6GDRKO-C9ykT6JL.js +10 -0
  73. package/dist/assets/channel-C4M311uf.js +1 -0
  74. package/dist/assets/chunk-4BX2VUAB-DSGrQP0T.js +1 -0
  75. package/dist/assets/chunk-55IACEB6-BqJf9Ja5.js +1 -0
  76. package/dist/assets/chunk-B4BG7PRW-CjLmShZ1.js +165 -0
  77. package/dist/assets/chunk-DI55MBZ5-DpYiJhBX.js +220 -0
  78. package/dist/assets/{chunk-FMBD7UC4-dPK7Boav-tO950uDG.js → chunk-FMBD7UC4-B_-ci5rY.js} +2 -2
  79. package/dist/assets/chunk-QN33PNHL-PupYKHzM.js +1 -0
  80. package/dist/assets/chunk-QZHKN3VN-Ddt7KdjH.js +1 -0
  81. package/dist/assets/chunk-TZMSLE5B-DFxHvBUh.js +1 -0
  82. package/dist/assets/classDiagram-2ON5EDUG-Brx1b2Nn.js +1 -0
  83. package/dist/assets/classDiagram-v2-WZHVMYZB-Brx1b2Nn.js +1 -0
  84. package/dist/assets/clone-C6___fhg.js +1 -0
  85. package/dist/assets/cose-bilkent-S5V4N54A-CoAwh2dl.js +1 -0
  86. package/dist/assets/cytoscape.esm-CyJtwmzi.js +331 -0
  87. package/dist/assets/dagre-6UL2VRFP-wqnQLI1r.js +4 -0
  88. package/dist/assets/defaultLocale-DX6XiGOO.js +1 -0
  89. package/dist/assets/diagram-PSM6KHXK-BThDYp3k.js +24 -0
  90. package/dist/assets/diagram-QEK2KX5R-QO0jbKYo.js +43 -0
  91. package/dist/assets/diagram-S2PKOQOG-DqXZQ_2w.js +24 -0
  92. package/dist/assets/erDiagram-Q2GNP2WA-BbHMUMHv.js +60 -0
  93. package/dist/assets/flowDiagram-NV44I4VS-BcZTJeWn.js +162 -0
  94. package/dist/assets/ganttDiagram-JELNMOA3-n27iVieA.js +267 -0
  95. package/dist/assets/gitGraphDiagram-V2S2FVAM-DaO03D0c.js +65 -0
  96. package/dist/assets/graph-CqgsEnyP.js +1 -0
  97. package/dist/assets/index-BLVU3y3C.js +624 -0
  98. package/dist/assets/index-DEMLXEhu.css +1 -0
  99. package/dist/assets/index-pVqH5W3l.js +134 -0
  100. package/dist/assets/infoDiagram-HS3SLOUP-DA9UPsCv.js +2 -0
  101. package/dist/assets/init-Dmth1JHB.js +1 -0
  102. package/dist/assets/journeyDiagram-XKPGCS4Q-DOnOrs8o.js +139 -0
  103. package/dist/assets/kanban-definition-3W4ZIXB7-CeCakzDJ.js +89 -0
  104. package/dist/assets/layout-D9Y_lOuO.js +1 -0
  105. package/dist/assets/linear-CKzVTQ0r.js +1 -0
  106. package/dist/assets/mindmap-definition-VGOIOE7T-DBP7KZgR.js +68 -0
  107. package/dist/assets/ordinal-DILIJJjt.js +1 -0
  108. package/dist/assets/pieDiagram-ADFJNKIX-DSpFXjVs.js +30 -0
  109. package/dist/assets/purify.es-A66Cw1IH.js +2 -0
  110. package/dist/assets/quadrantDiagram-AYHSOK5B-1w3noe3A.js +7 -0
  111. package/dist/assets/requirementDiagram-UZGBJVZJ-B9YzwP8J.js +64 -0
  112. package/dist/assets/sankeyDiagram-TZEHDZUN-FLiuEFVI.js +10 -0
  113. package/dist/assets/sequenceDiagram-WL72ISMW-BSJt3OHN.js +145 -0
  114. package/dist/assets/stateDiagram-FKZM4ZOC-Ch3MODOU.js +1 -0
  115. package/dist/assets/stateDiagram-v2-4FDKWEC3-CT2uJHYZ.js +1 -0
  116. package/dist/assets/step-EjIQ8UIn.js +1 -0
  117. package/dist/assets/time-h5EapSZu.js +1 -0
  118. package/dist/assets/timeline-definition-IT6M3QCI-C_qTQlCc.js +61 -0
  119. package/dist/assets/treemap-GDKQZRPO-yzuGqs4o.js +160 -0
  120. package/dist/assets/xychartDiagram-PRI3JC2R-G6i_3Vla.js +7 -0
  121. package/dist/favicon.svg +3 -1
  122. package/dist/index.html +2 -2
  123. package/dist/logo.png +0 -0
  124. package/markdown-renderer/LICENSE +201 -0
  125. package/markdown-renderer/src/CodeBlock.tsx +332 -0
  126. package/markdown-renderer/src/MarkdownComponents.tsx +233 -0
  127. package/markdown-renderer/src/MarkdownStyles.css +668 -0
  128. package/markdown-renderer/src/css.d.ts +4 -0
  129. package/markdown-renderer/src/index.ts +32 -0
  130. package/markdown-renderer/src/preprocessing.ts +519 -0
  131. package/markdown-renderer/src/renderers/ChartRenderer.tsx +1434 -0
  132. package/markdown-renderer/src/renderers/MermaidRenderer.tsx +474 -0
  133. package/markdown-renderer/src/renderers/MusicRenderer.tsx +394 -0
  134. package/markdown-renderer/src/renderers/SVGRenderer.tsx +307 -0
  135. package/markdown-renderer/src/types.ts +174 -0
  136. package/orbitchat.sh +282 -0
  137. package/package.json +30 -4
  138. package/dist/assets/_baseUniq-BRKsqoH--CzpIYnbF.js +0 -1
  139. package/dist/assets/arc-pab_su9s-DhurQhL4.js +0 -1
  140. package/dist/assets/architectureDiagram-VXUJARFQ-DqQ8r_6g-eqA8fOch.js +0 -36
  141. package/dist/assets/blockDiagram-VD42YOAC-B-dKfcH3-DT3R176S.js +0 -122
  142. package/dist/assets/c4Diagram-YG6GDRKO-DMUPaBEl-q6bAZUce.js +0 -10
  143. package/dist/assets/channel-HKsfPa5q-DshlUHWu.js +0 -1
  144. package/dist/assets/chunk-4BX2VUAB-CX67kh_B-gkWPpLiy.js +0 -1
  145. package/dist/assets/chunk-55IACEB6-BocSyyvr-9ksZ29Ab.js +0 -1
  146. package/dist/assets/chunk-B4BG7PRW-CO8QAyfE-CNBeArXO.js +0 -165
  147. package/dist/assets/chunk-DI55MBZ5-Dw1L6Eos-BVbGVMNG.js +0 -220
  148. package/dist/assets/chunk-QN33PNHL-vP2PqfVG-Y3PYDsm9.js +0 -1
  149. package/dist/assets/chunk-QZHKN3VN-Bcidzu63-BAQ3PYro.js +0 -1
  150. package/dist/assets/chunk-TZMSLE5B-BtljMjlg-CwIvybWR.js +0 -1
  151. package/dist/assets/classDiagram-2ON5EDUG-C7cYN9hv-CGN1Rq_o.js +0 -1
  152. package/dist/assets/classDiagram-v2-WZHVMYZB-C7cYN9hv-CGN1Rq_o.js +0 -1
  153. package/dist/assets/clone-DoPb9X13-C3rfr14F.js +0 -1
  154. package/dist/assets/cose-bilkent-S5V4N54A-BGzO4EsH-DtYXpCOp.js +0 -1
  155. package/dist/assets/cytoscape.esm-CjI2IsL8-Da6dFVsf.js +0 -331
  156. package/dist/assets/dagre-6UL2VRFP-TzNvXCds-xMthYtzg.js +0 -4
  157. package/dist/assets/diagram-PSM6KHXK-BqY4RpUg-CtQy_-7V.js +0 -24
  158. package/dist/assets/diagram-QEK2KX5R-CTjgBsne-DROkeGJL.js +0 -43
  159. package/dist/assets/diagram-S2PKOQOG-BqrhTIpA-Bv0e-7TM.js +0 -24
  160. package/dist/assets/erDiagram-Q2GNP2WA-B2hsi_Tl-BbvH-Jb1.js +0 -60
  161. package/dist/assets/flowDiagram-NV44I4VS-C03vtt_F-DT9Y-btb.js +0 -162
  162. package/dist/assets/ganttDiagram-JELNMOA3-B3hAg964-CnqUJtIB.js +0 -267
  163. package/dist/assets/gitGraphDiagram-NY62KEGX-ByhMH0yZ-CdQlDU7A.js +0 -65
  164. package/dist/assets/graph-BmNkcFEM-mCcAnvHY.js +0 -1
  165. package/dist/assets/index-BXexqYFc-D_cjFmh8.js +0 -134
  166. package/dist/assets/index-DBOy9259.css +0 -1
  167. package/dist/assets/index-zlswAhjt.js +0 -1010
  168. package/dist/assets/infoDiagram-WHAUD3N6-is6Ho4-T-BHS1wv7y.js +0 -2
  169. package/dist/assets/journeyDiagram-XKPGCS4Q-CRTOL26C-DkQ68DJ6.js +0 -139
  170. package/dist/assets/kanban-definition-3W4ZIXB7-CNnO_t6O-DWZm5JX4.js +0 -89
  171. package/dist/assets/layout-C0kZPebx-CygTh9Uo.js +0 -1
  172. package/dist/assets/min-7Gb0pNxh-BfuBtSqq.js +0 -1
  173. package/dist/assets/mindmap-definition-VGOIOE7T-CJZ2wTTa-B7rvUdG3.js +0 -68
  174. package/dist/assets/pieDiagram-ADFJNKIX-C9OSknjr-eiZAefZN.js +0 -30
  175. package/dist/assets/quadrantDiagram-AYHSOK5B-CW8yuAqv-DzBEpWQk.js +0 -7
  176. package/dist/assets/requirementDiagram-UZGBJVZJ-nGPhruO1-z5Z4Fwg9.js +0 -64
  177. package/dist/assets/sankeyDiagram-TZEHDZUN-CmL90u-m-Dk028WXo.js +0 -10
  178. package/dist/assets/sequenceDiagram-WL72ISMW-B02VRcnM-Cz8sPx_Q.js +0 -145
  179. package/dist/assets/stateDiagram-FKZM4ZOC-DjoyLUdz-BxsZBi1n.js +0 -1
  180. package/dist/assets/stateDiagram-v2-4FDKWEC3-Bq76BTB7-BTybFIdr.js +0 -1
  181. package/dist/assets/timeline-definition-IT6M3QCI-CTn0Gm3T-CI07_-Ei.js +0 -61
  182. package/dist/assets/treemap-KMMF4GRG-BjgLKKyi-Dg9N7uMT.js +0 -128
  183. package/dist/assets/xychartDiagram-PRI3JC2R-Cgg6Uija-BDR2Lfu_.js +0 -7
package/bin/orbitchat.js CHANGED
@@ -1,10 +1,11 @@
1
1
  #!/usr/bin/env node
2
2
  /**
3
3
  * ORBIT Chat CLI
4
- *
4
+ *
5
5
  * Serves the chat-app as a standalone application with runtime configuration.
6
- * Configuration can be provided via CLI arguments, config file, or environment variables.
7
- *
6
+ * Configuration is read from orbitchat.yaml (CWD by default, overridable via --config).
7
+ * Secrets (adapter API keys) come from VITE_ADAPTERS / ORBIT_ADAPTERS env var.
8
+ *
8
9
  * The server acts as a proxy to hide API keys from the client by mapping
9
10
  * adapter names to actual API keys.
10
11
  */
@@ -12,34 +13,101 @@
12
13
  import express from 'express';
13
14
  import fs from 'fs';
14
15
  import path from 'path';
15
- import { homedir } from 'os';
16
16
  import { execSync } from 'child_process';
17
17
  import { fileURLToPath } from 'url';
18
18
  import { dirname } from 'path';
19
19
  import { createProxyMiddleware } from 'http-proxy-middleware';
20
+ import yaml from 'js-yaml';
21
+ import rateLimit from 'express-rate-limit';
20
22
 
21
23
  const __filename = fileURLToPath(import.meta.url);
22
24
  const __dirname = dirname(__filename);
23
25
 
24
- // Default configuration matching env.example
25
- // Note: GitHub stats/owner/repo are hardcoded and only configurable via build-time env vars
26
- const DEFAULT_CONFIG = {
26
+ // ---- Minimal .env loader (CLI mode) ----
27
+
28
+ function parseDotEnvValue(raw) {
29
+ const trimmed = raw.trim();
30
+ if (!trimmed) return '';
31
+
32
+ if (
33
+ (trimmed.startsWith('"') && trimmed.endsWith('"')) ||
34
+ (trimmed.startsWith("'") && trimmed.endsWith("'"))
35
+ ) {
36
+ return trimmed.slice(1, -1);
37
+ }
38
+
39
+ return trimmed;
40
+ }
41
+
42
+ function loadDotEnvFromFile(filePath) {
43
+ if (!fs.existsSync(filePath)) return;
44
+ const content = fs.readFileSync(filePath, 'utf8');
45
+ const lines = content.split(/\r?\n/);
46
+
47
+ for (let i = 0; i < lines.length; i++) {
48
+ const trimmed = lines[i].trim();
49
+ if (!trimmed || trimmed.startsWith('#')) continue;
50
+
51
+ const equalsIndex = trimmed.indexOf('=');
52
+ if (equalsIndex <= 0) continue;
53
+
54
+ const key = trimmed.slice(0, equalsIndex).trim();
55
+ if (!key) continue;
56
+
57
+ if (process.env[key] !== undefined) continue;
58
+
59
+ let valueRaw = trimmed.slice(equalsIndex + 1);
60
+ const startsWithDouble = valueRaw.startsWith('"');
61
+ const startsWithSingle = valueRaw.startsWith("'");
62
+
63
+ // Support simple multiline quoted values, useful for formatted JSON values.
64
+ if (
65
+ (startsWithDouble && !valueRaw.endsWith('"')) ||
66
+ (startsWithSingle && !valueRaw.endsWith("'"))
67
+ ) {
68
+ const quote = startsWithDouble ? '"' : "'";
69
+ while (i + 1 < lines.length) {
70
+ i += 1;
71
+ valueRaw += `\n${lines[i]}`;
72
+ if (lines[i].trim().endsWith(quote)) {
73
+ break;
74
+ }
75
+ }
76
+ }
77
+
78
+ const value = parseDotEnvValue(valueRaw);
79
+ process.env[key] = value;
80
+ }
81
+ }
82
+
83
+ function loadDotEnv(baseDir) {
84
+ // Same precedence idea as Vite: .env then .env.local; do not override exported vars.
85
+ loadDotEnvFromFile(path.join(baseDir, '.env'));
86
+ loadDotEnvFromFile(path.join(baseDir, '.env.local'));
87
+ }
88
+
89
+ // ---- Defaults (must match DEFAULTS in src/utils/runtimeConfig.ts) ----
90
+
91
+ const DEFAULTS = {
27
92
  apiUrl: 'http://localhost:3000',
28
93
  defaultKey: 'default-key',
29
94
  applicationName: 'ORBIT Chat',
30
95
  applicationDescription: "Explore ideas with ORBIT's AI copilots, share context, and build together.",
31
96
  defaultInputPlaceholder: 'Message ORBIT...',
32
- useLocalApi: false,
33
- localApiPath: undefined,
34
97
  consoleDebug: false,
98
+ locale: 'en-US',
35
99
  enableUploadButton: false,
36
100
  enableAudioOutput: false,
37
101
  enableAudioInput: false,
38
102
  enableFeedbackButtons: false,
103
+ enableConversationThreads: true,
39
104
  enableAutocomplete: false,
40
105
  voiceSilenceTimeoutMs: 4000,
41
106
  voiceRecognitionLanguage: '',
107
+ showGitHubStats: true,
42
108
  outOfServiceMessage: null,
109
+ githubOwner: 'schmitech',
110
+ githubRepo: 'orbit',
43
111
  maxFilesPerConversation: 5,
44
112
  maxFileSizeMB: 50,
45
113
  maxTotalFiles: 100,
@@ -48,14 +116,157 @@ const DEFAULT_CONFIG = {
48
116
  maxMessagesPerThread: 1000,
49
117
  maxTotalMessages: 10000,
50
118
  maxMessageLength: 1000,
119
+ guestMaxConversations: 1,
120
+ guestMaxMessagesPerConversation: 10,
121
+ guestMaxTotalMessages: 10,
122
+ guestMaxMessagesPerThread: 10,
123
+ guestMaxFilesPerConversation: 1,
124
+ guestMaxTotalFiles: 2,
125
+ guestMaxMessageLength: 500,
126
+ guestMaxFileSizeMB: 10,
51
127
  settingsAboutMsg: 'ORBIT Chat',
128
+ enableAuth: false,
129
+ authDomain: '',
130
+ authClientId: '',
131
+ authAudience: '',
132
+ enableHeader: false,
133
+ headerLogoUrl: '',
134
+ headerBrandName: '',
135
+ headerBgColor: '',
136
+ headerTextColor: '',
137
+ headerNavLinks: [],
138
+ enableFooter: false,
139
+ footerText: '',
140
+ footerBgColor: '',
141
+ footerTextColor: '',
142
+ footerNavLinks: [],
52
143
  };
53
144
 
54
- function parseAdaptersListFromEnv() {
55
- const envValue = process.env.ORBIT_ADAPTERS || process.env.VITE_ADAPTERS;
56
- if (!envValue) {
57
- return [];
145
+ // ---- YAML config loading ----
146
+
147
+ function flattenYamlConfig(y) {
148
+ const f = {};
149
+ if (y.application) {
150
+ const a = y.application;
151
+ if (a.name !== undefined) f.applicationName = a.name;
152
+ if (a.description !== undefined) f.applicationDescription = a.description;
153
+ if (a.inputPlaceholder !== undefined) f.defaultInputPlaceholder = a.inputPlaceholder;
154
+ if (a.settingsAboutMsg !== undefined) f.settingsAboutMsg = a.settingsAboutMsg;
155
+ if (a.locale !== undefined) f.locale = a.locale;
156
+ }
157
+ if (y.api) {
158
+ if (y.api.url !== undefined) f.apiUrl = y.api.url;
159
+ if (y.api.defaultAdapter !== undefined) f.defaultKey = y.api.defaultAdapter;
160
+ }
161
+ if (y.debug) {
162
+ if (y.debug.consoleDebug !== undefined) f.consoleDebug = y.debug.consoleDebug;
163
+ }
164
+ if (y.features) {
165
+ const fe = y.features;
166
+ if (fe.enableUpload !== undefined) f.enableUploadButton = fe.enableUpload;
167
+ if (fe.enableAudioOutput !== undefined) f.enableAudioOutput = fe.enableAudioOutput;
168
+ if (fe.enableAudioInput !== undefined) f.enableAudioInput = fe.enableAudioInput;
169
+ if (fe.enableFeedbackButtons !== undefined) f.enableFeedbackButtons = fe.enableFeedbackButtons;
170
+ if (fe.enableConversationThreads !== undefined) f.enableConversationThreads = fe.enableConversationThreads;
171
+ if (fe.enableAutocomplete !== undefined) f.enableAutocomplete = fe.enableAutocomplete;
172
+ }
173
+ if (y.voice) {
174
+ if (y.voice.silenceTimeoutMs !== undefined) f.voiceSilenceTimeoutMs = y.voice.silenceTimeoutMs;
175
+ if (y.voice.recognitionLanguage !== undefined) f.voiceRecognitionLanguage = y.voice.recognitionLanguage;
176
+ }
177
+ if (y.github) {
178
+ if (y.github.showStats !== undefined) f.showGitHubStats = y.github.showStats;
179
+ if (y.github.owner !== undefined) f.githubOwner = y.github.owner;
180
+ if (y.github.repo !== undefined) f.githubRepo = y.github.repo;
58
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
+ function loadYamlConfig(configPath) {
250
+ try {
251
+ if (fs.existsSync(configPath)) {
252
+ const content = fs.readFileSync(configPath, 'utf8');
253
+ const parsed = yaml.load(content);
254
+ if (parsed && typeof parsed === 'object') {
255
+ return parsed;
256
+ }
257
+ }
258
+ } catch (error) {
259
+ console.error(`Error: Failed to parse ${configPath}: ${error.message}`);
260
+ process.exit(1);
261
+ }
262
+ return null;
263
+ }
264
+
265
+ // ---- Adapter loading (env secrets + YAML metadata) ----
266
+
267
+ function parseAdaptersFromEnv() {
268
+ const envValue = process.env.ORBIT_ADAPTERS || process.env.VITE_ADAPTERS;
269
+ if (!envValue) return [];
59
270
 
60
271
  try {
61
272
  const parsed = JSON.parse(envValue);
@@ -63,46 +274,28 @@ function parseAdaptersListFromEnv() {
63
274
  console.warn('Warning: ORBIT_ADAPTERS/VITE_ADAPTERS must be a JSON array');
64
275
  return [];
65
276
  }
66
-
67
- const adapters = [];
68
- for (const adapter of parsed) {
69
- if (!adapter.name) {
70
- console.warn('Warning: Each adapter must have a "name" property');
71
- continue;
72
- }
73
- adapters.push({
74
- name: adapter.name,
75
- apiKey: adapter.apiKey || DEFAULT_CONFIG.defaultKey,
76
- apiUrl: adapter.apiUrl || DEFAULT_CONFIG.apiUrl,
77
- description: adapter.description || adapter.summary,
78
- notes: adapter.notes,
79
- });
80
- }
81
-
82
- return adapters;
277
+ return parsed.filter(a => a.name).map(a => ({
278
+ name: a.name,
279
+ apiKey: a.apiKey || DEFAULTS.defaultKey,
280
+ apiUrl: a.apiUrl || DEFAULTS.apiUrl,
281
+ description: a.description || a.summary,
282
+ notes: a.notes,
283
+ }));
83
284
  } catch (error) {
84
285
  console.warn('Warning: Could not parse ORBIT_ADAPTERS/VITE_ADAPTERS:', error.message);
85
286
  return [];
86
287
  }
87
288
  }
88
289
 
89
- /**
90
- * Load adapter mappings from environment variable
91
- * Format: JSON array of adapter objects
92
- * Example: ORBIT_ADAPTERS='[{"name":"Simple Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
93
- * @returns {object|null} - Adapters object or null if not found/invalid
94
- */
95
290
  function loadAdaptersConfig() {
96
- const adapterList = parseAdaptersListFromEnv();
97
- if (adapterList.length === 0) {
98
- return null;
99
- }
291
+ const adapterList = parseAdaptersFromEnv();
292
+ if (adapterList.length === 0) return null;
100
293
 
101
294
  const adapters = {};
102
295
  for (const adapter of adapterList) {
103
296
  adapters[adapter.name] = {
104
- apiKey: adapter.apiKey || DEFAULT_CONFIG.defaultKey,
105
- apiUrl: adapter.apiUrl || DEFAULT_CONFIG.apiUrl,
297
+ apiKey: adapter.apiKey || DEFAULTS.defaultKey,
298
+ apiUrl: adapter.apiUrl || DEFAULTS.apiUrl,
106
299
  description: adapter.description,
107
300
  notes: adapter.notes,
108
301
  };
@@ -112,113 +305,26 @@ function loadAdaptersConfig() {
112
305
  }
113
306
 
114
307
  function getDefaultAdapterFromEnv() {
115
- const adapterList = parseAdaptersListFromEnv();
308
+ const adapterList = parseAdaptersFromEnv();
116
309
  return adapterList.length > 0 ? adapterList[0].name : null;
117
310
  }
118
311
 
119
- /**
120
- * Parse command-line arguments
121
- */
312
+ // ---- CLI arg parsing (server flags only) ----
313
+
122
314
  function parseArgs() {
123
315
  const args = process.argv.slice(2);
124
- const config = { ...DEFAULT_CONFIG };
125
316
  const serverConfig = {
126
317
  port: 5173,
127
318
  host: 'localhost',
128
319
  open: false,
129
320
  configFile: null,
130
321
  apiOnly: false,
322
+ corsOrigin: undefined,
131
323
  };
132
324
 
133
325
  for (let i = 0; i < args.length; i++) {
134
326
  const arg = args[i];
135
-
136
327
  switch (arg) {
137
- case '--api-url':
138
- config.apiUrl = args[++i];
139
- break;
140
- case '--api-key':
141
- config.defaultKey = args[++i];
142
- break;
143
- case '--default-adapter':
144
- config.defaultKey = args[++i];
145
- break;
146
- case '--default-key':
147
- console.warn('Warning: --default-key is deprecated. Use --default-adapter instead.');
148
- config.defaultKey = args[++i];
149
- break;
150
- case '--application-name':
151
- config.applicationName = args[++i];
152
- break;
153
- case '--application-description':
154
- config.applicationDescription = args[++i];
155
- break;
156
- case '--default-input-placeholder':
157
- config.defaultInputPlaceholder = args[++i];
158
- break;
159
- case '--use-local-api':
160
- config.useLocalApi = true;
161
- break;
162
- case '--local-api-path':
163
- config.localApiPath = args[++i];
164
- break;
165
- case '--console-debug':
166
- config.consoleDebug = true;
167
- break;
168
- case '--enable-upload':
169
- config.enableUploadButton = true;
170
- break;
171
- case '--enable-audio':
172
- config.enableAudioOutput = true;
173
- break;
174
- case '--enable-audio-input':
175
- config.enableAudioInput = true;
176
- break;
177
- case '--enable-feedback':
178
- config.enableFeedbackButtons = true;
179
- break;
180
- case '--enable-autocomplete':
181
- config.enableAutocomplete = true;
182
- break;
183
- case '--voice-silence-timeout-ms':
184
- config.voiceSilenceTimeoutMs = parseInt(args[++i], 10);
185
- break;
186
- case '--voice-recognition-lang':
187
- config.voiceRecognitionLanguage = args[++i];
188
- break;
189
- case '--enable-api-middleware':
190
- // Ignored — middleware mode is now always enabled
191
- break;
192
- case '--out-of-service-message':
193
- config.outOfServiceMessage = args[++i];
194
- break;
195
- case '--max-files-per-conversation':
196
- config.maxFilesPerConversation = parseInt(args[++i], 10);
197
- break;
198
- case '--max-file-size-mb':
199
- config.maxFileSizeMB = parseInt(args[++i], 10);
200
- break;
201
- case '--max-total-files':
202
- config.maxTotalFiles = parseInt(args[++i], 10);
203
- break;
204
- case '--max-conversations':
205
- config.maxConversations = parseInt(args[++i], 10);
206
- break;
207
- case '--max-messages-per-conversation':
208
- config.maxMessagesPerConversation = parseInt(args[++i], 10);
209
- break;
210
- case '--max-messages-per-thread':
211
- config.maxMessagesPerThread = parseInt(args[++i], 10);
212
- break;
213
- case '--max-total-messages':
214
- config.maxTotalMessages = parseInt(args[++i], 10);
215
- break;
216
- case '--max-message-length':
217
- config.maxMessageLength = parseInt(args[++i], 10);
218
- break;
219
- case '--settings-about-msg':
220
- config.settingsAboutMsg = args[++i];
221
- break;
222
328
  case '--port':
223
329
  serverConfig.port = parseInt(args[++i], 10);
224
330
  break;
@@ -228,22 +334,19 @@ function parseArgs() {
228
334
  case '--open':
229
335
  serverConfig.open = true;
230
336
  break;
337
+ case '--config':
338
+ serverConfig.configFile = args[++i];
339
+ break;
231
340
  case '--api-only':
232
341
  serverConfig.apiOnly = true;
233
342
  break;
234
343
  case '--cors-origin':
235
344
  serverConfig.corsOrigin = args[++i];
236
345
  break;
237
- case '--config':
238
- serverConfig.configFile = args[++i];
239
- break;
240
346
  case '--help':
241
347
  case '-h':
242
- // Handled in main() function
243
- break;
244
348
  case '--version':
245
349
  case '-v':
246
- // Handled in main() function
247
350
  break;
248
351
  default:
249
352
  if (arg.startsWith('--')) {
@@ -254,126 +357,15 @@ function parseArgs() {
254
357
  }
255
358
  }
256
359
 
257
- return { config, serverConfig };
360
+ return serverConfig;
258
361
  }
259
362
 
260
- /**
261
- * Load configuration from file
262
- */
263
- function loadConfigFile(filePath) {
264
- try {
265
- if (fs.existsSync(filePath)) {
266
- const content = fs.readFileSync(filePath, 'utf8');
267
- return JSON.parse(content);
268
- }
269
- } catch (error) {
270
- console.warn(`Warning: Could not load config file ${filePath}:`, error.message);
271
- }
272
- return null;
273
- }
363
+ // ---- HTML injection ----
274
364
 
275
- /**
276
- * Load configuration from environment variables
277
- */
278
- function loadConfigFromEnv() {
279
- const envConfig = {};
280
-
281
- // Map VITE_* environment variables to config keys
282
- const envMap = {
283
- VITE_API_URL: 'apiUrl',
284
- VITE_DEFAULT_KEY: 'defaultKey',
285
- VITE_APPLICATION_NAME: 'applicationName',
286
- VITE_APPLICATION_DESCRIPTION: 'applicationDescription',
287
- VITE_DEFAULT_INPUT_PLACEHOLDER: 'defaultInputPlaceholder',
288
- VITE_USE_LOCAL_API: 'useLocalApi',
289
- VITE_LOCAL_API_PATH: 'localApiPath',
290
- VITE_CONSOLE_DEBUG: 'consoleDebug',
291
- VITE_ENABLE_UPLOAD: 'enableUploadButton',
292
- VITE_ENABLE_AUDIO_OUTPUT: 'enableAudioOutput',
293
- VITE_ENABLE_AUDIO_INPUT: 'enableAudioInput',
294
- VITE_ENABLE_FEEDBACK: 'enableFeedbackButtons',
295
- VITE_ENABLE_AUTOCOMPLETE: 'enableAutocomplete',
296
- VITE_VOICE_SILENCE_TIMEOUT_MS: 'voiceSilenceTimeoutMs',
297
- VITE_VOICE_RECOGNITION_LANG: 'voiceRecognitionLanguage',
298
- VITE_OUT_OF_SERVICE_MESSAGE: 'outOfServiceMessage',
299
- VITE_MAX_FILES_PER_CONVERSATION: 'maxFilesPerConversation',
300
- VITE_MAX_FILE_SIZE_MB: 'maxFileSizeMB',
301
- VITE_MAX_TOTAL_FILES: 'maxTotalFiles',
302
- VITE_MAX_CONVERSATIONS: 'maxConversations',
303
- VITE_MAX_MESSAGES_PER_CONVERSATION: 'maxMessagesPerConversation',
304
- VITE_MAX_MESSAGES_PER_THREAD: 'maxMessagesPerThread',
305
- VITE_MAX_TOTAL_MESSAGES: 'maxTotalMessages',
306
- VITE_MAX_MESSAGE_LENGTH: 'maxMessageLength',
307
- VITE_SETTINGS_ABOUT_MSG: 'settingsAboutMsg',
308
- };
309
-
310
- for (const [envKey, configKey] of Object.entries(envMap)) {
311
- const value = process.env[envKey];
312
- if (value !== undefined) {
313
- if (configKey === 'useLocalApi' || configKey === 'consoleDebug' ||
314
- configKey === 'enableUploadButton' || configKey === 'enableAudioOutput' ||
315
- configKey === 'enableAudioInput' || configKey === 'enableFeedbackButtons' || configKey === 'enableAutocomplete') {
316
- envConfig[configKey] = value === 'true';
317
- } else if (configKey === 'voiceSilenceTimeoutMs') {
318
- const parsed = parseInt(value, 10);
319
- if (!isNaN(parsed)) {
320
- envConfig[configKey] = parsed;
321
- }
322
- } else if (configKey.includes('max') && configKey !== 'maxFileSizeMB') {
323
- const parsed = parseInt(value, 10);
324
- if (!isNaN(parsed)) {
325
- envConfig[configKey] = parsed;
326
- }
327
- } else {
328
- envConfig[configKey] = value;
329
- }
330
- }
331
- }
332
-
333
- return envConfig;
334
- }
335
-
336
- /**
337
- * Merge configurations in priority order: CLI args > config file > env vars > defaults
338
- * Note: GitHub stats/owner/repo are not included in runtime config - they're hardcoded
339
- * and only configurable via build-time env vars for developers who fork.
340
- */
341
- function mergeConfig(cliConfig, serverConfig) {
342
- // Start with defaults
343
- let config = { ...DEFAULT_CONFIG };
344
-
345
- // Load from environment variables (excluding GitHub config)
346
- const envConfig = loadConfigFromEnv();
347
- config = { ...config, ...envConfig };
348
-
349
- // Load from config file (excluding GitHub config)
350
- const configDir = path.join(homedir(), '.orbit-chat-app');
351
- const defaultConfigFile = path.join(configDir, 'config.json');
352
- const configFile = serverConfig.configFile || defaultConfigFile;
353
-
354
- const fileConfig = loadConfigFile(configFile);
355
- if (fileConfig) {
356
- // Remove GitHub config from file config if present
357
- const { showGitHubStats, githubOwner, githubRepo, ...fileConfigWithoutGitHub } = fileConfig;
358
- config = { ...config, ...fileConfigWithoutGitHub };
359
- }
360
-
361
- // CLI arguments override everything (excluding GitHub config)
362
- const { showGitHubStats, githubOwner, githubRepo, ...cliConfigWithoutGitHub } = cliConfig;
363
- config = { ...config, ...cliConfigWithoutGitHub };
364
-
365
- return config;
366
- }
367
-
368
- /**
369
- * Inject configuration into HTML
370
- * The config script MUST be placed in <head> BEFORE the main JS module loads,
371
- * otherwise window.ORBIT_CHAT_CONFIG won't be available when the app initializes.
372
- */
373
365
  function injectConfig(html, config) {
374
366
  const configScript = `<script>window.ORBIT_CHAT_CONFIG = ${JSON.stringify(config)};</script>`;
375
367
 
376
- // Remove the placeholder script tag (it's in body, too late)
368
+ // Remove the placeholder script tag
377
369
  html = html.replace(
378
370
  /<script id="orbit-chat-config" type="application\/json">[\s\S]*?<\/script>/,
379
371
  '<!-- Config injected in head -->'
@@ -385,34 +377,86 @@ function injectConfig(html, config) {
385
377
  /<title>.*?<\/title>/i,
386
378
  `<title>${config.applicationName}</title>`
387
379
  );
388
- // Also update apple-mobile-web-app-title meta tag
389
380
  html = html.replace(
390
381
  /<meta name="apple-mobile-web-app-title" content="[^"]*" \/>/i,
391
382
  `<meta name="apple-mobile-web-app-title" content="${config.applicationName}" />`
392
383
  );
393
384
  }
394
385
 
395
- // Inject the config script at the START of <head>, before any other scripts
396
- // This ensures window.ORBIT_CHAT_CONFIG is available when the main JS bundle loads
386
+ // Inject the config script at the START of <head>
397
387
  return html.replace(
398
388
  /<head>/i,
399
389
  '<head>\n ' + configScript
400
390
  );
401
391
  }
402
392
 
403
- /**
404
- * Create Express server
405
- *
406
- * @param {string|null} distPath - Path to built UI assets (null in api-only mode)
407
- * @param {object} config - Merged application config
408
- * @param {object} serverConfig - Server config (port, host, apiOnly, etc.)
409
- */
393
+ // ---- Rate limiting ----
394
+
395
+ function createRateLimiters(rateLimitConfig) {
396
+ if (!rateLimitConfig || rateLimitConfig.enabled === false) return null;
397
+
398
+ const windowMs = rateLimitConfig.windowMs || 60000;
399
+ const maxRequests = rateLimitConfig.maxRequests || 30;
400
+ const chatWindowMs = rateLimitConfig.chat?.windowMs || 60000;
401
+ const chatMaxRequests = rateLimitConfig.chat?.maxRequests || 10;
402
+
403
+ const keyGenerator = (req) =>
404
+ req.ip || req.headers['x-forwarded-for'] || 'unknown';
405
+
406
+ const api = rateLimit({
407
+ windowMs,
408
+ max: maxRequests,
409
+ keyGenerator,
410
+ standardHeaders: 'draft-7',
411
+ legacyHeaders: false,
412
+ validate: { default: true, keyGeneratorIpFallback: false },
413
+ skip: (req) => req.method === 'OPTIONS' || req.path === '/adapters',
414
+ handler: (req, res) => {
415
+ const retryAfterMs = res.getHeader('RateLimit-Reset')
416
+ ? Number(res.getHeader('RateLimit-Reset')) * 1000
417
+ : windowMs;
418
+ res.status(429).json({
419
+ error: 'Too many requests',
420
+ message: `Rate limit exceeded. Try again in ${Math.ceil(retryAfterMs / 1000)} seconds.`,
421
+ retryAfterMs,
422
+ });
423
+ },
424
+ });
425
+
426
+ const chat = rateLimit({
427
+ windowMs: chatWindowMs,
428
+ max: chatMaxRequests,
429
+ keyGenerator,
430
+ standardHeaders: 'draft-7',
431
+ legacyHeaders: false,
432
+ validate: { default: true, keyGeneratorIpFallback: false },
433
+ handler: (req, res) => {
434
+ const retryAfterMs = res.getHeader('RateLimit-Reset')
435
+ ? Number(res.getHeader('RateLimit-Reset')) * 1000
436
+ : chatWindowMs;
437
+ res.status(429).json({
438
+ error: 'Too many requests',
439
+ message: `Chat rate limit exceeded. Try again in ${Math.ceil(retryAfterMs / 1000)} seconds.`,
440
+ retryAfterMs,
441
+ });
442
+ },
443
+ });
444
+
445
+ return { api, chat };
446
+ }
447
+
448
+ // ---- Express server ----
449
+
410
450
  function createServer(distPath, config, serverConfig = {}) {
411
451
  const app = express();
412
452
  const adapters = loadAdaptersConfig();
413
453
  const apiOnly = serverConfig.apiOnly || false;
454
+ const yamlAdapterMetadata = new Map(
455
+ Array.isArray(config.adapters)
456
+ ? config.adapters.filter(a => a && a.name).map(a => [a.name, a])
457
+ : []
458
+ );
414
459
 
415
- // In api-only mode, add CORS middleware so external UIs on other origins can call the API
416
460
  if (apiOnly) {
417
461
  const allowedOrigin = serverConfig.corsOrigin || '*';
418
462
  app.use((req, res, next) => {
@@ -427,10 +471,39 @@ function createServer(distPath, config, serverConfig = {}) {
427
471
  });
428
472
  }
429
473
 
474
+ // Guest rate limiting — after CORS, before proxy. Skips authenticated requests.
475
+ const limiters = createRateLimiters(serverConfig.rateLimit);
476
+ if (limiters) {
477
+ app.use('/api', (req, res, next) => {
478
+ if (req.headers.authorization) return next();
479
+ limiters.api(req, res, next);
480
+ });
481
+ app.use('/api', (req, res, next) => {
482
+ if (req.headers.authorization) return next();
483
+ if (req.method === 'POST' && (/\/chat/i.test(req.path) || /\/stream/i.test(req.path))) {
484
+ return limiters.chat(req, res, next);
485
+ }
486
+ next();
487
+ });
488
+ }
489
+
430
490
  // API proxy endpoints - MUST be before body parsers
431
491
  if (adapters) {
432
- // Pre-create proxy instances for each adapter to avoid memory leaks
433
- // Creating proxies on every request adds event listeners that accumulate
492
+ // Merge adapter metadata from YAML so UI labels/notes are consistent with dev mode.
493
+ for (const [adapterName, adapter] of Object.entries(adapters)) {
494
+ const metadata = yamlAdapterMetadata.get(adapterName);
495
+ if (!metadata) continue;
496
+ if (!adapter.description && metadata.description) {
497
+ adapter.description = metadata.description;
498
+ }
499
+ if (!adapter.notes && metadata.notes) {
500
+ adapter.notes = metadata.notes;
501
+ }
502
+ if (!adapter.apiUrl && metadata.apiUrl) {
503
+ adapter.apiUrl = metadata.apiUrl;
504
+ }
505
+ }
506
+
434
507
  const proxyInstances = {};
435
508
  for (const [adapterName, adapter] of Object.entries(adapters)) {
436
509
  if (!adapter.apiKey || !adapter.apiUrl) {
@@ -440,33 +513,26 @@ function createServer(distPath, config, serverConfig = {}) {
440
513
  proxyInstances[adapterName] = createProxyMiddleware({
441
514
  target: adapter.apiUrl,
442
515
  changeOrigin: true,
443
- // Restore /api prefix for backend paths that need it (files, threads)
444
516
  pathRewrite: (path) => {
445
517
  if (path.startsWith('/files') || path.startsWith('/threads')) {
446
518
  return '/api' + path;
447
519
  }
448
520
  return path;
449
521
  },
450
- // Set headers directly - this is more reliable than onProxyReq for some cases
451
522
  headers: {
452
523
  'X-API-Key': adapter.apiKey,
453
524
  },
454
- // Critical for SSE streaming - disable response buffering
455
525
  selfHandleResponse: false,
456
526
  onProxyReq: (proxyReq, req) => {
457
- // Remove adapter name header
458
527
  proxyReq.removeHeader('x-adapter-name');
459
- // Ensure API key is set (backup to headers option above)
460
528
  proxyReq.setHeader('X-API-Key', adapter.apiKey);
461
- // Preserve important headers
462
- const headersToPreserve = ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length'];
529
+ const headersToPreserve = ['content-type', 'x-session-id', 'x-thread-id', 'accept', 'content-length', 'authorization'];
463
530
  headersToPreserve.forEach(header => {
464
531
  const value = req.headers[header];
465
532
  if (value) {
466
533
  proxyReq.setHeader(header, value);
467
534
  }
468
535
  });
469
- // Copy all other headers
470
536
  Object.keys(req.headers).forEach(key => {
471
537
  const lowerKey = key.toLowerCase();
472
538
  if (!['x-adapter-name', 'host', 'connection', 'transfer-encoding'].includes(lowerKey)) {
@@ -478,18 +544,14 @@ function createServer(distPath, config, serverConfig = {}) {
478
544
  });
479
545
  },
480
546
  onProxyRes: (proxyRes, req, res) => {
481
- // Handle CORS if needed
482
547
  proxyRes.headers['access-control-allow-origin'] = '*';
483
548
  proxyRes.headers['access-control-allow-methods'] = 'GET, POST, PUT, DELETE, OPTIONS';
484
549
  proxyRes.headers['access-control-allow-headers'] = 'Content-Type, X-API-Key, X-Session-ID, X-Thread-ID, X-Adapter-Name';
485
550
 
486
- // Critical for SSE streaming - disable buffering
487
551
  const contentType = proxyRes.headers['content-type'] || '';
488
552
  if (contentType.includes('text/event-stream')) {
489
- // Disable caching and buffering for SSE
490
553
  proxyRes.headers['cache-control'] = 'no-cache';
491
554
  proxyRes.headers['x-accel-buffering'] = 'no';
492
- // Flush response immediately
493
555
  if (res.flushHeaders) {
494
556
  res.flushHeaders();
495
557
  }
@@ -501,12 +563,11 @@ function createServer(distPath, config, serverConfig = {}) {
501
563
  res.status(500).json({ error: 'Proxy error', message: err.message });
502
564
  }
503
565
  },
504
- ws: false, // Disable WebSocket proxying
505
- logLevel: 'silent', // Reduce logging
566
+ ws: false,
567
+ logLevel: 'silent',
506
568
  });
507
569
  }
508
570
 
509
- // Endpoint to list available adapters (only expose names, not URLs or keys)
510
571
  app.get('/api/adapters', (req, res) => {
511
572
  const adapterList = Object.keys(adapters).map(name => ({
512
573
  name,
@@ -516,10 +577,7 @@ function createServer(distPath, config, serverConfig = {}) {
516
577
  res.json({ adapters: adapterList });
517
578
  });
518
579
 
519
- // Proxy middleware for API requests - must be before body parsers to preserve request stream
520
- // Note: Uses /api path instead of /api/proxy for security (hides proxy nature)
521
580
  app.use('/api', (req, res, next) => {
522
- // Skip the /api/adapters route - it's handled separately above
523
581
  if (req.path === '/adapters') {
524
582
  return next('route');
525
583
  }
@@ -539,14 +597,10 @@ function createServer(distPath, config, serverConfig = {}) {
539
597
  });
540
598
  }
541
599
 
542
- // Middleware for parsing JSON - after proxy routes to preserve request body stream
543
600
  app.use(express.json());
544
601
  app.use(express.urlencoded({ extended: true }));
545
602
 
546
- // --- UI serving (skipped in api-only mode) ---
547
603
  if (!apiOnly && distPath) {
548
- // IMPORTANT: Handle index.html BEFORE express.static to inject runtime config
549
- // express.static would serve the file without config injection otherwise
550
604
  app.get(['/', '/index.html'], (req, res) => {
551
605
  try {
552
606
  const indexPath = path.join(distPath, 'index.html');
@@ -560,24 +614,15 @@ function createServer(distPath, config, serverConfig = {}) {
560
614
  }
561
615
  });
562
616
 
563
- // Serve static files (JS, CSS, images, etc.) - index.html is handled above
564
- app.use(express.static(distPath, {
565
- index: false, // Don't serve index.html automatically - we handle it above
566
- }));
617
+ app.use(express.static(distPath, { index: false }));
567
618
 
568
- // SPA fallback - serve index.html for all non-file routes (client-side routing)
569
619
  app.get('/{*splat}', (req, res, next) => {
570
- // Skip API routes
571
620
  if (req.path.startsWith('/api/')) {
572
621
  return next();
573
622
  }
574
-
575
- // Skip requests for files with extensions (let them 404)
576
623
  if (path.extname(req.path)) {
577
624
  return res.status(404).send('Not Found');
578
625
  }
579
-
580
- // Serve index.html for SPA routes
581
626
  try {
582
627
  const indexPath = path.join(distPath, 'index.html');
583
628
  let content = fs.readFileSync(indexPath, 'utf8');
@@ -594,173 +639,104 @@ function createServer(distPath, config, serverConfig = {}) {
594
639
  return app;
595
640
  }
596
641
 
597
- /**
598
- * Get MIME type for file extension
599
- */
600
- function getMimeType(filePath) {
601
- const ext = path.extname(filePath).toLowerCase();
602
- const mimeTypes = {
603
- '.html': 'text/html',
604
- '.js': 'application/javascript',
605
- '.mjs': 'application/javascript',
606
- '.json': 'application/json',
607
- '.css': 'text/css',
608
- '.png': 'image/png',
609
- '.jpg': 'image/jpeg',
610
- '.jpeg': 'image/jpeg',
611
- '.gif': 'image/gif',
612
- '.svg': 'image/svg+xml',
613
- '.ico': 'image/x-icon',
614
- '.woff': 'font/woff',
615
- '.woff2': 'font/woff2',
616
- '.ttf': 'font/ttf',
617
- '.eot': 'application/vnd.ms-fontobject',
618
- };
619
- return mimeTypes[ext] || 'application/octet-stream';
620
- }
642
+ // ---- Utilities ----
621
643
 
622
- /**
623
- * Open browser
624
- */
625
644
  function openBrowser(url) {
626
645
  const platform = process.platform;
627
646
  let command;
628
-
629
- if (platform === 'darwin') {
630
- command = `open "${url}"`;
631
- } else if (platform === 'linux') {
632
- command = `xdg-open "${url}"`;
633
- } else if (platform === 'win32') {
634
- command = `start "${url}"`;
635
- } else {
636
- return;
637
- }
647
+ if (platform === 'darwin') command = `open "${url}"`;
648
+ else if (platform === 'linux') command = `xdg-open "${url}"`;
649
+ else if (platform === 'win32') command = `start "${url}"`;
650
+ else return;
638
651
 
639
- try {
640
- execSync(command, { stdio: 'ignore' });
641
- } catch (error) {
642
- // Ignore errors
643
- }
652
+ try { execSync(command, { stdio: 'ignore' }); } catch { /* ignore */ }
644
653
  }
645
654
 
646
- /**
647
- * Get version from package.json
648
- */
649
655
  function getVersion() {
650
656
  try {
651
657
  const packagePath = path.join(__dirname, '..', 'package.json');
652
658
  const packageContent = fs.readFileSync(packagePath, 'utf8');
653
- const packageJson = JSON.parse(packageContent);
654
- return packageJson.version || 'unknown';
655
- } catch (error) {
656
- return 'unknown';
657
- }
658
- }
659
-
660
- /**
661
- * Print version
662
- */
663
- function printVersion() {
664
- console.log(getVersion());
659
+ return JSON.parse(packageContent).version || 'unknown';
660
+ } catch { return 'unknown'; }
665
661
  }
666
662
 
667
- /**
668
- * Print help message
669
- */
670
663
  function printHelp() {
671
664
  console.log(`
672
665
  ORBIT Chat CLI
673
666
 
674
667
  Usage: orbitchat [options]
675
668
 
669
+ All application settings are configured in orbitchat.yaml (see orbitchat.yaml.example).
670
+ Secrets (adapter API keys) go in VITE_ADAPTERS / ORBIT_ADAPTERS env var.
671
+
676
672
  Options:
677
- --api-url URL API URL (default: http://localhost:3000)
678
- --default-adapter NAME Default adapter to preselect (middleware mode)
679
- --api-key KEY Default API key (default: default-key)
680
- --application-name NAME Application name shown in browser tab (default: ORBIT Chat)
681
- --application-description TEXT Subtitle shown under the welcome heading
682
- --default-input-placeholder TEXT Message input placeholder text (default: Message ORBIT...)
683
- --use-local-api Use local API build (default: false)
684
- --local-api-path PATH Path to local API
685
- --console-debug Enable console debug (default: false)
686
- --enable-upload Enable upload button (default: false)
687
- --enable-audio Enable audio output button (default: false)
688
- --enable-audio-input Enable microphone input button (default: false)
689
- --enable-feedback Enable feedback buttons (default: false)
690
- --enable-autocomplete Enable autocomplete suggestions (default: false)
691
- --voice-silence-timeout-ms N Auto-stop voice capture after N ms of silence (default: 4000)
692
- --voice-recognition-lang LANG BCP-47 language code for speech recognition (default: browser locale)
693
- --enable-api-middleware Ignored (middleware mode is always enabled)
694
- --out-of-service-message TEXT Show maintenance screen blocking access
695
- --max-files-per-conversation N Max files per conversation (default: 5)
696
- --max-file-size-mb N Max file size in MB (default: 50)
697
- --max-total-files N Max total files (default: 100, 0 = unlimited)
698
- --max-conversations N Max conversations (default: 10, 0 = unlimited)
699
- --max-messages-per-conversation N Max messages per conversation (default: 1000, 0 = unlimited)
700
- --max-messages-per-thread N Max messages per thread (default: 1000, 0 = unlimited)
701
- --max-total-messages N Max total messages (default: 10000, 0 = unlimited)
702
- --max-message-length N Max message length (default: 1000)
703
- --settings-about-msg TEXT About message in settings page (default: ORBIT Chat)
704
- --port PORT Server port (default: 5173)
705
- --host HOST Server host (default: localhost)
706
- --open Open browser automatically
707
- --api-only Run API proxy only (no UI serving, no build required)
708
- --cors-origin ORIGIN Allowed CORS origin in api-only mode (default: *)
709
- --config PATH Path to config file (default: ~/.orbit-chat-app/config.json)
710
- --help, -h Show this help message
711
- --version, -v Show version number
712
-
713
- Configuration Priority:
714
- 1. CLI arguments
715
- 2. Config file (~/.orbit-chat-app/config.json)
716
- 3. Environment variables (VITE_*)
717
- 4. Default values
718
-
719
- Environment Variables for Middleware Mode:
720
- ORBIT_ADAPTERS or VITE_ADAPTERS JSON array of adapter configurations
721
- Example: '[{"name":"Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
673
+ --port PORT Server port (default: 5173)
674
+ --host HOST Server host (default: localhost)
675
+ --open Open browser automatically
676
+ --config PATH Path to orbitchat.yaml (default: ./orbitchat.yaml)
677
+ --api-only Run API proxy only (no UI serving)
678
+ --cors-origin URL Allowed CORS origin in api-only mode (default: *)
679
+ --help, -h Show this help message
680
+ --version, -v Show version number
681
+
682
+ Environment Variables:
683
+ ORBIT_ADAPTERS or VITE_ADAPTERS JSON array of adapter configurations (secrets)
684
+ Example: '[{"name":"Chat","apiKey":"key1","apiUrl":"https://api.example.com"}]'
722
685
 
723
686
  Examples:
724
- orbitchat --api-url http://localhost:3000 --port 8080
725
- orbitchat --api-key my-key --open
726
- orbitchat --enable-audio --enable-audio-input --enable-upload --console-debug
727
- orbitchat --config /path/to/config.json
728
- ORBIT_ADAPTERS='[{"name":"Chat","apiKey":"mykey","apiUrl":"https://api.example.com"}]' orbitchat
729
- orbitchat --api-only --port 5174
687
+ orbitchat --port 8080
688
+ orbitchat --config /path/to/orbitchat.yaml --open
730
689
  orbitchat --api-only --cors-origin http://localhost:3001
731
690
  `);
732
-
733
691
  }
734
692
 
735
- /**
736
- * Main function
737
- */
693
+ // ---- Main ----
694
+
738
695
  function main() {
739
- // Check for version flag first
740
696
  if (process.argv.includes('--version') || process.argv.includes('-v')) {
741
- printVersion();
697
+ console.log(getVersion());
742
698
  return;
743
699
  }
744
-
745
- // Check for help flag
746
700
  if (process.argv.includes('--help') || process.argv.includes('-h')) {
747
701
  printHelp();
748
702
  return;
749
703
  }
750
704
 
751
- const { config: cliConfig, serverConfig } = parseArgs();
752
- const config = mergeConfig(cliConfig, serverConfig);
705
+ const serverConfig = parseArgs();
706
+ loadDotEnv(process.cwd());
707
+
708
+ // Load YAML config
709
+ const yamlPath = serverConfig.configFile || path.join(process.cwd(), 'orbitchat.yaml');
710
+ const yamlObj = loadYamlConfig(yamlPath);
711
+ const yamlFlat = yamlObj ? flattenYamlConfig(yamlObj) : {};
712
+
713
+ if (yamlObj) {
714
+ console.debug(`Loaded config from ${yamlPath}`);
715
+ }
716
+
717
+ // Merge: DEFAULTS < YAML config < auth secrets from env
718
+ const config = { ...DEFAULTS, ...yamlFlat };
753
719
 
720
+ // Auth secrets from env
721
+ if (process.env.VITE_AUTH_DOMAIN) config.authDomain = process.env.VITE_AUTH_DOMAIN;
722
+ if (process.env.VITE_AUTH_CLIENT_ID) config.authClientId = process.env.VITE_AUTH_CLIENT_ID;
723
+ if (process.env.VITE_AUTH_AUDIENCE) config.authAudience = process.env.VITE_AUTH_AUDIENCE;
724
+
725
+ // Default adapter fallback
754
726
  const trimmedDefaultKey = (config.defaultKey || '').trim();
755
- if (!trimmedDefaultKey || trimmedDefaultKey === DEFAULT_CONFIG.defaultKey) {
727
+ if (!trimmedDefaultKey || trimmedDefaultKey === DEFAULTS.defaultKey) {
756
728
  const fallbackAdapter = getDefaultAdapterFromEnv();
757
729
  if (fallbackAdapter) {
758
730
  config.defaultKey = fallbackAdapter;
759
- console.debug(`ℹ️ Using '${fallbackAdapter}' as the default adapter (first entry from VITE_ADAPTERS).`);
760
731
  }
761
732
  }
762
733
 
763
- // Find dist directory (not required in api-only mode)
734
+ // Guest rate limiting (server-only, never sent to browser)
735
+ if (yamlObj && yamlObj.guestLimits?.rateLimit) {
736
+ serverConfig.rateLimit = yamlObj.guestLimits.rateLimit;
737
+ }
738
+
739
+ // Find dist directory
764
740
  const distPath = path.join(__dirname, '..', 'dist');
765
741
 
766
742
  if (!serverConfig.apiOnly && !fs.existsSync(distPath)) {
@@ -768,7 +744,6 @@ function main() {
768
744
  process.exit(1);
769
745
  }
770
746
 
771
- // Create and start server
772
747
  const app = createServer(
773
748
  serverConfig.apiOnly ? null : distPath,
774
749
  config,
@@ -785,9 +760,15 @@ function main() {
785
760
  console.debug('Configuration:');
786
761
  console.debug(` Mode: ${serverConfig.apiOnly ? 'API-only (no UI)' : 'Full (API + UI)'}`);
787
762
  console.debug(` API URL: ${config.apiUrl}`);
788
- console.debug(` Default Key/Adapter: ${config.defaultKey || '(not set)'}`);
763
+ console.debug(` Default Adapter: ${config.defaultKey || '(not set)'}`);
789
764
  console.debug(` Port: ${serverConfig.port}`);
790
765
  console.debug(` Host: ${serverConfig.host}`);
766
+ if (yamlObj) {
767
+ console.debug(` Config: ${yamlPath}`);
768
+ }
769
+ if (serverConfig.rateLimit && serverConfig.rateLimit.enabled !== false) {
770
+ 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)`);
771
+ }
791
772
  const startupAdapters = loadAdaptersConfig();
792
773
  if (startupAdapters) {
793
774
  console.debug(` Available Adapters: ${Object.keys(startupAdapters).join(', ')}`);
@@ -795,21 +776,19 @@ function main() {
795
776
  console.debug(` Warning: No adapters configured. Set ORBIT_ADAPTERS or VITE_ADAPTERS environment variable.`);
796
777
  }
797
778
  console.debug('');
798
-
779
+
799
780
  if (serverConfig.open) {
800
781
  openBrowser(url);
801
782
  }
802
783
  });
803
784
 
804
- // Handle graceful shutdown
805
785
  process.on('SIGINT', () => {
806
786
  console.debug('\n\nShutting down server...');
807
787
  process.exit(0);
808
788
  });
809
789
  }
810
790
 
811
- // Run if called directly (ES module equivalent of require.main === module)
812
- // For ES modules, we check if this file is being executed directly
791
+ // Run if called directly
813
792
  const isMainModule = process.argv[1] && (
814
793
  import.meta.url === `file://${process.argv[1]}` ||
815
794
  import.meta.url.endsWith(process.argv[1].replace(/\\/g, '/')) ||
@@ -821,4 +800,4 @@ if (isMainModule) {
821
800
  main();
822
801
  }
823
802
 
824
- export { main, parseArgs, mergeConfig, createServer, loadAdaptersConfig };
803
+ export { main, createServer, loadAdaptersConfig };