handoff-app 2.0.0-8 → 2.0.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 (322) hide show
  1. package/{Changelog.md → CHANGELOG.md} +85 -1
  2. package/{License.md → LICENSE.md} +1 -1
  3. package/README.md +816 -393
  4. package/RECIPES.md +975 -0
  5. package/UPGRADE.md +57 -9
  6. package/components.json +20 -20
  7. package/config/docs/foundations/colors/guidelines.md +0 -1
  8. package/config/docs/foundations/colors.md +0 -1
  9. package/config/docs/foundations/effects/guidelines.md +0 -1
  10. package/config/docs/foundations/effects/resources.md +0 -1
  11. package/config/docs/foundations/effects.md +0 -1
  12. package/config/docs/foundations/fonts.md +0 -1
  13. package/config/docs/foundations/grid.md +0 -1
  14. package/config/docs/foundations/icon.md +0 -1
  15. package/config/docs/foundations/icons/guidelines.md +0 -1
  16. package/config/docs/foundations/icons.md +0 -1
  17. package/config/docs/foundations/images/guidelines.md +0 -1
  18. package/config/docs/foundations/images/resources.md +0 -1
  19. package/config/docs/foundations/images.md +0 -1
  20. package/config/docs/foundations/logo/resources.md +0 -1
  21. package/config/docs/foundations/logo.md +0 -1
  22. package/config/docs/foundations/typography/guidelines.md +0 -1
  23. package/config/docs/foundations/typography/resources.md +0 -1
  24. package/config/docs/foundations/typography.md +0 -1
  25. package/config/docs/foundations.md +0 -1
  26. package/config/docs/guidelines.md +0 -1
  27. package/config/docs/index.md +0 -1
  28. package/config/docs/system/tokens/foundations/colors.md +0 -4
  29. package/config/docs/system/tokens/foundations/effects.md +0 -4
  30. package/config/docs/system/tokens/foundations/typography.md +0 -2
  31. package/config/docs/system.md +0 -2
  32. package/config/templates/page.md +2 -1
  33. package/config/templates/page.mdx +2 -1
  34. package/dist/ai/connections.d.ts +66 -0
  35. package/dist/ai/connections.js +149 -0
  36. package/dist/ai/define.d.ts +10 -0
  37. package/dist/ai/define.js +13 -0
  38. package/dist/ai/types.d.ts +26 -0
  39. package/dist/ai/types.js +10 -0
  40. package/dist/app-builder/build.d.ts +14 -7
  41. package/dist/app-builder/build.js +195 -66
  42. package/dist/app-builder/client-config.js +3 -2
  43. package/dist/app-builder/paths.d.ts +0 -12
  44. package/dist/app-builder/paths.js +3 -20
  45. package/dist/app-builder/static-export.js +1 -1
  46. package/dist/app-builder/vercel-output.d.ts +1 -1
  47. package/dist/app-builder/vercel-output.js +5 -4
  48. package/dist/cache/build-cache.d.ts +27 -4
  49. package/dist/cache/build-cache.js +114 -47
  50. package/dist/cache/build-inputs.d.ts +13 -0
  51. package/dist/cache/build-inputs.js +19 -0
  52. package/dist/cache/index.d.ts +2 -1
  53. package/dist/cache/index.js +5 -1
  54. package/dist/catalog/normalize.js +3 -0
  55. package/dist/catalog/previews.js +5 -4
  56. package/dist/catalog/types.d.ts +8 -0
  57. package/dist/cli/create.js +1 -2
  58. package/dist/commands/build/index.d.ts +1 -1
  59. package/dist/commands/build/index.js +1 -1
  60. package/dist/commands/utils.d.ts +3 -3
  61. package/dist/config/defaults.js +7 -4
  62. package/dist/config/from-env.d.ts +4 -0
  63. package/dist/config/from-env.js +38 -1
  64. package/dist/config/helpers.js +2 -2
  65. package/dist/config/index.d.ts +0 -1
  66. package/dist/config/index.js +1 -4
  67. package/dist/config/loader.js +28 -2
  68. package/dist/config/resolve-env.d.ts +2 -1
  69. package/dist/config/resolve-env.js +28 -12
  70. package/dist/config/runtime.js +9 -11
  71. package/dist/handlebars.d.ts +10 -2
  72. package/dist/index.d.ts +12 -7
  73. package/dist/index.js +18 -22
  74. package/dist/nav/index.d.ts +2 -2
  75. package/dist/nav/registry.d.ts +3 -3
  76. package/dist/nav/registry.js +1 -1
  77. package/dist/nav/workspace.d.ts +3 -3
  78. package/dist/nav/workspace.js +1 -1
  79. package/dist/pipeline/figma.d.ts +13 -2
  80. package/dist/pipeline/figma.js +86 -46
  81. package/dist/pipeline/styles.js +1 -1
  82. package/dist/pipeline/validation.d.ts +1 -1
  83. package/dist/pipeline/validation.js +3 -3
  84. package/dist/providers.d.ts +10 -0
  85. package/dist/providers.js +13 -0
  86. package/dist/registry/asset-storage/define.d.ts +5 -5
  87. package/dist/registry/asset-storage/define.js +4 -4
  88. package/dist/registry/asset-storage/resolve.d.ts +9 -19
  89. package/dist/registry/asset-storage/resolve.js +33 -21
  90. package/dist/registry/asset-storage/types.d.ts +37 -17
  91. package/dist/registry/asset-storage/types.js +5 -5
  92. package/dist/registry/auth/ai-keys.d.ts +32 -0
  93. package/dist/registry/auth/ai-keys.js +69 -0
  94. package/dist/registry/auth/crypto.d.ts +17 -0
  95. package/dist/registry/auth/crypto.js +61 -1
  96. package/dist/registry/auth/index.d.ts +2 -1
  97. package/dist/registry/auth/index.js +11 -2
  98. package/dist/registry/checkout/index.d.ts +2 -1
  99. package/dist/registry/checkout/index.js +57 -127
  100. package/dist/registry/client.js +53 -17
  101. package/dist/registry/db/driver.js +3 -3
  102. package/dist/registry/db/schema.d.ts +261 -3
  103. package/dist/registry/db/schema.js +29 -4
  104. package/dist/registry/email/define.d.ts +10 -0
  105. package/dist/registry/email/define.js +13 -0
  106. package/dist/registry/email/providers/smtp.d.ts +10 -0
  107. package/dist/registry/email/providers/smtp.js +73 -0
  108. package/dist/registry/email/resolve.d.ts +28 -0
  109. package/dist/registry/email/resolve.js +74 -0
  110. package/dist/registry/email/types.d.ts +24 -0
  111. package/dist/registry/email/types.js +6 -0
  112. package/dist/registry/path.d.ts +2 -0
  113. package/dist/registry/path.js +11 -1
  114. package/dist/registry/publish/package.js +8 -0
  115. package/dist/registry/transfer.d.ts +4 -0
  116. package/dist/store/registry.d.ts +3 -3
  117. package/dist/store/registry.js +4 -4
  118. package/dist/transformers/plugins/ssr-render.js +40 -15
  119. package/dist/transformers/preview/component/builder.d.ts +1 -9
  120. package/dist/transformers/preview/component/builder.js +17 -17
  121. package/dist/transformers/preview/component/css.d.ts +3 -1
  122. package/dist/transformers/preview/component/css.js +92 -51
  123. package/dist/transformers/preview/component/html.d.ts +3 -1
  124. package/dist/transformers/preview/component/html.js +6 -2
  125. package/dist/transformers/preview/component/javascript.d.ts +2 -1
  126. package/dist/transformers/preview/component/javascript.js +10 -4
  127. package/dist/transformers/preview/pattern/html.js +3 -1
  128. package/dist/transformers/preview/types.d.ts +13 -0
  129. package/dist/transformers/utils/build.d.ts +24 -0
  130. package/dist/transformers/utils/build.js +100 -1
  131. package/dist/transformers/utils/module.js +2 -1
  132. package/dist/types/config.d.ts +135 -18
  133. package/dist/utils/path.d.ts +1 -5
  134. package/dist/utils/path.js +6 -23
  135. package/docs/api.md +6 -4
  136. package/docs/cli.md +275 -54
  137. package/drizzle/0007_add_ai_keys.sql +11 -0
  138. package/drizzle/0008_add_source_dir.sql +2 -0
  139. package/drizzle/meta/0007_snapshot.json +2013 -0
  140. package/drizzle/meta/0008_snapshot.json +2025 -0
  141. package/drizzle/meta/_journal.json +14 -0
  142. package/package.json +19 -8
  143. package/src/ai/README.md +42 -0
  144. package/src/ai/connections.ts +189 -0
  145. package/src/ai/define.ts +15 -0
  146. package/src/ai/types.ts +30 -0
  147. package/src/app/components/AiAssistant/AiAssistantPanel.tsx +144 -0
  148. package/src/app/components/AiAssistant/AiAssistantProvider.tsx +215 -0
  149. package/src/app/components/AiAssistant/AiConversation.tsx +737 -0
  150. package/src/app/components/AiAssistant/AiToolResult.tsx +99 -0
  151. package/src/app/components/AiAssistant/dockState.ts +86 -0
  152. package/src/app/components/AiAssistant/toolLabel.ts +24 -0
  153. package/src/app/components/AiAssistant/useAiChat.ts +147 -0
  154. package/src/app/components/AiAssistant/useAiConnections.ts +86 -0
  155. package/src/app/components/AiAssistant/usePageContext.ts +63 -0
  156. package/src/app/components/Auth/AccountLayout.tsx +58 -19
  157. package/src/app/components/Auth/AuthShell.tsx +27 -33
  158. package/src/app/components/Auth/RegistryInstallGate.tsx +4 -8
  159. package/src/app/components/Auth/installStatus.ts +29 -0
  160. package/src/app/components/Component/PageSliceResolver.tsx +1 -0
  161. package/src/app/components/Component/Preview.tsx +383 -189
  162. package/src/app/components/Component/slices/ComponentDisplaySlice.tsx +4 -0
  163. package/src/app/components/Foundations/ColorGrid.tsx +13 -14
  164. package/src/app/components/Layout/Header.tsx +21 -15
  165. package/src/app/components/Layout/Main.tsx +2 -2
  166. package/src/app/components/Markdown/MarkdownComponents.tsx +30 -32
  167. package/src/app/components/McpIntegration/McpConfigDialog.tsx +12 -51
  168. package/src/app/components/Navigation/MobileNav.tsx +2 -2
  169. package/src/app/components/Navigation/SideNav.tsx +1 -15
  170. package/src/app/components/context/NavProvider.tsx +7 -3
  171. package/src/app/components/ui/attachment.tsx +50 -0
  172. package/src/app/components/ui/bubble.tsx +57 -0
  173. package/src/app/components/ui/button.tsx +3 -1
  174. package/src/app/components/ui/confirm-dialog.tsx +87 -0
  175. package/src/app/components/ui/dialog.tsx +7 -2
  176. package/src/app/components/ui/marker.tsx +56 -0
  177. package/src/app/components/ui/sidebar.tsx +3 -1
  178. package/src/app/components/util/index.ts +3 -12
  179. package/src/app/css/index.css +64 -0
  180. package/src/app/lib/ai/auth.ts +33 -0
  181. package/src/app/lib/ai/context.ts +99 -0
  182. package/src/app/lib/ai/model.ts +79 -0
  183. package/src/app/lib/ai/prompt.ts +36 -0
  184. package/src/app/lib/ai/resolve.ts +97 -0
  185. package/src/app/lib/ai/tools.ts +120 -0
  186. package/src/app/lib/api/methods.ts +13 -0
  187. package/src/app/lib/asset-storage.ts +35 -36
  188. package/src/app/lib/auth/api.ts +4 -12
  189. package/src/app/lib/auth/email.ts +96 -23
  190. package/src/app/lib/docs-api/page-rendering.ts +1 -1
  191. package/src/app/lib/docs-api/registry.ts +2 -3
  192. package/src/app/lib/docs-api/runtime-config.ts +47 -21
  193. package/src/app/lib/mcp/components.ts +523 -0
  194. package/src/app/lib/mcp/page-url.ts +24 -0
  195. package/src/app/lib/mcp/pages.ts +84 -0
  196. package/src/app/lib/mcp/result.ts +33 -0
  197. package/src/app/lib/mcp/server.ts +39 -318
  198. package/src/app/lib/mcp/tokens.ts +334 -0
  199. package/src/app/lib/mcp/tool.ts +19 -0
  200. package/src/app/lib/registry-api/asset-transfer.ts +119 -11
  201. package/src/app/lib/registry-api/errors.ts +2 -0
  202. package/src/app/lib/registry-api/handler.ts +7 -1
  203. package/src/app/lib/registry-api/transfer.ts +28 -6
  204. package/src/app/lib/server-module.ts +34 -0
  205. package/src/app/lib/utils.ts +20 -0
  206. package/src/app/next.config.mjs +26 -24
  207. package/src/app/pages/[...slug]/index.tsx +2 -1
  208. package/src/app/pages/_app.tsx +6 -2
  209. package/src/app/pages/_document.tsx +6 -0
  210. package/src/app/pages/account/ai.tsx +183 -0
  211. package/src/app/pages/account/index.tsx +68 -68
  212. package/src/app/pages/account/tokens.tsx +37 -7
  213. package/src/app/pages/account/users.tsx +75 -15
  214. package/src/app/pages/api/account/ai/keys/[connectionId].ts +60 -0
  215. package/src/app/pages/api/account/ai/keys/index.ts +20 -0
  216. package/src/app/pages/api/admin/users/[id]/[action].ts +80 -11
  217. package/src/app/pages/api/admin/users/index.ts +2 -2
  218. package/src/app/pages/api/ai/chat.ts +115 -0
  219. package/src/app/pages/api/ai/connections.ts +23 -0
  220. package/src/app/pages/api/auth/request-reset.ts +14 -16
  221. package/src/app/pages/api/docs/nav.json.ts +1 -1
  222. package/src/app/pages/api/install.ts +3 -3
  223. package/src/app/pages/api/mcp.ts +6 -0
  224. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/complete.ts +11 -0
  225. package/src/app/pages/api/registry/transfer/assets/blobs/[hash]/upload.ts +12 -0
  226. package/src/app/pages/api/registry/transfer/assets/blobs/[hash].ts +1 -1
  227. package/src/app/pages/cli/device.tsx +10 -5
  228. package/src/app/pages/foundations/colors.tsx +9 -8
  229. package/src/app/pages/foundations/index.tsx +3 -3
  230. package/src/app/pages/index.tsx +2 -2
  231. package/src/app/pages/install.tsx +7 -2
  232. package/src/app/pages/login.tsx +32 -27
  233. package/src/app/pages/reset-password.tsx +21 -20
  234. package/src/app/pages/system/component/[component]/index.tsx +2 -5
  235. package/src/app/pages/system/index.tsx +11 -11
  236. package/src/app/pages/system/pattern/[pattern]/index.tsx +2 -2
  237. package/src/app/pages/system/tokens/components/[component]/index.tsx +0 -4
  238. package/src/app-builder/README.md +2 -1
  239. package/src/app-builder/build.ts +218 -73
  240. package/src/app-builder/client-config.ts +3 -2
  241. package/src/app-builder/paths.ts +3 -22
  242. package/src/app-builder/static-export.ts +1 -1
  243. package/src/app-builder/vercel-output.ts +5 -4
  244. package/src/cache/README.md +4 -1
  245. package/src/cache/build-cache.ts +128 -43
  246. package/src/cache/build-inputs.ts +28 -0
  247. package/src/cache/index.ts +4 -0
  248. package/src/catalog/README.md +3 -0
  249. package/src/catalog/normalize.ts +2 -0
  250. package/src/catalog/previews.ts +6 -3
  251. package/src/catalog/types.ts +8 -0
  252. package/src/cli/create.ts +1 -2
  253. package/src/commands/build/index.ts +1 -1
  254. package/src/config/README.md +10 -9
  255. package/src/config/defaults.ts +7 -4
  256. package/src/config/from-env.ts +33 -0
  257. package/src/config/helpers.ts +1 -3
  258. package/src/config/index.ts +0 -3
  259. package/src/config/loader.ts +27 -3
  260. package/src/config/resolve-env.ts +28 -15
  261. package/src/config/runtime.ts +10 -5
  262. package/src/handlebars.ts +8 -2
  263. package/src/index.ts +33 -29
  264. package/src/nav/index.ts +2 -2
  265. package/src/nav/registry.ts +3 -3
  266. package/src/nav/workspace.ts +3 -3
  267. package/src/pipeline/README.md +1 -1
  268. package/src/pipeline/figma.ts +98 -49
  269. package/src/pipeline/styles.ts +1 -1
  270. package/src/pipeline/validation.ts +3 -3
  271. package/src/providers.ts +18 -0
  272. package/src/registry/asset-storage/define.ts +6 -6
  273. package/src/registry/asset-storage/resolve.ts +38 -37
  274. package/src/registry/asset-storage/types.ts +39 -17
  275. package/src/registry/auth/ai-keys.ts +62 -0
  276. package/src/registry/auth/crypto.ts +63 -1
  277. package/src/registry/auth/index.ts +11 -1
  278. package/src/registry/checkout/index.ts +60 -138
  279. package/src/registry/client.ts +60 -20
  280. package/src/registry/db/driver.ts +2 -2
  281. package/src/registry/db/schema.ts +49 -22
  282. package/src/registry/email/define.ts +15 -0
  283. package/src/registry/email/providers/smtp.ts +39 -0
  284. package/src/registry/email/resolve.ts +94 -0
  285. package/src/registry/email/types.ts +28 -0
  286. package/src/registry/path.ts +11 -0
  287. package/src/registry/publish/package.ts +9 -0
  288. package/src/registry/transfer.ts +4 -0
  289. package/src/store/registry.ts +7 -7
  290. package/src/transformers/plugins/ssr-render.ts +53 -16
  291. package/src/transformers/preview/component/builder.ts +19 -25
  292. package/src/transformers/preview/component/css.ts +101 -53
  293. package/src/transformers/preview/component/html.ts +6 -2
  294. package/src/transformers/preview/component/javascript.ts +14 -4
  295. package/src/transformers/preview/pattern/html.ts +3 -1
  296. package/src/transformers/preview/types.ts +14 -0
  297. package/src/transformers/utils/build.ts +93 -0
  298. package/src/transformers/utils/module.ts +4 -2
  299. package/src/types/config.ts +148 -20
  300. package/src/utils/README.md +1 -1
  301. package/src/utils/path.ts +6 -29
  302. package/templates/default/handoff.config.js.tpl +10 -2
  303. package/templates/default/handoff.config.ts.tpl +10 -2
  304. package/templates/default/handoff.config.with-examples.js.tpl +10 -2
  305. package/templates/default/handoff.config.with-examples.ts.tpl +10 -2
  306. package/templates/default/package.json.tpl +2 -2
  307. package/.editorconfig +0 -19
  308. package/.eslintrc.json +0 -4
  309. package/.prettierignore +0 -0
  310. package/.prettierrc.json +0 -8
  311. package/.vscode/settings.json +0 -17
  312. package/dist/config/validator.d.ts +0 -9
  313. package/dist/config/validator.js +0 -24
  314. package/dist/registry/asset-storage/adapters/vercel-blob.d.ts +0 -17
  315. package/dist/registry/asset-storage/adapters/vercel-blob.js +0 -87
  316. package/src/app/components/old/Header.tsx +0 -89
  317. package/src/app/lib/mcp/shape.ts +0 -596
  318. package/src/app/pages/assets/fonts.tsx +0 -129
  319. package/src/app/pages/assets/index.tsx +0 -91
  320. package/src/app/pages/assets/logos.tsx +0 -75
  321. package/src/config/validator.ts +0 -22
  322. package/src/registry/asset-storage/adapters/vercel-blob.ts +0 -51
@@ -50,6 +50,20 @@
50
50
  "when": 1788980586467,
51
51
  "tag": "0006_add_component_source_format",
52
52
  "breakpoints": true
53
+ },
54
+ {
55
+ "idx": 7,
56
+ "version": "7",
57
+ "when": 1789827999294,
58
+ "tag": "0007_add_ai_keys",
59
+ "breakpoints": true
60
+ },
61
+ {
62
+ "idx": 8,
63
+ "version": "7",
64
+ "when": 1790876591116,
65
+ "tag": "0008_add_source_dir",
66
+ "breakpoints": true
53
67
  }
54
68
  ]
55
69
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "handoff-app",
3
- "version": "2.0.0-8",
3
+ "version": "2.0.0",
4
4
  "description": "Automated documentation toolchain for building client side documentation from figma",
5
5
  "author ": {
6
6
  "name": "Convertiv",
@@ -40,6 +40,10 @@
40
40
  "types": "./dist/pattern.d.ts",
41
41
  "default": "./dist/pattern.js"
42
42
  },
43
+ "./providers": {
44
+ "types": "./dist/providers.d.ts",
45
+ "default": "./dist/providers.js"
46
+ },
43
47
  "./package.json": "./package.json",
44
48
  "./*": "./*"
45
49
  },
@@ -53,6 +57,9 @@
53
57
  ],
54
58
  "pattern": [
55
59
  "./dist/pattern.d.ts"
60
+ ],
61
+ "providers": [
62
+ "./dist/providers.d.ts"
56
63
  ]
57
64
  }
58
65
  },
@@ -60,8 +67,8 @@
60
67
  "handoff-app": "./dist/cli.js"
61
68
  },
62
69
  "engines": {
63
- "npm": ">=8.0.0",
64
- "node": ">=16.0.0"
70
+ "npm": ">=10",
71
+ "node": ">=22"
65
72
  },
66
73
  "scripts": {
67
74
  "start": "node ./dist/cli.js start",
@@ -82,9 +89,11 @@
82
89
  "plugins": [
83
90
  "prettier-plugin-tailwindcss"
84
91
  ],
85
- "tailwindConfig": "./tailwind.config.ts"
92
+ "tailwindStylesheet": "./src/app/css/index.css"
86
93
  },
87
94
  "dependencies": {
95
+ "@ai-sdk/openai-compatible": "^3.0.53",
96
+ "@ai-sdk/react": "^4.0.110",
88
97
  "@clack/prompts": "^0.7.0",
89
98
  "@modelcontextprotocol/sdk": "^1.30.0",
90
99
  "@neondatabase/serverless": "^1.1.0",
@@ -107,14 +116,16 @@
107
116
  "@tailwindcss/postcss": "^4.1.14",
108
117
  "@tailwindcss/typography": "^0.5.16",
109
118
  "@types/lodash": "^4.14.185",
119
+ "@types/nodemailer": "^8.0.2",
110
120
  "@types/react": "^19.1.0",
111
121
  "@types/react-dom": "^19.1.0",
112
122
  "@types/react-scroll": "^1.8.6",
113
123
  "@types/react-syntax-highlighter": "^15.5.7",
114
124
  "@types/refractor": "^3.0.2",
115
125
  "@types/ws": "^8.18.1",
116
- "@vercel/blob": "^1.1.1",
126
+ "@vercel/nft": "^1.11.0",
117
127
  "@vitejs/plugin-react": "^4.5.2",
128
+ "ai": "^7.0.107",
118
129
  "archiver": "^5.3.1",
119
130
  "chalk": "^4.1.2",
120
131
  "chokidar": "^3.5.3",
@@ -136,6 +147,7 @@
136
147
  "next-auth": "^4.24.15",
137
148
  "next-themes": "^0.4.4",
138
149
  "node-html-parser": "^6.1.4",
150
+ "nodemailer": "^7.0.13",
139
151
  "pg": "^8.22.0",
140
152
  "postcss": "^8.4.16",
141
153
  "prettier": "^3.4.2",
@@ -155,8 +167,7 @@
155
167
  "vaul": "^1.1.2",
156
168
  "vite": "^6.3.5",
157
169
  "ws": "^8.18.3",
158
- "yargs": "^17.7.2",
159
- "zod": "^4.4.3"
170
+ "yargs": "^17.7.2"
160
171
  },
161
172
  "peerDependencies": {
162
173
  "typescript": ">=5.6 <7"
@@ -166,7 +177,7 @@
166
177
  "@types/cross-spawn": "^6.0.2",
167
178
  "@types/fs-extra": "^11.0.4",
168
179
  "@types/handlebars": "^4.1.0",
169
- "@types/node": "^20.11.18",
180
+ "@types/node": "^22.0.0",
170
181
  "@types/pg": "^8.20.0",
171
182
  "@types/yargs": "^17.0.33",
172
183
  "drizzle-kit": "^0.31.10",
@@ -0,0 +1,42 @@
1
+ # AI Module
2
+
3
+ The connection contract behind the docs assistant. `runtime.ai.connections` declares every endpoint
4
+ and model a reader can reach. A reader supplies a key for a declared connection and nothing else, so
5
+ the server never calls a URL a reader chose.
6
+
7
+ Every connection speaks the OpenAI-compatible `/chat/completions` API. A connection that fits
8
+ nothing else names a `defineAiProvider()` module instead of a base URL.
9
+
10
+ Only provider selection, module location, option literals, and env-var _names_ are ever
11
+ persisted. Secret _values_ are read from `process.env` at request time.
12
+
13
+ ## Files
14
+
15
+ | File | Purpose |
16
+ | ---------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------- |
17
+ | `connections.ts` | `AiConnectionSettings`/`AiSettings`, `resolveAiFromConfig()`, `parseAiConnections()`, `mergeAiConnections()`, `aiCredentialKind()`, model-id parse/format |
18
+ | `define.ts` | `defineAiProvider()` — typed identity helper for a custom, server-only provider module |
19
+ | `types.ts` | `AiProvider`, `AiProviderContext`, `AiProviderFactory` — the pluggable provider contract |
20
+
21
+ ## Credential states
22
+
23
+ A connection declares how it is paid for, and this is never inferred from a missing key. Without the
24
+ explicit third state, a reader gets a raw 401 from the provider instead of an instruction.
25
+
26
+ | State | Declaration | Meaning |
27
+ | ------------- | ------------------------- | --------------------------------------------------------- |
28
+ | Service key | `apiKey: fromEnv('NAME')` | The deployment pays. Every reader can use it. |
29
+ | No credential | neither field | The endpoint needs none, such as a local Ollama. |
30
+ | User key | `credential: 'user'` | Each reader adds their own key for the declared endpoint. |
31
+
32
+ ## Resolution order
33
+
34
+ 1. The baked `runtime.ai.connections` list (`HANDOFF_AI_BAKED_CONNECTIONS`).
35
+ 2. `HANDOFF_AI_CONNECTIONS`, a JSON array read at request time, merged over the baked list by `id`.
36
+
37
+ A reader's saved keys are not a third layer. They attach to a connection produced by these two
38
+ steps, and cannot create, rename or redirect one.
39
+
40
+ `runtime.ai.enabled` is config-only and baked, like `runtime.mcp`, so it decides whether the route
41
+ and the header control exist at all. The connection list is not baked, so a deployment can change it
42
+ without a rebuild.
@@ -0,0 +1,189 @@
1
+ /**
2
+ * AI connection resolution.
3
+ *
4
+ * `runtime.ai.connections` declares the whole surface a reader can reach. Mirrors
5
+ * {@link import('../registry/asset-storage/resolve')}: only provider selection, module location,
6
+ * non-secret options, and env-var *names* are ever persisted; secret *values* are read from
7
+ * `process.env` at request time.
8
+ *
9
+ * The list is deployment data rather than build shape, so `HANDOFF_AI_CONNECTIONS` can extend or
10
+ * replace the baked list at request time. A registry is built once and redeployed many times, and a
11
+ * new gateway should not need a rebuild.
12
+ */
13
+
14
+ import { HandoffConfigError } from '../config/errors';
15
+ import type { AiCredentialKind, HandoffAiConnection, ResolvedConfig } from '../types/config';
16
+
17
+ /** A connection as it is baked and read back: env-var names, never secret values. */
18
+ export interface AiConnectionSettings {
19
+ id: string;
20
+ label: string;
21
+ baseUrl?: string;
22
+ module?: string;
23
+ /** Name of the env var holding the deployment's own key. Its presence marks a service connection. */
24
+ apiKeyEnv?: string;
25
+ credential?: 'user';
26
+ models?: string[];
27
+ options?: Record<string, unknown>;
28
+ }
29
+
30
+ /** The resolved AI block: whether this build serves the assistant, and what it can reach. */
31
+ export interface AiSettings {
32
+ enabled: boolean;
33
+ connections: AiConnectionSettings[];
34
+ defaultModel?: string;
35
+ }
36
+
37
+ /** A connection id addresses a model as `<id>/<model>`, so it cannot itself contain a slash. */
38
+ const isUsableId = (id: unknown): id is string => typeof id === 'string' && id.trim().length > 0 && !id.includes('/');
39
+
40
+ /**
41
+ * Which of the three credential states a connection declares. Derived from the declaration, never
42
+ * from a missing key: without the explicit `user` state, a connection that needs a reader's key
43
+ * would be indistinguishable from one that needs none.
44
+ */
45
+ export const aiCredentialKind = (connection: AiConnectionSettings): AiCredentialKind => {
46
+ if (connection.credential === 'user') return 'user';
47
+ return connection.apiKeyEnv ? 'service' : 'none';
48
+ };
49
+
50
+ /** Split `<connectionId>/<model>`. Only the first slash separates them, since model ids carry slashes. */
51
+ export const parseAiModelId = (value: string): { connectionId: string; model: string } | null => {
52
+ const separator = value.indexOf('/');
53
+ if (separator <= 0 || separator === value.length - 1) return null;
54
+ return { connectionId: value.slice(0, separator), model: value.slice(separator + 1) };
55
+ };
56
+
57
+ /** Join a connection id and a model into the address the client sends back. */
58
+ export const formatAiModelId = (connectionId: string, model: string): string => `${connectionId}/${model}`;
59
+
60
+ /** Normalize one authored or deployment-supplied connection, dropping anything unusable. */
61
+ const toConnectionSettings = (input: unknown): AiConnectionSettings | null => {
62
+ if (!input || typeof input !== 'object' || Array.isArray(input)) return null;
63
+ const raw = input as Record<string, unknown>;
64
+ if (!isUsableId(raw.id)) return null;
65
+ const baseUrl = typeof raw.baseUrl === 'string' ? raw.baseUrl.trim() : undefined;
66
+ const module = typeof raw.module === 'string' ? raw.module.trim() : undefined;
67
+ if (!baseUrl && !module) return null;
68
+ // An authored config carries `apiKey` as a deferred `{ $env }` reference; a baked or
69
+ // deployment-supplied list carries the name it resolved to.
70
+ const reference = raw.apiKey as { $env?: unknown } | undefined;
71
+ const apiKeyEnv =
72
+ typeof raw.apiKeyEnv === 'string' && raw.apiKeyEnv.trim()
73
+ ? raw.apiKeyEnv.trim()
74
+ : typeof reference?.$env === 'string'
75
+ ? reference.$env
76
+ : undefined;
77
+ // A repeated model would be two picker entries for one choice, so the first occurrence wins.
78
+ const models = Array.isArray(raw.models)
79
+ ? [...new Set(raw.models.filter((model): model is string => typeof model === 'string' && !!model.trim()).map((model) => model.trim()))]
80
+ : [];
81
+ const options =
82
+ raw.options && typeof raw.options === 'object' && !Array.isArray(raw.options) ? (raw.options as Record<string, unknown>) : undefined;
83
+
84
+ return {
85
+ id: raw.id.trim(),
86
+ label: typeof raw.label === 'string' && raw.label.trim() ? raw.label.trim() : raw.id.trim(),
87
+ ...(baseUrl ? { baseUrl } : {}),
88
+ ...(module ? { module } : {}),
89
+ ...(apiKeyEnv ? { apiKeyEnv } : {}),
90
+ ...(raw.credential === 'user' ? { credential: 'user' as const } : {}),
91
+ ...(models.length ? { models } : {}),
92
+ ...(options ? { options } : {}),
93
+ };
94
+ };
95
+
96
+ /** Normalize a declared list, keeping the first entry for any repeated id. */
97
+ const toConnectionList = (input: readonly unknown[] | undefined): AiConnectionSettings[] => {
98
+ const byId = new Map<string, AiConnectionSettings>();
99
+ for (const entry of input ?? []) {
100
+ const connection = toConnectionSettings(entry);
101
+ if (connection && !byId.has(connection.id)) byId.set(connection.id, connection);
102
+ }
103
+ return [...byId.values()];
104
+ };
105
+
106
+ const ignoredOverrides = new Set<string>();
107
+
108
+ /**
109
+ * Merge a deployment-supplied list over the baked one by `id`: a matching id replaces the baked
110
+ * connection outright, and a new id is appended. Replacement rather than a field-wise merge, so a
111
+ * deployment that repoints a gateway cannot leave a stale model list or key name behind.
112
+ *
113
+ * A deployment-supplied connection can use only a `module` that the build includes.
114
+ */
115
+ export const mergeAiConnections = (
116
+ baked: readonly AiConnectionSettings[],
117
+ overrides: readonly AiConnectionSettings[]
118
+ ): AiConnectionSettings[] => {
119
+ const byId = new Map(baked.map((connection) => [connection.id, connection]));
120
+ const bakedModules = new Set(baked.map((connection) => connection.module).filter(Boolean));
121
+ for (const connection of overrides) {
122
+ if (connection.module && !bakedModules.has(connection.module)) {
123
+ if (!ignoredOverrides.has(connection.id)) {
124
+ ignoredOverrides.add(connection.id);
125
+ console.warn(
126
+ `HANDOFF_AI_CONNECTIONS: connection "${connection.id}" is ignored, because the build does not include its module ` +
127
+ `"${connection.module}". Declare the connection in runtime.ai.connections.`
128
+ );
129
+ }
130
+ continue;
131
+ }
132
+ byId.set(connection.id, connection);
133
+ }
134
+ return [...byId.values()];
135
+ };
136
+
137
+ /** Parse the `HANDOFF_AI_CONNECTIONS` JSON array. A malformed value is ignored rather than fatal. */
138
+ export const parseAiConnections = (raw: string | undefined): AiConnectionSettings[] => {
139
+ if (!raw?.trim()) return [];
140
+ try {
141
+ const parsed = JSON.parse(raw);
142
+ return Array.isArray(parsed) ? toConnectionList(parsed) : [];
143
+ } catch {
144
+ return [];
145
+ }
146
+ };
147
+
148
+ /**
149
+ * Whether this build serves the assistant. The `ai` block is the opt-in; `enabled: false` turns it
150
+ * back off. Config-only and baked, like `runtime.mcp`.
151
+ */
152
+ export const isAiEnabled = (config: Pick<ResolvedConfig, 'runtime'> | null | undefined): boolean =>
153
+ Boolean(config?.runtime?.ai) && config?.runtime?.ai?.enabled !== false;
154
+
155
+ /**
156
+ * Resolve the AI settings from a loaded config (CLI/build side).
157
+ *
158
+ * An authored connection that cannot be used fails the build rather than disappearing. A deployment
159
+ * list stays lenient by contrast: a bad `HANDOFF_AI_CONNECTIONS` entry must not take the server down.
160
+ */
161
+ export const resolveAiFromConfig = (config: ResolvedConfig | null | undefined): AiSettings => {
162
+ const authored = (config?.runtime?.ai?.connections ?? []) as readonly HandoffAiConnection[];
163
+ const fail = (problem: string, ids: readonly unknown[]): never => {
164
+ throw new HandoffConfigError(
165
+ `Config "runtime.ai.connections": ${problem} (${ids.map((id) => JSON.stringify(id ?? null)).join(', ')}).`
166
+ );
167
+ };
168
+
169
+ const normalized = authored.map((connection) => ({ authored: connection, settings: toConnectionSettings(connection) }));
170
+ const unusable = normalized.filter((entry) => !entry.settings);
171
+ if (unusable.length > 0) {
172
+ fail(
173
+ 'every connection needs an "id" without a slash, and either a "baseUrl" or a "module"',
174
+ unusable.map((entry) => entry.authored?.id)
175
+ );
176
+ }
177
+
178
+ const ids = normalized.map((entry) => entry.settings!.id);
179
+ const duplicates = ids.filter((id, index) => ids.indexOf(id) !== index);
180
+ if (duplicates.length > 0) {
181
+ fail('a connection id must be unique, because it is half of every model address', duplicates);
182
+ }
183
+
184
+ return {
185
+ enabled: isAiEnabled(config),
186
+ connections: normalized.map((entry) => entry.settings!),
187
+ defaultModel: config?.runtime?.ai?.defaultModel?.trim() || undefined,
188
+ };
189
+ };
@@ -0,0 +1,15 @@
1
+ /**
2
+ * Typed identity helper for custom AI providers (mirrors `defineStorageProvider`): a consumer
3
+ * default-exports its result from a **server-only** module and points a connection's `module` at
4
+ * it. It must be its own module (not an inline `defineConfig` function) so the build can trace it
5
+ * into the deployed registry. The default export may be the provider object, or a factory
6
+ * `(context) => AiProvider` when it needs its options, env or the resolved key.
7
+ */
8
+
9
+ import type { AiProvider, AiProviderFactory } from './types';
10
+
11
+ export function defineAiProvider(provider: AiProvider): AiProvider;
12
+ export function defineAiProvider(factory: AiProviderFactory): AiProviderFactory;
13
+ export function defineAiProvider(input: AiProvider | AiProviderFactory): AiProvider | AiProviderFactory {
14
+ return input;
15
+ }
@@ -0,0 +1,30 @@
1
+ /**
2
+ * Server-only AI provider contract for connections that are not OpenAI-compatible.
3
+ *
4
+ * Every built-in connection speaks the OpenAI-compatible `/chat/completions` API and needs no
5
+ * custom module — Ollama, LiteLLM, OpenRouter, vLLM, LM Studio and Azure OpenAI all serve it, and each of
6
+ * them fronts Anthropic, Google and xAI models. A provider that fits nothing else names a
7
+ * {@link defineAiProvider} module instead of a base URL.
8
+ */
9
+
10
+ import type { LanguageModel } from 'ai';
11
+
12
+ /** Context passed to a custom provider factory. */
13
+ export interface AiProviderContext {
14
+ /** The connection's `options`, with each `fromEnv()` reference replaced by its value. */
15
+ options: Record<string, unknown>;
16
+ env: NodeJS.ProcessEnv;
17
+ /**
18
+ * The resolved key for this request: the deployment's own key for a service connection, or the
19
+ * reader's saved key for a `credential: 'user'` connection. Undefined when neither applies.
20
+ */
21
+ apiKey?: string;
22
+ }
23
+
24
+ /** The pluggable provider contract: resolve one of the connection's declared models. */
25
+ export interface AiProvider {
26
+ languageModel(modelId: string): LanguageModel;
27
+ }
28
+
29
+ /** A custom provider may export the provider directly or a factory that builds it from its context. */
30
+ export type AiProviderFactory = (context: AiProviderContext) => AiProvider | Promise<AiProvider>;
@@ -0,0 +1,144 @@
1
+ 'use client';
2
+
3
+ import { X } from 'lucide-react';
4
+ import * as React from 'react';
5
+
6
+ import { cn } from '../../lib/utils';
7
+ import { Button } from '../ui/button';
8
+ import { Sheet, SheetContent, SheetDescription, SheetTitle } from '../ui/sheet';
9
+ import { AiConversation } from './AiConversation';
10
+ import { DOCK_WIDTH_VAR, fitWidth, MAX_WIDTH, MIN_WIDTH, type DockState } from './dockState';
11
+
12
+ /**
13
+ * Where the conversation sits: a column pinned to the right of the page, not a modal, so the reader
14
+ * can browse the docs and read the answer at once. A narrow viewport has no room for two columns,
15
+ * so the same conversation moves into a sheet.
16
+ *
17
+ * Nothing here owns the conversation. {@link useAiChat} does, so none of these moves can discard it.
18
+ */
19
+
20
+ const RESIZE_STEP = 16;
21
+
22
+ const IconButton: React.FC<{ label: string; onClick: () => void; children: React.ReactNode }> = ({ label, onClick, children }) => (
23
+ <Button type="button" variant="ghost" size="icon-sm" onClick={onClick} title={label} aria-label={label} className="text-muted-foreground">
24
+ {children}
25
+ </Button>
26
+ );
27
+
28
+ /**
29
+ * A drag writes the width straight to `--ai-dock-width` and reports it to React only on release: a
30
+ * state change per pointer move would re-render the whole page under the dock once a frame. The
31
+ * `data-ai-dock-resizing` flag drops the transition for the drag, so the edge tracks the pointer.
32
+ *
33
+ * The dock is anchored right, so the width is the pointer's distance from that edge.
34
+ */
35
+ const ResizeHandle: React.FC<{ width: number; onCommit: (width: number) => void }> = ({ width, onCommit }) => {
36
+ const onPointerDown = (event: React.PointerEvent<HTMLDivElement>) => {
37
+ event.preventDefault();
38
+ const root = document.documentElement;
39
+ let next = width;
40
+
41
+ const onMove = (move: PointerEvent) => {
42
+ next = fitWidth(window.innerWidth - move.clientX);
43
+ root.style.setProperty(DOCK_WIDTH_VAR, `${next}px`);
44
+ };
45
+ const onUp = () => {
46
+ delete root.dataset.aiDockResizing;
47
+ window.removeEventListener('pointermove', onMove);
48
+ window.removeEventListener('pointerup', onUp);
49
+ onCommit(next);
50
+ };
51
+
52
+ root.dataset.aiDockResizing = 'true';
53
+ window.addEventListener('pointermove', onMove);
54
+ window.addEventListener('pointerup', onUp);
55
+ };
56
+
57
+ return (
58
+ <div
59
+ role="separator"
60
+ aria-orientation="vertical"
61
+ aria-label="Resize the assistant"
62
+ aria-valuemin={MIN_WIDTH}
63
+ aria-valuemax={MAX_WIDTH}
64
+ aria-valuenow={width}
65
+ tabIndex={0}
66
+ onPointerDown={onPointerDown}
67
+ onKeyDown={(event) => {
68
+ if (event.key === 'ArrowLeft') onCommit(fitWidth(width + RESIZE_STEP));
69
+ else if (event.key === 'ArrowRight') onCommit(fitWidth(width - RESIZE_STEP));
70
+ else return;
71
+ event.preventDefault();
72
+ }}
73
+ // Inside the panel: the dock clips its overflow, so a handle across the border loses half its
74
+ // hit area.
75
+ className="outline-hidden absolute inset-y-0 left-0 z-10 w-1.5 cursor-col-resize transition-colors hover:bg-border focus-visible:bg-ring"
76
+ />
77
+ );
78
+ };
79
+
80
+ export const AiAssistantPanel: React.FC<{
81
+ state: DockState;
82
+ isMobile: boolean;
83
+ onChange: (next: Partial<DockState>) => void;
84
+ }> = ({ state, isMobile, onChange }) => {
85
+ // Keep the scroll position and an unsent draft across a close, without costing a reader who never
86
+ // opens the assistant anything.
87
+ const [everOpened, setEverOpened] = React.useState(state.open);
88
+ const close = () => onChange({ open: false });
89
+
90
+ React.useEffect(() => {
91
+ if (state.open) setEverOpened(true);
92
+ }, [state.open]);
93
+
94
+ if (isMobile) {
95
+ return (
96
+ <Sheet open={state.open} onOpenChange={(open) => onChange({ open })}>
97
+ <SheetContent side="right" className="w-full gap-0 p-0 sm:max-w-md [&>button:first-of-type]:hidden">
98
+ <SheetTitle className="sr-only">Ask the design system</SheetTitle>
99
+ <SheetDescription className="sr-only">
100
+ Answers come from the components, tokens and documentation of this design system.
101
+ </SheetDescription>
102
+ <AiConversation
103
+ active={state.open}
104
+ controls={
105
+ <IconButton label="Close the assistant" onClick={close}>
106
+ <X className="h-4 w-4" />
107
+ </IconButton>
108
+ }
109
+ />
110
+ </SheetContent>
111
+ </Sheet>
112
+ );
113
+ }
114
+
115
+ return (
116
+ <aside
117
+ aria-label="Design system assistant"
118
+ // The closed dock stays mounted at zero width, so `inert` has to keep it out of the tab order
119
+ // and the accessibility tree.
120
+ inert={!state.open}
121
+ style={{ width: `var(${DOCK_WIDTH_VAR}, 0px)` }}
122
+ className={cn(
123
+ // Below the app header, which reaches across the dock's column through `.ai-dock-bleed`.
124
+ 'ai-dock top-17 fixed bottom-0 right-0 z-40 flex flex-col overflow-hidden bg-background',
125
+ state.open && 'border-l'
126
+ )}
127
+ >
128
+ {/* Kept mounted after the first open, so closing preserves the scroll position and an unsent draft. */}
129
+ {everOpened && (
130
+ <div className="absolute inset-y-0 right-0 flex min-h-0 flex-col" style={{ width: `${state.width}px` }}>
131
+ <AiConversation
132
+ active={state.open}
133
+ controls={
134
+ <IconButton label="Close the assistant" onClick={close}>
135
+ <X className="h-4 w-4" />
136
+ </IconButton>
137
+ }
138
+ />
139
+ </div>
140
+ )}
141
+ {state.open && <ResizeHandle width={state.width} onCommit={(width) => onChange({ width })} />}
142
+ </aside>
143
+ );
144
+ };