@kitn.ai/ui 0.21.0 → 0.22.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 (243) hide show
  1. package/dist/components/chat-thread.d.ts +9 -0
  2. package/dist/{create-tween-D3Y5qiij.js → create-tween-COnPwAQN.js} +1 -1
  3. package/dist/{create-tween-DGKY1FDY.js → create-tween-DmTUaXWE.js} +1 -1
  4. package/dist/{create-tween-paho5Rh-.js → create-tween-qs-jjvNA.js} +1 -1
  5. package/dist/custom-elements.json +43 -7
  6. package/dist/elements/agent-card.js +1 -1
  7. package/dist/elements/artifact.js +1 -1
  8. package/dist/elements/attachments.js +1 -1
  9. package/dist/elements/audio-visualizer.js +1 -1
  10. package/dist/elements/avatar.js +1 -1
  11. package/dist/elements/badge.js +1 -1
  12. package/dist/elements/button.js +1 -1
  13. package/dist/elements/card.js +1 -1
  14. package/dist/elements/cards.js +1 -1
  15. package/dist/elements/chain-of-thought.js +1 -1
  16. package/dist/elements/chat-scope-picker.js +1 -1
  17. package/dist/elements/chat-workspace.js +1 -1
  18. package/dist/elements/chat.js +1 -1
  19. package/dist/elements/checkpoint.js +1 -1
  20. package/dist/elements/choice.js +1 -1
  21. package/dist/elements/chunks/{Icon-C-2LvGDG.js → Icon-CHG5z5L7.js} +1 -1
  22. package/dist/elements/chunks/{action-icons-CaYoImEk.js → action-icons-BAtCEEgg.js} +1 -1
  23. package/dist/elements/chunks/arrow-left-CFPTeL1Y.js +1 -0
  24. package/dist/elements/chunks/{artifact-BBIscOxJ.js → artifact-fBZOE4SO.js} +1 -1
  25. package/dist/elements/chunks/{attachments-BB56zoji.js → attachments-DpYMe5VQ.js} +1 -1
  26. package/dist/elements/chunks/{audio-visualizer-BxOx4THz.js → audio-visualizer-DFwzAcuL.js} +1 -1
  27. package/dist/elements/chunks/{badge-SxLSZ19R.js → badge-D6zodm6E.js} +1 -1
  28. package/dist/elements/chunks/{button-CmkUkz6c.js → button-C42vrGvO.js} +1 -1
  29. package/dist/elements/chunks/{card-renderer-Db4ffkWv.js → card-renderer-DMZsSuvU.js} +1 -1
  30. package/dist/elements/chunks/{chat-thread-B3Ks-cKr.js → chat-thread-CNNEhol-.js} +1 -1
  31. package/dist/elements/chunks/check-7Q3K-iXE.js +1 -0
  32. package/dist/elements/chunks/chevron-down-CpNYLn7E.js +1 -0
  33. package/dist/elements/chunks/chevron-right-DrH85Fha.js +1 -0
  34. package/dist/elements/chunks/{choice-card-Q9cYYDyM.js → choice-card-YoWjpsFV.js} +1 -1
  35. package/dist/elements/chunks/circle-C9KRjKYv.js +1 -0
  36. package/dist/elements/chunks/circle-check-Dmi7Y4zw.js +1 -0
  37. package/dist/elements/chunks/{circle-x-D0MEfxOM.js → circle-x-QIxXda6k.js} +1 -1
  38. package/dist/elements/chunks/{code-block-C8VzAraE.js → code-block-CqeugReY.js} +1 -1
  39. package/dist/elements/chunks/{collapsible-Cvj61obE.js → collapsible-sTv-_5lF.js} +1 -1
  40. package/dist/elements/chunks/{composer-D86ztyL0.js → composer-ldQyeVEB.js} +1 -1
  41. package/dist/elements/chunks/{confirm-card-BdyDAGLw.js → confirm-card-C0RNOwfn.js} +1 -1
  42. package/dist/elements/chunks/context-5qgypNHC.js +1 -0
  43. package/dist/elements/chunks/conversation-list-DLzOacXz.js +1 -0
  44. package/dist/elements/chunks/{create-tween-C_A_B638.js → create-tween-V4TXZ93X.js} +1 -1
  45. package/dist/elements/chunks/default-input-DeeRSDHc.js +1 -0
  46. package/dist/elements/chunks/{define-BzPnNsXg.js → define-lE-QvQFn.js} +1 -1
  47. package/dist/elements/chunks/{disclosure-DVKsoc1F.js → disclosure-DZSa9jog.js} +1 -1
  48. package/dist/elements/chunks/{download-BjA54IMJ.js → download-B2ZmnGqW.js} +1 -1
  49. package/dist/elements/chunks/{dropdown-BjVVHWXH.js → dropdown-R3B7cSRE.js} +1 -1
  50. package/dist/elements/chunks/{ellipsis-BNedmqfi.js → ellipsis-BmxceY4l.js} +1 -1
  51. package/dist/elements/chunks/{embed-DQsl4XEq.js → embed-BVrtTZPo.js} +1 -1
  52. package/dist/elements/chunks/{external-link-Di-dw2CU.js → external-link-BbdR7YO7.js} +1 -1
  53. package/dist/elements/chunks/{file-text-B3sAODCV.js → file-text-D7dijo1A.js} +1 -1
  54. package/dist/elements/chunks/{file-tree-Dgto-bMn.js → file-tree-0upJ5jeb.js} +1 -1
  55. package/dist/elements/chunks/{folder-D6rMadFV.js → folder-D8z58t7B.js} +1 -1
  56. package/dist/elements/chunks/{form-CILxvhQ7.js → form-p5BKJNw5.js} +1 -1
  57. package/dist/elements/chunks/{hover-card-BtiF4XiU.js → hover-card-CN9s2md5.js} +1 -1
  58. package/dist/elements/chunks/{icon-DKkmze3y.js → icon-CNiKP37E.js} +1 -1
  59. package/dist/elements/chunks/{info-CWRjZkuy.js → info-BSnuJiHI.js} +1 -1
  60. package/dist/elements/chunks/{input--QJBK6BA.js → input-BauDmzxX.js} +1 -1
  61. package/dist/elements/chunks/{kbd-C3zuKh8R.js → kbd-BlHJaP9b.js} +1 -1
  62. package/dist/elements/chunks/{link-BL5JA7Q0.js → link-DEi5iUWC.js} +1 -1
  63. package/dist/elements/chunks/{link-preview-CU37bbXh.js → link-preview-BaHyM6W4.js} +1 -1
  64. package/dist/elements/chunks/{loader-XmpyxyRH.js → loader-CmJG_Fkq.js} +1 -1
  65. package/dist/elements/chunks/{markdown-3Gv5UZ_o.js → markdown-D2jaMpCN.js} +1 -1
  66. package/dist/elements/chunks/{message-X5NZbq-W.js → message-C5UfXZ2Z.js} +1 -1
  67. package/dist/elements/chunks/{message-CnBA2OUt.js → message-CXWLmmLn.js} +1 -1
  68. package/dist/elements/chunks/message-square-hXylwsy0.js +1 -0
  69. package/dist/elements/chunks/{minimize-2-DwyrUTcM.js → minimize-2-C9ytZFMU.js} +1 -1
  70. package/dist/elements/chunks/model-switcher-CivGfD7-.js +1 -0
  71. package/dist/elements/chunks/{overlay-Cepd-KcS.js → overlay-DJL_sbOm.js} +1 -1
  72. package/dist/elements/chunks/{paperclip-CdwKjrlu.js → paperclip-CFaB8naM.js} +1 -1
  73. package/dist/elements/chunks/{progress-bar-DMVXBD3b.js → progress-bar-Br5SksfR.js} +1 -1
  74. package/dist/elements/chunks/{prompt-suggestion-Di_WZE_5.js → prompt-suggestion-CMIvtAhU.js} +1 -1
  75. package/dist/elements/chunks/{reasoning-D0jMch5u.js → reasoning-DpZcNzLq.js} +1 -1
  76. package/dist/elements/chunks/{resizable-1znbL1Gv.js → resizable-Q8i3T9a_.js} +1 -1
  77. package/dist/elements/chunks/{rotate-cw-DVhhgx8n.js → rotate-cw-B5IHCBFV.js} +1 -1
  78. package/dist/elements/chunks/{scroll-area-Bs4bFS1P.js → scroll-area-eLeydBje.js} +1 -1
  79. package/dist/elements/chunks/{scroll-button-Bet5NLc4.js → scroll-button-DWbz0yA1.js} +1 -1
  80. package/dist/elements/chunks/{separator-C5Gh8qGC.js → separator-Xic78AD_.js} +1 -1
  81. package/dist/elements/chunks/{settings-5vmEFg0K.js → settings-B8H51pAx.js} +1 -1
  82. package/dist/elements/chunks/{settings-group-BWLB38kz.js → settings-group-CsG9eYLe.js} +1 -1
  83. package/dist/elements/chunks/{share-BZ3G_DaB.js → share-CNTx0z__.js} +1 -1
  84. package/dist/elements/chunks/{skeleton-DafPJQTp.js → skeleton-CLlHTcMI.js} +1 -1
  85. package/dist/elements/chunks/{source-D_qj_6uJ.js → source-BoVcU5ok.js} +1 -1
  86. package/dist/elements/chunks/{star-Cuv7j2IE.js → star-CxQVTVhI.js} +1 -1
  87. package/dist/elements/chunks/{store-C1StPHfW.js → store-CLtS5CUG.js} +1 -1
  88. package/dist/elements/chunks/{tasks-card-EyoBZfpA.js → tasks-card-CzlkLmUt.js} +1 -1
  89. package/dist/elements/chunks/{text-shimmer-D9gRSh34.js → text-shimmer-D5NhpIQ1.js} +1 -1
  90. package/dist/elements/chunks/{textarea-DuhtHfcm.js → textarea-x5Uxsmtm.js} +1 -1
  91. package/dist/elements/chunks/{thumbs-up-B9hwv43w.js → thumbs-up-UobcJnx9.js} +1 -1
  92. package/dist/elements/chunks/{toast-store-Bm3DUEDe.js → toast-store-BOfs9eXq.js} +1 -1
  93. package/dist/elements/chunks/{tool-CDtW7L0z.js → tool-Be6Dh_dt.js} +1 -1
  94. package/dist/elements/chunks/{tooltip-eB226mGK.js → tooltip-RxMxyCLF.js} +1 -1
  95. package/dist/elements/chunks/{triangle-alert-C1SNzTqK.js → triangle-alert-C4yxH2XN.js} +1 -1
  96. package/dist/elements/chunks/{use-card-resolution-YZh2MvI5.js → use-card-resolution-jva9ehH6.js} +1 -1
  97. package/dist/elements/chunks/{variant-aurora-Cj1F7D0l.js → variant-aurora-Dx4VRcjS.js} +1 -1
  98. package/dist/elements/chunks/{variant-custom-T9gpt7QE.js → variant-custom-Y1ZTWgEa.js} +1 -1
  99. package/dist/elements/chunks/{variant-wave-CxU9z-TE.js → variant-wave-B9gml9Cg.js} +1 -1
  100. package/dist/elements/chunks/{video-CK4R7Rus.js → video-CrPHsytT.js} +1 -1
  101. package/dist/elements/chunks/x-_-OPHept.js +1 -0
  102. package/dist/elements/coachmark.js +1 -1
  103. package/dist/elements/code-block.js +1 -1
  104. package/dist/elements/command.js +1 -1
  105. package/dist/elements/compare.js +1 -1
  106. package/dist/elements/composer.js +1 -1
  107. package/dist/elements/confirm-card.js +1 -1
  108. package/dist/elements/context-meter.js +1 -1
  109. package/dist/elements/conversation-list.js +1 -1
  110. package/dist/elements/default-input.d.ts +27 -0
  111. package/dist/elements/dialog.js +1 -1
  112. package/dist/elements/editable-label.js +1 -1
  113. package/dist/elements/embed.js +1 -1
  114. package/dist/elements/empty.js +1 -1
  115. package/dist/elements/feedback-bar.js +1 -1
  116. package/dist/elements/file-tree.js +1 -1
  117. package/dist/elements/file-upload.js +1 -1
  118. package/dist/elements/form.js +1 -1
  119. package/dist/elements/hover-card.js +1 -1
  120. package/dist/elements/icon.js +1 -1
  121. package/dist/elements/image.js +1 -1
  122. package/dist/elements/input.js +1 -1
  123. package/dist/elements/kbd.js +1 -1
  124. package/dist/elements/link-preview.js +1 -1
  125. package/dist/elements/loader.js +1 -1
  126. package/dist/elements/markdown.js +1 -1
  127. package/dist/elements/menu.js +1 -1
  128. package/dist/elements/message-skills.js +1 -1
  129. package/dist/elements/message.js +1 -1
  130. package/dist/elements/model-switcher.js +1 -1
  131. package/dist/elements/nav.js +1 -1
  132. package/dist/elements/notice.js +1 -1
  133. package/dist/elements/pane-group.js +1 -1
  134. package/dist/elements/pane.js +1 -1
  135. package/dist/elements/popover.js +1 -1
  136. package/dist/elements/progress-bar.js +1 -1
  137. package/dist/elements/prompt-dock.js +1 -1
  138. package/dist/elements/prompt-input.js +1 -1
  139. package/dist/elements/prompt-suggestions.js +1 -1
  140. package/dist/elements/reasoning.js +1 -1
  141. package/dist/elements/remote.js +1 -1
  142. package/dist/elements/resizable.js +1 -1
  143. package/dist/elements/response-stream.js +2 -2
  144. package/dist/elements/screen.js +1 -1
  145. package/dist/elements/scroll-area.js +1 -1
  146. package/dist/elements/scroll-button.js +1 -1
  147. package/dist/elements/search.js +1 -1
  148. package/dist/elements/segmented.js +1 -1
  149. package/dist/elements/separator.js +1 -1
  150. package/dist/elements/setting-item.js +1 -1
  151. package/dist/elements/settings-group.js +1 -1
  152. package/dist/elements/skeleton.js +1 -1
  153. package/dist/elements/slot-text.d.ts +51 -0
  154. package/dist/elements/slots.d.ts +5 -0
  155. package/dist/elements/source.js +1 -1
  156. package/dist/elements/status.js +1 -1
  157. package/dist/elements/switch.js +1 -1
  158. package/dist/elements/tabs.js +1 -1
  159. package/dist/elements/tasks.js +1 -1
  160. package/dist/elements/text-shimmer.js +1 -1
  161. package/dist/elements/thinking-bar.js +1 -1
  162. package/dist/elements/thread.js +1 -1
  163. package/dist/elements/toast.js +1 -1
  164. package/dist/elements/tool.js +1 -1
  165. package/dist/elements/tooltip.js +1 -1
  166. package/dist/elements/voice-input.js +1 -1
  167. package/dist/elements/voice-output.js +1 -1
  168. package/dist/elements.d.ts +12 -6
  169. package/dist/index.js +1535 -1487
  170. package/dist/index.server.js +2330 -2296
  171. package/dist/kai.es.js +1 -1
  172. package/dist/llms/llms-full.txt +12 -3
  173. package/dist/mcp.es.js +809 -75
  174. package/dist/react/index.d.ts +13 -3
  175. package/dist/react.js +14 -14
  176. package/dist/register-impl-AxIlqjo7.js +145 -0
  177. package/dist/{solid-BCJUQqwS.js → solid-BGYSJyHG.js} +4901 -4691
  178. package/dist/{solid-BdENBcEq.js → solid-Dt2nEwfl.js} +4406 -4320
  179. package/dist/solid.js +1 -1
  180. package/dist/solid.server.js +1 -1
  181. package/dist/{variant-aurora-DOb_o4pC.js → variant-aurora-B44X69AG.js} +1 -1
  182. package/dist/{variant-aurora-BKHHiOgL.js → variant-aurora-BRhV8Flk.js} +2 -2
  183. package/dist/{variant-aurora-1wMcXRUS.js → variant-aurora-Ds5ou9BH.js} +2 -2
  184. package/dist/{variant-custom-DWSSgmd2.js → variant-custom-B20FhtYj.js} +2 -2
  185. package/dist/{variant-custom-CJGB0Kbw.js → variant-custom-BvA1Schl.js} +1 -1
  186. package/dist/{variant-custom-Co0CkXJF.js → variant-custom-D6Rtvneb.js} +2 -2
  187. package/dist/{variant-wave-CEODq-9q.js → variant-wave-BUQtkryq.js} +1 -1
  188. package/dist/{variant-wave-C3-45cL6.js → variant-wave-BVYnqy9L.js} +2 -2
  189. package/dist/{variant-wave-jdXBnCUZ.js → variant-wave-DXC6_LcH.js} +2 -2
  190. package/dist/wire/encode.d.ts +79 -6
  191. package/dist/wire/files.d.ts +116 -0
  192. package/dist/wire/index.d.ts +3 -1
  193. package/dist/wire/media-types.d.ts +106 -0
  194. package/dist/wire.js +596 -368
  195. package/frameworks/react/index.tsx +9 -5
  196. package/llms-full.txt +12 -3
  197. package/package.json +2 -1
  198. package/src/agent-tooling/archetypes.ts +43 -12
  199. package/src/agent-tooling/integrations/anthropic.ts +29 -5
  200. package/src/agent-tooling/integrations/cloudflare.ts +5 -0
  201. package/src/agent-tooling/integrations/langgraph.ts +13 -0
  202. package/src/agent-tooling/integrations/mastra.ts +46 -5
  203. package/src/agent-tooling/integrations/mock.ts +4 -0
  204. package/src/agent-tooling/integrations/ollama.ts +7 -0
  205. package/src/agent-tooling/integrations/openai.ts +2 -0
  206. package/src/agent-tooling/integrations/openrouter.ts +2 -0
  207. package/src/agent-tooling/integrations/pi.ts +26 -1
  208. package/src/agent-tooling/integrations/pydantic-ai.ts +37 -3
  209. package/src/agent-tooling/integrations/vercel-ai-sdk.ts +42 -6
  210. package/src/agent-tooling/mcp/tools/scaffold.ts +853 -32
  211. package/src/agent-tooling/registry.ts +57 -0
  212. package/src/agent-tooling/types.ts +132 -0
  213. package/src/components/chat-thread.tsx +10 -1
  214. package/src/components/context.tsx +18 -12
  215. package/src/components/conversation-item.tsx +6 -0
  216. package/src/components/model-switcher.tsx +38 -8
  217. package/src/components/toast.tsx +6 -1
  218. package/src/elements/button.tsx +26 -3
  219. package/src/elements/chat.tsx +28 -2
  220. package/src/elements/default-input.tsx +128 -5
  221. package/src/elements/element-meta.json +30 -4
  222. package/src/elements/element-types.d.ts +12 -6
  223. package/src/elements/menu.tsx +30 -3
  224. package/src/elements/slot-text.ts +73 -0
  225. package/src/elements/slots.ts +10 -0
  226. package/src/index.ts +9 -5
  227. package/src/wire/encode.ts +229 -14
  228. package/src/wire/files.ts +342 -0
  229. package/src/wire/index.ts +21 -0
  230. package/src/wire/media-types.ts +344 -0
  231. package/dist/elements/chunks/arrow-left-Bb46H7w9.js +0 -1
  232. package/dist/elements/chunks/check-C8desc_h.js +0 -1
  233. package/dist/elements/chunks/chevron-down-DKtc2HNB.js +0 -1
  234. package/dist/elements/chunks/chevron-right-Dw4GPhwL.js +0 -1
  235. package/dist/elements/chunks/circle-BeokBsvB.js +0 -1
  236. package/dist/elements/chunks/circle-check-wJSqt859.js +0 -1
  237. package/dist/elements/chunks/context-WHDl-xSx.js +0 -1
  238. package/dist/elements/chunks/conversation-list-CdR14_Y4.js +0 -1
  239. package/dist/elements/chunks/default-input-CSZqUM81.js +0 -1
  240. package/dist/elements/chunks/message-square-CnpAm_P0.js +0 -1
  241. package/dist/elements/chunks/model-switcher-CclaJEHt.js +0 -1
  242. package/dist/elements/chunks/x-D1qx0aLd.js +0 -1
  243. package/dist/register-impl-bzrZ44ft.js +0 -145
package/dist/mcp.es.js CHANGED
@@ -805,12 +805,15 @@ const StreamFormat = z$1.enum(["openai-sse", "ai-sdk", "native"]);
805
805
  const Framework = z$1.enum(["html", "react", "next", "vue", "svelte", "angular", "solid", "fastapi", "express", "worker", "tanstack-start"]);
806
806
  const Placement = z$1.enum(["side", "full-page", "docked-widget", "inline"]);
807
807
  const KeyExposure = z$1.enum(["frontend-safe", "needs-proxy"]);
808
+ const OutOfBand = z$1.enum(["none", "local-server", "local-binary", "language-runtime"]);
808
809
  const DepsSchema = z$1.object({
809
810
  npm: z$1.array(z$1.string()).default([]),
810
811
  pip: z$1.array(z$1.string()).default([])
811
812
  });
812
813
  const SECRET_ENV_VAR = /(?:KEY|TOKEN|SECRET|PASSWORD)$/;
813
814
  const AUTH_HEADER = /Authorization\s*:|['"]x-api-key['"]\s*:/i;
815
+ const LOOPBACK_FETCH = /\b(?:fetch|URL)\s*\(\s*['"`]https?:\/\/(?:localhost|127\.0\.0\.1)/;
816
+ const SPAWNS_PROCESS = /\bfrom\s+['"]node:child_process['"]|\bspawn\s*\(/;
814
817
  z$1.object({
815
818
  id: z$1.string(),
816
819
  title: z$1.string(),
@@ -897,7 +900,14 @@ z$1.object({
897
900
  * failed, so a plain required field would report "Invalid input" and this is
898
901
  * the one field where the reader needs to be told what is at stake.
899
902
  */
900
- keyExposure: KeyExposure.optional()
903
+ keyExposure: KeyExposure.optional(),
904
+ /**
905
+ * See {@link OutOfBand}. Optional in the TYPE only so the refinement below owns
906
+ * the error message — Zod skips refinements when the base object already
907
+ * failed, so a plain required field would report "Invalid input" and this is a
908
+ * field whose reader needs to be told what the four values mean.
909
+ */
910
+ outOfBand: OutOfBand.optional()
901
911
  }).superRefine((integration, ctx) => {
902
912
  if (integration.forwardsFromClient.includes("tools") && integration.clientToolFormat === void 0) {
903
913
  ctx.addIssue({
@@ -934,6 +944,40 @@ z$1.object({
934
944
  });
935
945
  }
936
946
  }
947
+ if (integration.outOfBand === void 0) {
948
+ ctx.addIssue({
949
+ code: z$1.ZodIssueCode.custom,
950
+ path: ["outOfBand"],
951
+ message: `integration '${integration.id}' declares no outOfBand. State what the developer must supply BEYOND an install and a key, because that is the group create-kai's gateway prompt puts it in: 'none' (a remote endpoint, or the agent runs inside the route), 'local-server' (a process must already be listening, e.g. 'ollama serve'), 'local-binary' (an executable on PATH that the route spawns), or 'language-runtime' (the emitted backend needs a runtime the JS toolchain does not provide, e.g. python + uvicorn). Read the route and the runNote to decide, not the category: 'provider' holds both openai and ollama. There is no default on purpose — a missing value read as 'none' is a scaffold that says "npm run dev" to someone who has nothing to connect to.`
952
+ });
953
+ }
954
+ if (integration.outOfBand === "none") {
955
+ const routeSources = [
956
+ ...Object.values(integration.routeTemplates),
957
+ ...integration.webRoute ? [integration.webRoute] : []
958
+ ];
959
+ if (integration.language === "python") {
960
+ ctx.addIssue({
961
+ code: z$1.ZodIssueCode.custom,
962
+ path: ["outOfBand"],
963
+ message: `integration '${integration.id}' claims outOfBand 'none' but its route is python, so a consumer whose whole toolchain is node cannot run it without an interpreter and an ASGI server they start themselves. Use 'language-runtime'.`
964
+ });
965
+ }
966
+ if (routeSources.some((code) => SPAWNS_PROCESS.test(code))) {
967
+ ctx.addIssue({
968
+ code: z$1.ZodIssueCode.custom,
969
+ path: ["outOfBand"],
970
+ message: `integration '${integration.id}' claims outOfBand 'none' but its route spawns a child process, so the program it spawns has to be installed on the host — nothing in package.json puts it there. Use 'local-binary'.`
971
+ });
972
+ }
973
+ if (routeSources.some((code) => LOOPBACK_FETCH.test(code))) {
974
+ ctx.addIssue({
975
+ code: z$1.ZodIssueCode.custom,
976
+ path: ["outOfBand"],
977
+ message: `integration '${integration.id}' claims outOfBand 'none' but its route fetches a loopback address, so something local has to be listening before the first message works. Use 'local-server'.`
978
+ });
979
+ }
980
+ }
937
981
  });
938
982
  z$1.object({
939
983
  id: z$1.string(),
@@ -1022,7 +1066,9 @@ const openai = {
1022
1066
  deps: { npm: [], pip: [] },
1023
1067
  // The route reads OPENAI_API_KEY and puts it in an `Authorization: Bearer`
1024
1068
  // header. A static bundle carrying that is a published key.
1025
- keyExposure: "needs-proxy"
1069
+ keyExposure: "needs-proxy",
1070
+ // A remote HTTPS endpoint and a key. Nothing to install, nothing to start.
1071
+ outOfBand: "none"
1026
1072
  };
1027
1073
  const anthropic = {
1028
1074
  id: "anthropic",
@@ -1037,10 +1083,19 @@ const anthropic = {
1037
1083
  // No per-framework templates: the handler below is web-standard, so the
1038
1084
  // scaffolder wraps it in whatever the target framework routes with.
1039
1085
  routeTemplates: {},
1040
- webRoute: `/** One Anthropic content block. Open union on the wire; these are the four the
1086
+ webRoute: `/** Where an image or document's bytes come from. Anthropic takes both forms
1087
+ * for both block types, which is why this route has no gap where the
1088
+ * OpenAI-shaped one does: a chat-completions \`file\` part is base64-only. */
1089
+ type AnthropicSource =
1090
+ | { type: 'base64'; media_type: string; data: string }
1091
+ | { type: 'url'; url: string };
1092
+
1093
+ /** One Anthropic content block. Open union on the wire; these are the ones the
1041
1094
  * scaffold's own thread can produce. */
1042
1095
  type AnthropicBlock =
1043
1096
  | { type: 'text'; text: string }
1097
+ | { type: 'image'; source: AnthropicSource }
1098
+ | { type: 'document'; source: AnthropicSource }
1044
1099
  | { type: 'tool_use'; id: string; name: string; input: unknown }
1045
1100
  | { type: 'tool_result'; tool_use_id: string; content: string };
1046
1101
 
@@ -1123,12 +1178,24 @@ function toAnthropicBody(messages: ChatRequestBody['messages']): {
1123
1178
  switch (message.role) {
1124
1179
  case 'system': {
1125
1180
  // TOP-LEVEL, not a message. Several system turns concatenate.
1126
- const text = message.content ?? '';
1181
+ const text = wireText(message.content);
1127
1182
  system = system === undefined ? text : \`\${system}\\n\\n\${text}\`;
1128
1183
  break;
1129
1184
  }
1130
1185
  case 'user': {
1131
- if (message.content) pushUser([{ type: 'text', text: message.content }]);
1186
+ // Attachments ride HERE, as image and document blocks beside the text,
1187
+ // in the order the thread authored them.
1188
+ const blocks = wireParts(message.content).map((part): AnthropicBlock => {
1189
+ if (part.kind === 'text') return { type: 'text', text: part.text };
1190
+ const source: AnthropicSource =
1191
+ part.source.type === 'data'
1192
+ ? { type: 'base64', media_type: part.mediaType, data: part.source.data }
1193
+ : { type: 'url', url: part.source.url };
1194
+ // \`image/png\` and the bare top-level \`image\` both mean an image; a URL
1195
+ // source reports only the segment because the wire carries no more.
1196
+ return part.mediaType.startsWith('image') ? { type: 'image', source } : { type: 'document', source };
1197
+ });
1198
+ if (blocks.length > 0) pushUser(blocks);
1132
1199
  break;
1133
1200
  }
1134
1201
  case 'tool': {
@@ -1137,14 +1204,15 @@ function toAnthropicBody(messages: ChatRequestBody['messages']): {
1137
1204
  {
1138
1205
  type: 'tool_result',
1139
1206
  tool_use_id: message.tool_call_id ?? '',
1140
- content: message.content ?? '',
1207
+ content: wireText(message.content),
1141
1208
  },
1142
1209
  ]);
1143
1210
  break;
1144
1211
  }
1145
1212
  case 'assistant': {
1146
1213
  const content: AnthropicBlock[] = [];
1147
- if (message.content) content.push({ type: 'text', text: message.content });
1214
+ const text = wireText(message.content);
1215
+ if (text) content.push({ type: 'text', text });
1148
1216
  for (const call of message.tool_calls ?? []) {
1149
1217
  content.push({
1150
1218
  type: 'tool_use',
@@ -1406,7 +1474,9 @@ async function chatHandler(request: Request): Promise<Response> {
1406
1474
  // The route reads ANTHROPIC_API_KEY into an `x-api-key` header. Note the
1407
1475
  // header is NOT `Authorization`, which is why the schema's detector matches
1408
1476
  // both spellings.
1409
- keyExposure: "needs-proxy"
1477
+ keyExposure: "needs-proxy",
1478
+ // A remote HTTPS endpoint and a key. Nothing to install, nothing to start.
1479
+ outOfBand: "none"
1410
1480
  };
1411
1481
  const openrouter = {
1412
1482
  id: "openrouter",
@@ -1475,7 +1545,9 @@ const openrouter = {
1475
1545
  // Nothing to install. The route is global `fetch` and imports no module.
1476
1546
  deps: { npm: [], pip: [] },
1477
1547
  // The route reads OPENROUTER_API_KEY into an `Authorization: Bearer` header.
1478
- keyExposure: "needs-proxy"
1548
+ keyExposure: "needs-proxy",
1549
+ // A remote HTTPS endpoint and a key. Nothing to install, nothing to start.
1550
+ outOfBand: "none"
1479
1551
  };
1480
1552
  const vercelAiSdk = {
1481
1553
  id: "vercel-ai-sdk",
@@ -1488,12 +1560,32 @@ const vercelAiSdk = {
1488
1560
  // scaffolder wraps it in the target framework's own route declaration.
1489
1561
  routeTemplates: {},
1490
1562
  webRoute: `import { streamText } from 'ai';
1491
- import type { ModelMessage, AssistantContent, ToolResultPart } from 'ai';
1563
+ import type { ModelMessage, AssistantContent, UserContent, FilePart, ToolResultPart } from 'ai';
1492
1564
 
1493
1565
  // Next.js only: add \`export const maxDuration = 30\` to the route file to allow
1494
1566
  // long streaming responses. It is a Next route-segment config, not part of the
1495
1567
  // handler, so it lives in the file rather than in here.
1496
1568
 
1569
+ /**
1570
+ * One attachment to an AI SDK FilePart.
1571
+ *
1572
+ * \`data\` takes the BARE shorthand — a base64 string, or a URL object for a
1573
+ * remote file — rather than the newer tagged \`{ type: 'data' | 'url' }\` form.
1574
+ * Both are accepted by the installed SDK; the shorthand is also what AI SDK v5
1575
+ * and v6 accept, so this route keeps compiling if the app pins an older \`ai\`.
1576
+ *
1577
+ * Images arrive as \`image_url\` and become FileParts too. \`ImagePart\` still
1578
+ * exists but is deprecated in favour of exactly this.
1579
+ */
1580
+ function toFilePart(part: Extract<WirePart, { kind: 'file' }>): FilePart {
1581
+ return {
1582
+ type: 'file',
1583
+ mediaType: part.mediaType,
1584
+ ...(part.filename === undefined ? {} : { filename: part.filename }),
1585
+ data: part.source.type === 'data' ? part.source.data : new URL(part.source.url),
1586
+ };
1587
+ }
1588
+
1497
1589
  // The kit's wire format is OpenAI-shaped (tool_calls on the assistant message,
1498
1590
  // a flat content string on the tool message). The AI SDK's ModelMessage is not:
1499
1591
  // a tool call is a CONTENT PART on the assistant message, and a tool result is a
@@ -1502,22 +1594,35 @@ import type { ModelMessage, AssistantContent, ToolResultPart } from 'ai';
1502
1594
  function toModelMessages(messages: ChatRequestBody['messages']): ModelMessage[] {
1503
1595
  return messages.map((message): ModelMessage => {
1504
1596
  switch (message.role) {
1597
+ // A system prompt is text-only on every wire, so the array form collapses.
1505
1598
  case 'system':
1506
- case 'user':
1507
- return { role: message.role, content: message.content ?? '' };
1599
+ return { role: 'system', content: wireText(message.content) };
1600
+ case 'user': {
1601
+ const parts = wireParts(message.content);
1602
+ // Plain string for an ordinary turn: identical to what this route sent
1603
+ // before attachments existed, and the shape the SDK docs lead with.
1604
+ if (parts.every((p) => p.kind === 'text')) {
1605
+ return { role: 'user', content: wireText(message.content) };
1606
+ }
1607
+ const content: UserContent = parts.map((p) =>
1608
+ p.kind === 'text' ? { type: 'text' as const, text: p.text } : toFilePart(p),
1609
+ );
1610
+ return { role: 'user', content };
1611
+ }
1508
1612
  case 'tool': {
1509
1613
  const result: ToolResultPart = {
1510
1614
  type: 'tool-result',
1511
1615
  toolCallId: message.tool_call_id ?? '',
1512
1616
  toolName: message.name ?? '',
1513
- output: { type: 'text', value: message.content ?? '' },
1617
+ output: { type: 'text', value: wireText(message.content) },
1514
1618
  };
1515
1619
  return { role: 'tool', content: [result] };
1516
1620
  }
1517
1621
  case 'assistant': {
1518
- if (!message.tool_calls?.length) return { role: 'assistant', content: message.content ?? '' };
1622
+ const text = wireText(message.content);
1623
+ if (!message.tool_calls?.length) return { role: 'assistant', content: text };
1519
1624
  const content: AssistantContent = [];
1520
- if (message.content) content.push({ type: 'text', text: message.content });
1625
+ if (text) content.push({ type: 'text', text });
1521
1626
  for (const call of message.tool_calls) {
1522
1627
  content.push({
1523
1628
  type: 'tool-call',
@@ -1586,7 +1691,10 @@ async function chatHandler(request: Request): Promise<Response> {
1586
1691
  // streamText(). This is the case that makes inference from route source
1587
1692
  // unsafe and the declaration necessary; `envVars` is what the schema's safety
1588
1693
  // net can still see.
1589
- keyExposure: "needs-proxy"
1694
+ keyExposure: "needs-proxy",
1695
+ // The AI Gateway is a remote HTTPS endpoint reached through the `ai` package,
1696
+ // which is an ordinary npm dependency (see `deps`). Nothing to start.
1697
+ outOfBand: "none"
1590
1698
  };
1591
1699
  const langgraph = {
1592
1700
  id: "langgraph",
@@ -1696,7 +1804,20 @@ async function chatHandler(request: Request): Promise<Response> {
1696
1804
  deps: { npm: ["@langchain/langgraph", "@langchain/openai", "@langchain/core", "zod"], pip: [] },
1697
1805
  // No key appears in the route: `new ChatOpenAI(...)` reads OPENAI_API_KEY from
1698
1806
  // the environment itself. Same invisible-key shape as vercel-ai-sdk.
1699
- keyExposure: "needs-proxy"
1807
+ keyExposure: "needs-proxy",
1808
+ // 'none', and this is the entry most likely to be "corrected" to something
1809
+ // else, so the reason is here rather than assumed. The create-kai spec lists
1810
+ // LangGraph under "Bring a server or runtime". That is WRONG for this route:
1811
+ // the graph is built and compiled IN PROCESS above (`createReactAgent` over a
1812
+ // `new ChatOpenAI(...)`), so there is no LangGraph server, no port and no
1813
+ // second process — the four packages in `deps` are the whole install and
1814
+ // `runNote` asks for a key and nothing else.
1815
+ //
1816
+ // LangGraph Platform / `langgraph dev` IS a server, and an integration that
1817
+ // talked to one over HTTP would be 'local-server'. That is a different route
1818
+ // than the one above, and if it is ever added it must be added as its own
1819
+ // catalog entry rather than by changing this line.
1820
+ outOfBand: "none"
1700
1821
  };
1701
1822
  const cloudflare = {
1702
1823
  id: "cloudflare",
@@ -1863,7 +1984,12 @@ export default {
1863
1984
  // The two routes differ and the stricter one governs: the worker template uses
1864
1985
  // the `env.AI` binding and holds no token in code at all, but a binding is a
1865
1986
  // Worker capability, not a browser one. Either way a server is required.
1866
- keyExposure: "needs-proxy"
1987
+ keyExposure: "needs-proxy",
1988
+ // Workers AI is a remote HTTPS endpoint (the REST route) or a platform binding
1989
+ // (the worker route) — either way there is nothing on the developer's machine
1990
+ // to install or run. `wrangler` is a devDependency of a Worker app, not an
1991
+ // out-of-band prerequisite of this integration.
1992
+ outOfBand: "none"
1867
1993
  };
1868
1994
  const ollama = {
1869
1995
  id: "ollama",
@@ -1942,7 +2068,14 @@ const ollama = {
1942
2068
  // empty, so there is no secret for a bundle to leak. runNote already documents
1943
2069
  // the browser-direct path (set OLLAMA_ORIGINS to allow the page origin), which
1944
2070
  // is the path this value unlocks.
1945
- keyExposure: "frontend-safe"
2071
+ keyExposure: "frontend-safe",
2072
+ // The route fetches http://localhost:11434, so Ollama must ALREADY be running:
2073
+ // `ollama serve`, then `ollama pull <model>` for the model the route pins.
2074
+ // Nothing in package.json installs or starts it — `deps.npm` is empty because
2075
+ // the route is bare `fetch`, which is exactly why this cannot be inferred from
2076
+ // the dependency list. The schema's LOOPBACK_FETCH net catches this one
2077
+ // independently, so a later edit to 'none' fails at the catalog boundary.
2078
+ outOfBand: "local-server"
1946
2079
  };
1947
2080
  const mastra = {
1948
2081
  id: "mastra",
@@ -1979,17 +2112,43 @@ async function chatHandler(request: Request): Promise<Response> {
1979
2112
  // is never null. A 'tool' wire message is OUR client-side tool bookkeeping;
1980
2113
  // this agent owns its tools server-side (forwardsFromClient is empty), so it
1981
2114
  // has no schema for that shape and the entry is dropped rather than guessed.
1982
- type MastraMessage = { role: 'system'; content: string } | { role: 'user'; content: string } | { role: 'assistant'; content: string };
2115
+ //
2116
+ // Only the USER role takes content parts: a system prompt is text by
2117
+ // definition, and an assistant turn here is replayed history, not a new
2118
+ // upload. \`data\` uses the bare base64-or-URL shorthand, which every AI SDK
2119
+ // major Mastra accepts understands.
2120
+ type MastraFilePart = { type: 'file'; data: string | URL; mediaType: string; filename?: string };
2121
+ type MastraUserContent = string | Array<{ type: 'text'; text: string } | MastraFilePart>;
2122
+ type MastraMessage = { role: 'system'; content: string } | { role: 'user'; content: MastraUserContent } | { role: 'assistant'; content: string };
1983
2123
  const mastraMessages: MastraMessage[] = [];
1984
2124
  for (const m of messages) {
1985
- const content = m.content ?? '';
1986
2125
  // Each branch constructs a literal with ONE fixed role, not m.role (which is
1987
2126
  // still typed as the 4-way union): a union-VALUED field on a single object
1988
2127
  // does not structurally match a union of role-discriminated objects, so
1989
2128
  // widening back to m.role here would reintroduce the original TS2345.
1990
- if (m.role === 'system') mastraMessages.push({ role: 'system', content });
1991
- else if (m.role === 'user') mastraMessages.push({ role: 'user', content });
1992
- else if (m.role === 'assistant') mastraMessages.push({ role: 'assistant', content });
2129
+ if (m.role === 'system') mastraMessages.push({ role: 'system', content: wireText(m.content) });
2130
+ else if (m.role === 'assistant') mastraMessages.push({ role: 'assistant', content: wireText(m.content) });
2131
+ else if (m.role === 'user') {
2132
+ const parts = wireParts(m.content);
2133
+ // Plain string unless the turn actually carries an attachment.
2134
+ if (parts.every((p) => p.kind === 'text')) {
2135
+ mastraMessages.push({ role: 'user', content: wireText(m.content) });
2136
+ } else {
2137
+ mastraMessages.push({
2138
+ role: 'user',
2139
+ content: parts.map((p) =>
2140
+ p.kind === 'text'
2141
+ ? { type: 'text' as const, text: p.text }
2142
+ : {
2143
+ type: 'file' as const,
2144
+ mediaType: p.mediaType,
2145
+ ...(p.filename === undefined ? {} : { filename: p.filename }),
2146
+ data: p.source.type === 'data' ? p.source.data : new URL(p.source.url),
2147
+ },
2148
+ ),
2149
+ });
2150
+ }
2151
+ }
1993
2152
  }
1994
2153
 
1995
2154
  let agentStream: Awaited<ReturnType<ReturnType<typeof mastra.getAgent>['stream']>>;
@@ -2073,7 +2232,22 @@ async function chatHandler(request: Request): Promise<Response> {
2073
2232
  // agent endpoint — a decision a scaffolder must not make silently, even though
2074
2233
  // no key leaks. The conservative direction is the cheap one: a needless server
2075
2234
  // hop costs a process, the other error costs the endpoint.
2076
- keyExposure: "needs-proxy"
2235
+ keyExposure: "needs-proxy",
2236
+ // THE JUDGEMENT CALL FOR THIS FIELD, exactly as keyExposure above is, and for
2237
+ // a related reason: no automatic check can decide it. MASTRA_URL is a base URL
2238
+ // for a server this integration does not ship and cannot start; the route's
2239
+ // only mention of loopback is inside a `throw new Error(...)` string, which
2240
+ // LOOPBACK_FETCH deliberately does not match (a net that fired on prose would
2241
+ // be right here by accident and wrong elsewhere). So 'none' would parse
2242
+ // cleanly.
2243
+ //
2244
+ // It is 'local-server' on what the integration actually requires: a Mastra
2245
+ // server has to be reachable at MASTRA_URL before the first message works,
2246
+ // `mastra dev` is how a developer gets one (port 4111), and `@mastra/client-js`
2247
+ // is a CLIENT for it, not the server itself. Printing "start your Mastra
2248
+ // server" costs a line; omitting it produces a scaffold that fetches a URL
2249
+ // nothing answers.
2250
+ outOfBand: "local-server"
2077
2251
  };
2078
2252
  const pi = {
2079
2253
  id: "pi",
@@ -2098,7 +2272,25 @@ app.use(express.json());
2098
2272
  // POST /api/chat: bridge a Pi RPC session to the browser as SSE.
2099
2273
  app.post('/api/chat', (req, res) => {
2100
2274
  const { messages } = req.body as { messages: OpenAIWireMessage[] };
2101
- const prompt = messages.at(-1)?.content ?? '';
2275
+ const last = messages.at(-1)?.content;
2276
+ // \`content\` is a plain string until the turn carries an attachment, at which
2277
+ // point it is an ARRAY of content parts. Pi's RPC mode takes a TEXT prompt and
2278
+ // has no channel for a file, so an attachment is REFUSED here. Passing the
2279
+ // array straight through would JSON.stringify an object graph into the prompt
2280
+ // — no type error, no crash, just a model reading serialised noise.
2281
+ if (Array.isArray(last) && last.some((part) => part.type !== 'text')) {
2282
+ res.status(400).json({
2283
+ error: {
2284
+ message:
2285
+ 'This Pi bridge forwards a text prompt only and has no channel for an attachment. Extract the file content into the message text, or send it through a tool.',
2286
+ },
2287
+ });
2288
+ return;
2289
+ }
2290
+ const prompt =
2291
+ typeof last === 'string'
2292
+ ? last
2293
+ : (last ?? []).map((part) => (part.type === 'text' ? part.text : '')).join('');
2102
2294
 
2103
2295
  // Headers before the first frame: without text/event-stream the browser
2104
2296
  // buffers the body and readOpenAIStream never sees a frame.
@@ -2165,7 +2357,14 @@ app.listen(3001, () => console.log('chat api: http://localhost:3001/api/chat'));
2165
2357
  // warns that Pi runs with full user permissions and wants sandboxing before it
2166
2358
  // is exposed. "No key" is not the same fact as "safe in the browser", and
2167
2359
  // collapsing the two is exactly how this flag would be got wrong.
2168
- keyExposure: "needs-proxy"
2360
+ keyExposure: "needs-proxy",
2361
+ // Not 'local-server': nothing is listening in advance. The bridge spawns
2362
+ // `pi --mode rpc` per request, so what the developer supplies is an EXECUTABLE
2363
+ // on PATH ("Pi must be installed locally and available on PATH as 'pi'"), and
2364
+ // "start the server first" would be the wrong instruction to print. Pi is not
2365
+ // in `deps` for the same reason — the route reaches it with spawn(), not an
2366
+ // import. The schema's SPAWNS_PROCESS net catches this one independently.
2367
+ outOfBand: "local-binary"
2169
2368
  };
2170
2369
  const pydanticAi = {
2171
2370
  id: "pydantic-ai",
@@ -2177,7 +2376,7 @@ const pydanticAi = {
2177
2376
  routeTemplates: {
2178
2377
  fastapi: `# main.py
2179
2378
  import json
2180
- from fastapi import FastAPI
2379
+ from fastapi import FastAPI, HTTPException
2181
2380
  from fastapi.middleware.cors import CORSMiddleware
2182
2381
  from fastapi.responses import StreamingResponse
2183
2382
  from pydantic import BaseModel
@@ -2190,15 +2389,41 @@ app.add_middleware(
2190
2389
  CORSMiddleware, allow_origins=['*'], allow_methods=['*'], allow_headers=['*']
2191
2390
  )
2192
2391
 
2392
+ class ContentPart(BaseModel):
2393
+ type: str
2394
+ text: str | None = None
2395
+
2193
2396
  class Message(BaseModel):
2194
2397
  role: str
2195
- content: str
2398
+ # A plain string until the turn carries an attachment, at which point the
2399
+ # wire sends an ARRAY of content parts. Declaring this \`str\` alone made a
2400
+ # message with a file a 422 that named pydantic rather than the cause.
2401
+ content: str | list[ContentPart] | None = None
2196
2402
 
2197
2403
  class ChatRequest(BaseModel):
2198
2404
  messages: list[Message]
2199
2405
 
2406
+ def prompt_text(message: Message) -> str:
2407
+ """The text of a turn, refusing what this route cannot carry.
2408
+
2409
+ agent.run_stream() takes a TEXT prompt, so an image or a document has no
2410
+ channel here. Dropping it would send the model a turn that silently lost the
2411
+ user's file; a 400 that names the reason is the honest failure.
2412
+ """
2413
+ if message.content is None:
2414
+ return ''
2415
+ if isinstance(message.content, str):
2416
+ return message.content
2417
+ if any(part.type != 'text' for part in message.content):
2418
+ raise HTTPException(
2419
+ status_code=400,
2420
+ detail='This route forwards a text prompt only and has no channel for an attachment. '
2421
+ 'Extract the file content into the message text, or send it through a tool.',
2422
+ )
2423
+ return ''.join(part.text or '' for part in message.content)
2424
+
2200
2425
  async def openai_sse(messages: list[Message]):
2201
- prompt = messages[-1].content if messages else ''
2426
+ prompt = prompt_text(messages[-1]) if messages else ''
2202
2427
  async with agent.run_stream(prompt) as result:
2203
2428
  async for delta in result.stream_text(delta=True):
2204
2429
  chunk = {'choices': [{'delta': {'content': delta}}]}
@@ -2233,7 +2458,15 @@ async def chat(req: ChatRequest):
2233
2458
  // DIRECTLY on :8000, with no JS proxy in front. That still is not
2234
2459
  // 'frontend-safe' — the flag asks where the SECRET lives, and it lives in the
2235
2460
  // python process. The FastAPI service IS the server hop.
2236
- keyExposure: "needs-proxy"
2461
+ keyExposure: "needs-proxy",
2462
+ // The emitted backend is a FastAPI service: a python interpreter, the four
2463
+ // `deps.pip` packages, and `uvicorn main:app` on its own port — none of which a
2464
+ // node toolchain provides or starts. It also needs OPENAI_API_KEY, so this is
2465
+ // the entry that proves the groups are not mutually exclusive: a runtime
2466
+ // prerequisite and a key at once. The prompt should lead with the runtime,
2467
+ // because a key is useless until the service runs. The schema's `language ===
2468
+ // 'python'` net catches this one independently.
2469
+ outOfBand: "language-runtime"
2237
2470
  };
2238
2471
  const mock = {
2239
2472
  id: "mock",
@@ -2257,7 +2490,11 @@ const mock = {
2257
2490
  // and no secret, so there is nothing a public bundle could give away. This is
2258
2491
  // the one place where "declares nothing" genuinely means safe — which is
2259
2492
  // precisely why it still has to SAY so rather than be left blank.
2260
- keyExposure: "frontend-safe"
2493
+ keyExposure: "frontend-safe",
2494
+ // Nothing at all, and here that is the literal truth rather than a shorthand:
2495
+ // there is no route, no upstream and no process. This is the "No backend"
2496
+ // group of create-kai's gateway prompt all by itself.
2497
+ outOfBand: "none"
2261
2498
  };
2262
2499
  const archetypes$1 = [
2263
2500
  {
@@ -2301,6 +2538,34 @@ const archetypes$1 = [
2301
2538
  components: ["kai-chat", "kai-voice-input"],
2302
2539
  defaultPlacement: "full-page",
2303
2540
  docsSlug: "examples/voice-assistant"
2541
+ },
2542
+ /**
2543
+ * THE PRESET THAT EXISTS TO MAKE A CAPABILITY REACHABLE, not to name a product
2544
+ * shape someone asked for.
2545
+ *
2546
+ * `kai-file-upload` and `kai-attachments` were registered elements that NO
2547
+ * preset composed, and `listCapabilityGroups` derives its answer from this
2548
+ * table — so the attachments capability reached neither `listSurfaceProbes`
2549
+ * nor `verify:scaffold`, and `renderSurface` had no branch that emitted either
2550
+ * tag. A caller could pass them in `components` (the axis takes any list) and
2551
+ * get two bare `<kai-file-upload></kai-file-upload>` / `<kai-attachments>`
2552
+ * siblings with nothing wired to them: on screen, inert, silent.
2553
+ *
2554
+ * The pair is ONE capability for the same reason `kai-artifact` +
2555
+ * `kai-resizable` are: a dropzone with no list is a black hole, and a list
2556
+ * with no dropzone can never fill. `hasAttachments` in scaffold.ts requires
2557
+ * both for exactly that reason.
2558
+ *
2559
+ * The components match the create-kai spec's own feature table
2560
+ * (`attachments` -> `kai-file-upload`, `kai-attachments`), so the CLI's
2561
+ * multi-select and this preset name the same surface rather than two.
2562
+ */
2563
+ {
2564
+ id: "attachments",
2565
+ title: "File attachments",
2566
+ components: ["kai-chat", "kai-file-upload", "kai-attachments"],
2567
+ defaultPlacement: "full-page",
2568
+ docsSlug: "examples/attachments"
2304
2569
  }
2305
2570
  ];
2306
2571
  const integrations = [
@@ -2433,11 +2698,13 @@ function realStreamBody(opts) {
2433
2698
  cards = false,
2434
2699
  valueSource = "e.detail.value",
2435
2700
  afterValue = [],
2436
- mock: mock2 = false
2701
+ mock: mock2 = false,
2702
+ filesExpr
2437
2703
  } = opts;
2438
2704
  const asConst = strictRoles ? " as const" : "";
2439
2705
  const historyType = strictRoles ? ": ChatMessage[]" : "";
2440
- const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
2706
+ const fileParts = filesExpr ? `...${filesExpr}.map((attachment) => ({ type: 'file'${asConst}, attachment })), ` : "";
2707
+ const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [${fileParts}{ type: 'text', text: value }] }`;
2441
2708
  const open = toolLoop ? thread.open({ pad, userMessage, typed: strictRoles }) : [
2442
2709
  `${pad}const history${historyType} = [...${read}, ${userMessage}];`,
2443
2710
  `${pad}${commitSet("history")}`
@@ -2585,12 +2852,13 @@ function assertCardToolFormat(integration) {
2585
2852
  return provider;
2586
2853
  }
2587
2854
  function wireImportLines(opts) {
2588
- const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false } = opts;
2855
+ const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false, attachments = false } = opts;
2589
2856
  const stateNames = [
2590
2857
  "createAssistantStream",
2591
2858
  // The mock's canned reply comes from the kit, not from a copy pasted into
2592
2859
  // this file. One implementation, shared with create-kai and the starters.
2593
2860
  ...mock2 ? ["createMockResponder"] : [],
2861
+ ...attachments ? ["type AttachmentData"] : [],
2594
2862
  ...typed ? ["type ChatMessage"] : [],
2595
2863
  ...typed && setMessagesType ? ["type SetMessages"] : []
2596
2864
  ].join(", ");
@@ -2743,6 +3011,68 @@ const WORKSPACE_STRUCTURAL_TAGS = /* @__PURE__ */ new Set(["kai-resizable", "kai
2743
3011
  function isWorkspace(components) {
2744
3012
  return components.includes("kai-resizable") && components.includes("kai-artifact");
2745
3013
  }
3014
+ const ATTACHMENT_TAGS = /* @__PURE__ */ new Set(["kai-file-upload", "kai-attachments"]);
3015
+ function hasAttachments(components) {
3016
+ return components.includes("kai-file-upload") && components.includes("kai-attachments");
3017
+ }
3018
+ const ATTACHMENT_WIRE_NOTE = [
3019
+ "// The staged files ride along on the message as `file` parts, so they RENDER",
3020
+ "// in the thread AND reach the model: toOpenAIMessages / toAnthropicMessages",
3021
+ "// encode each one into the content block its API takes.",
3022
+ "// Which is why toAttachment below stages a `data:` URI rather than a",
3023
+ "// URL.createObjectURL blob: an object URL previews fine and resolves ONLY",
3024
+ "// inside this tab, so the encoder rejects it rather than send an address the",
3025
+ "// provider cannot fetch.",
3026
+ "// What can be sent is not a list worth copying into a comment -- it moves, and",
3027
+ '// a copy cannot. `encodableMediaTypes()` from "@kitn.ai/ui/wire" IS the set,',
3028
+ "// and `resolveMediaPolicy().decide(mediaType)` answers for one file. Anything",
3029
+ "// outside it throws at encode time instead of going missing from the request.",
3030
+ "// A file the browser could not name (a .rs, a .toml) is settled by decoding",
3031
+ "// its bytes, never by its extension."
3032
+ ];
3033
+ function fileToAttachmentLines(pad, typeName) {
3034
+ return [
3035
+ `${pad}/** <kai-file-upload> hands over File objects; <kai-attachments> renders these. */`,
3036
+ `${pad}async function toAttachment(file: File): Promise<${typeName}> {`,
3037
+ `${pad} // A data: URI, NOT URL.createObjectURL. Both preview identically, but an`,
3038
+ `${pad} // object URL resolves only inside this tab, so it can never reach a model —`,
3039
+ `${pad} // the encoder rejects one rather than send an address the provider cannot`,
3040
+ `${pad} // fetch. Reading is async, which is why this function is.`,
3041
+ `${pad} const url = await new Promise<string>((resolve, reject) => {`,
3042
+ `${pad} const reader = new FileReader();`,
3043
+ `${pad} reader.onload = () => resolve(String(reader.result));`,
3044
+ `${pad} reader.onerror = () => reject(reader.error ?? new Error('Could not read ' + file.name));`,
3045
+ `${pad} reader.readAsDataURL(file);`,
3046
+ `${pad} });`,
3047
+ `${pad} return {`,
3048
+ `${pad} id: crypto.randomUUID(),`,
3049
+ `${pad} type: 'file',`,
3050
+ `${pad} filename: file.name,`,
3051
+ `${pad} // '' for an extensionless file, and an empty mediaType would render as a`,
3052
+ `${pad} // blank detail line rather than falling back to the generic icon.`,
3053
+ `${pad} mediaType: file.type || undefined,`,
3054
+ `${pad} url,`,
3055
+ `${pad} };`,
3056
+ `${pad}}`
3057
+ ];
3058
+ }
3059
+ const ATTACHMENT_MERGE_NOTE = [
3060
+ "// <kai-chat> renders its OWN paperclip (there is no prop that hides it), and its",
3061
+ "// staged files arrive on this event as detail.attachments. The dropzone above is",
3062
+ "// the second source. Both are folded in — reading either one alone silently",
3063
+ "// drops files the user really attached."
3064
+ ];
3065
+ function attachmentTurnLines(opts) {
3066
+ const { stagedExpr, clear, fromSubmitEvent } = opts;
3067
+ return [
3068
+ ...fromSubmitEvent ? ATTACHMENT_MERGE_NOTE : [
3069
+ "// This target renders PromptInput directly rather than <kai-chat>, so the",
3070
+ "// dropzone is the only source of staged files — there is no kai-submit detail."
3071
+ ],
3072
+ fromSubmitEvent ? `const files = [...${stagedExpr}, ...e.detail.attachments];` : `const files = [...${stagedExpr}];`,
3073
+ ...clear
3074
+ ];
3075
+ }
2746
3076
  const SAMPLE_AGENTIC_MESSAGE = {
2747
3077
  id: "sample-assistant",
2748
3078
  role: "assistant",
@@ -2779,11 +3109,22 @@ function sampleSeedComment(isMock, pad, decl) {
2779
3109
  }
2780
3110
  function componentTags(components, chatFill) {
2781
3111
  const companionTags = components.filter(
2782
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3112
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(hasAttachments(components) && ATTACHMENT_TAGS.has(t))
2783
3113
  );
2784
3114
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2785
3115
  const hasStandaloneCompanions = companionTags.length > 0;
2786
3116
  const lines = [];
3117
+ if (hasAttachments(components)) {
3118
+ lines.push(
3119
+ ` <!-- Drop files here to stage them for the NEXT message. src/main.ts wires`,
3120
+ ` kai-files-added -> the staged list -> kai-attachments' items property. -->`,
3121
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3122
+ ` <kai-file-upload id="upload" accept="image/*,application/pdf"></kai-file-upload>`,
3123
+ ` <!-- items is a JS PROPERTY (arrays can't be attributes); 'removable' fires kai-remove. -->`,
3124
+ ` <kai-attachments id="attachments" variant="inline" removable></kai-attachments>`,
3125
+ ` </div>`
3126
+ );
3127
+ }
2787
3128
  if (isWorkspace(components)) {
2788
3129
  lines.push(
2789
3130
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
@@ -2826,6 +3167,40 @@ function componentTags(components, chatFill) {
2826
3167
  function htmlModule(ctx, components) {
2827
3168
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2828
3169
  const hasSources = components.includes("kai-sources");
3170
+ const attachments = hasAttachments(components);
3171
+ const attachmentModuleLines = attachments ? [
3172
+ ...ATTACHMENT_WIRE_NOTE,
3173
+ `//`,
3174
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
3175
+ `// <kai-attachments>.items holds and the same one a 'file' message part holds.`,
3176
+ `let staged: AttachmentData[] = [];`,
3177
+ ``,
3178
+ ...fileToAttachmentLines("", "AttachmentData"),
3179
+ ``
3180
+ ] : [];
3181
+ const attachmentSetupLines = attachments ? [
3182
+ ` const uploadEl = document.getElementById('upload') as HTMLElement;`,
3183
+ ` const attachmentsEl = document.getElementById('attachments') as KaiAttachmentsElement;`,
3184
+ ` // Same upgrade rule as <kai-chat>: 'items' is an array, so it is a PROPERTY,`,
3185
+ ` // and a property set before the element upgrades is dropped on upgrade.`,
3186
+ ` await customElements.whenDefined('kai-attachments');`,
3187
+ ` // A NEW array per write — same contract as chat.messages.`,
3188
+ ` const showStaged = () => { attachmentsEl.items = [...staged]; };`,
3189
+ ``,
3190
+ ` uploadEl.addEventListener('kai-files-added', async (event: Event) => {`,
3191
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3192
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3193
+ ` // list by whichever finished reading first.`,
3194
+ ` staged = [...staged, ...(await Promise.all(files.map(toAttachment)))];`,
3195
+ ` showStaged();`,
3196
+ ` });`,
3197
+ ` attachmentsEl.addEventListener('kai-remove', (event: Event) => {`,
3198
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3199
+ ` staged = staged.filter((a) => a.id !== id);`,
3200
+ ` showStaged();`,
3201
+ ` });`,
3202
+ ``
3203
+ ] : [];
2829
3204
  const seedLines = hasEmbedded ? [...sampleSeedComment(ctx.isMock, ` `, (literal) => [`chat.messages = [${literal}];`]), ``] : [];
2830
3205
  const sourcesSetupLines = hasSources ? [
2831
3206
  ` const sourcesEl = document.getElementById('sources') as KaiSourcesElement;`,
@@ -2851,7 +3226,11 @@ function htmlModule(ctx, components) {
2851
3226
  ` chat.cardSchemas = cards.validationSchemas;`,
2852
3227
  ``
2853
3228
  ] : [];
2854
- const elementTypes = hasSources ? "KaiChatElement, KaiSourcesElement" : "KaiChatElement";
3229
+ const elementTypes = [
3230
+ "KaiChatElement",
3231
+ ...hasSources ? ["KaiSourcesElement"] : [],
3232
+ ...attachments ? ["KaiAttachmentsElement"] : []
3233
+ ].join(", ");
2855
3234
  const annotatesChatMessage = !ctx.emitToolLoop;
2856
3235
  const head = [
2857
3236
  `// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
@@ -2870,12 +3249,14 @@ function htmlModule(ctx, components) {
2870
3249
  toolLoop: ctx.emitToolLoop,
2871
3250
  cards: ctx.emitCards,
2872
3251
  cardTools: ctx.cardProvider !== null,
2873
- mock: ctx.isMock
3252
+ mock: ctx.isMock,
3253
+ attachments
2874
3254
  }),
2875
3255
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2876
3256
  ``,
2877
3257
  ...ctx.isMock ? [...mockResponderInit(), ``] : [],
2878
3258
  ...modelLines,
3259
+ ...attachmentModuleLines,
2879
3260
  ...cardsLines,
2880
3261
  ...toolsLines,
2881
3262
  ...runnerLines,
@@ -2891,11 +3272,12 @@ function htmlModule(ctx, components) {
2891
3272
  ``,
2892
3273
  ...cardPropLines,
2893
3274
  ...seedLines,
2894
- ...sourcesSetupLines
3275
+ ...sourcesSetupLines,
3276
+ ...attachmentSetupLines
2895
3277
  ];
2896
3278
  const listenerOpen = [
2897
3279
  ` chat.addEventListener('kai-submit', async (event: Event) => {`,
2898
- ` const e = event as CustomEvent<{ value: string }>;`
3280
+ attachments ? ` const e = event as CustomEvent<{ value: string; attachments: AttachmentData[] }>;` : ` const e = event as CustomEvent<{ value: string }>;`
2899
3281
  ];
2900
3282
  const footer = [
2901
3283
  ` });`,
@@ -2923,7 +3305,15 @@ function htmlModule(ctx, components) {
2923
3305
  "(fn) => { chat.messages = fn(chat.messages); }",
2924
3306
  "chat.messages ?? []"
2925
3307
  ),
2926
- mock: ctx.isMock
3308
+ mock: ctx.isMock,
3309
+ ...attachments ? {
3310
+ filesExpr: "files",
3311
+ afterValue: attachmentTurnLines({
3312
+ stagedExpr: "staged",
3313
+ clear: ["staged = [];", "showStaged();"],
3314
+ fromSubmitEvent: true
3315
+ })
3316
+ } : {}
2927
3317
  }),
2928
3318
  ...footer
2929
3319
  ].join("\n");
@@ -2966,8 +3356,9 @@ function renderJsx(components, ctx, framework) {
2966
3356
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
2967
3357
  const wrapperNames = importTags.map(toPascalCase);
2968
3358
  const importList = wrapperNames.join(", ");
3359
+ const attachments = hasAttachments(components);
2969
3360
  const standaloneCompanionTags = components.filter(
2970
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3361
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
2971
3362
  );
2972
3363
  const companionJsxLines = [];
2973
3364
  if (hasEmbedded) {
@@ -2988,6 +3379,41 @@ function renderJsx(components, ctx, framework) {
2988
3379
  }
2989
3380
  }
2990
3381
  const companions = companionJsxLines.join("\n");
3382
+ const attachmentJsx = attachments ? [
3383
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
3384
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
3385
+ ` <FileUpload accept="image/*,application/pdf" onFilesAdded={onFilesAdded} />`,
3386
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
3387
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
3388
+ ` </div>`
3389
+ ].join("\n") : "";
3390
+ const attachmentModuleInit = attachments ? [
3391
+ ...ATTACHMENT_WIRE_NOTE,
3392
+ `//`,
3393
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
3394
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
3395
+ ...fileToAttachmentLines("", "AttachmentData")
3396
+ ] : [];
3397
+ const attachmentStateInit = attachments ? [
3398
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
3399
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
3400
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3401
+ ` // list by whichever finished reading first.`,
3402
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
3403
+ ` setStaged((prev) => [...prev, ...added]);`,
3404
+ ` };`,
3405
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
3406
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`
3407
+ ].join("\n") : "";
3408
+ const attachmentSubmitOpts = attachments ? {
3409
+ filesExpr: "files",
3410
+ afterValue: attachmentTurnLines({
3411
+ stagedExpr: "staged",
3412
+ clear: ["setStaged([]);"],
3413
+ fromSubmitEvent: true
3414
+ })
3415
+ } : {};
3416
+ const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
2991
3417
  const mockInit = isMock ? mockResponderInit() : [];
2992
3418
  const sampleMessagesInit = [
2993
3419
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
@@ -3022,7 +3448,8 @@ function renderJsx(components, ctx, framework) {
3022
3448
  toolLoop: emitToolLoop,
3023
3449
  cards: ctx.emitCards,
3024
3450
  thread: REACT_THREAD,
3025
- mock: isMock
3451
+ mock: isMock,
3452
+ ...attachmentSubmitOpts
3026
3453
  });
3027
3454
  const useClientDirective = framework === "next" ? [`'use client';`, ``] : [];
3028
3455
  if (framework === "next") {
@@ -3043,7 +3470,8 @@ function renderJsx(components, ctx, framework) {
3043
3470
  setMessagesType: emitToolLoop,
3044
3471
  cards: ctx.emitCards,
3045
3472
  cardTools: ctx.cardProvider !== null,
3046
- mock: isMock
3473
+ mock: isMock,
3474
+ attachments
3047
3475
  }),
3048
3476
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3049
3477
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -3056,22 +3484,25 @@ function renderJsx(components, ctx, framework) {
3056
3484
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3057
3485
  ...mockInit,
3058
3486
  ``,
3487
+ ...attachmentModuleInit,
3059
3488
  ...cardsInit,
3060
3489
  `export default function App() {`,
3061
3490
  sampleMessagesInit,
3062
3491
  ` const [loading, setLoading] = useState(false);`,
3063
3492
  ` const suggestions = ${jsArray(suggestions)};`,
3493
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3064
3494
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3065
3495
  ...modelInit ? [modelInit] : [],
3066
3496
  ...toolsInit ? [toolsInit] : [],
3067
3497
  ...toolRunner ? [toolRunner] : [],
3068
3498
  ``,
3069
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3499
+ ` async function onSubmit(e: ${submitParamType}) {`,
3070
3500
  onSubmitBody,
3071
3501
  ` }`,
3072
3502
  ``,
3073
3503
  ` return (`,
3074
3504
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3505
+ ...attachmentJsx ? [attachmentJsx] : [],
3075
3506
  ...workspace ? [
3076
3507
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3077
3508
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3131,7 +3562,8 @@ function renderJsx(components, ctx, framework) {
3131
3562
  setMessagesType: emitToolLoop,
3132
3563
  cards: ctx.emitCards,
3133
3564
  cardTools: ctx.cardProvider !== null,
3134
- mock: isMock
3565
+ mock: isMock,
3566
+ attachments
3135
3567
  }),
3136
3568
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3137
3569
  ``,
@@ -3140,22 +3572,25 @@ function renderJsx(components, ctx, framework) {
3140
3572
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3141
3573
  ...mockInit,
3142
3574
  ``,
3575
+ ...attachmentModuleInit,
3143
3576
  ...cardsInit,
3144
3577
  `export default function App() {`,
3145
3578
  sampleMessagesInit,
3146
3579
  ` const [loading, setLoading] = useState(false);`,
3147
3580
  ` const suggestions = ${jsArray(suggestions)};`,
3581
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3148
3582
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3149
3583
  ...modelInit ? [modelInit] : [],
3150
3584
  ...toolsInit ? [toolsInit] : [],
3151
3585
  ...toolRunner ? [toolRunner] : [],
3152
3586
  ``,
3153
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3587
+ ` async function onSubmit(e: ${submitParamType}) {`,
3154
3588
  onSubmitBody,
3155
3589
  ` }`,
3156
3590
  ``,
3157
3591
  ` return (`,
3158
3592
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3593
+ ...attachmentJsx ? [attachmentJsx] : [],
3159
3594
  ...workspace ? [
3160
3595
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3161
3596
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3200,8 +3635,9 @@ function renderJsx(components, ctx, framework) {
3200
3635
  function renderVue(components, ctx) {
3201
3636
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3202
3637
  const workspace = isWorkspace(components);
3638
+ const attachments = hasAttachments(components);
3203
3639
  const standaloneCompanionTags = components.filter(
3204
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3640
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3205
3641
  );
3206
3642
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3207
3643
  const companionLines = [];
@@ -3220,6 +3656,42 @@ function renderVue(components, ctx) {
3220
3656
  }
3221
3657
  }
3222
3658
  const companions = companionLines.join("\n");
3659
+ const attachmentTemplate = attachments ? [
3660
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
3661
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3662
+ ` <kai-file-upload accept="image/*,application/pdf" @kai-files-added="onFilesAdded" />`,
3663
+ ` <kai-attachments :items.prop="staged" variant="inline" removable @kai-remove="onRemoveAttachment" />`,
3664
+ ` </div>`
3665
+ ].join("\n") : "";
3666
+ const attachmentScript = attachments ? [
3667
+ ...ATTACHMENT_WIRE_NOTE,
3668
+ `//`,
3669
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
3670
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
3671
+ `const staged = ref<AttachmentData[]>([]);`,
3672
+ ``,
3673
+ ...fileToAttachmentLines("", "AttachmentData"),
3674
+ ``,
3675
+ `async function onFilesAdded(event: Event) {`,
3676
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3677
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3678
+ ` // list by whichever finished reading first.`,
3679
+ ` const added = await Promise.all(files.map(toAttachment));`,
3680
+ ` staged.value = [...staged.value, ...added];`,
3681
+ `}`,
3682
+ `function onRemoveAttachment(event: Event) {`,
3683
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3684
+ ` staged.value = staged.value.filter((a) => a.id !== id);`,
3685
+ `}`
3686
+ ] : [];
3687
+ const attachmentSubmitOpts = attachments ? {
3688
+ filesExpr: "files",
3689
+ afterValue: attachmentTurnLines({
3690
+ stagedExpr: "staged.value",
3691
+ clear: ["staged.value = [];"],
3692
+ fromSubmitEvent: true
3693
+ })
3694
+ } : {};
3223
3695
  const onSubmitBody = realStreamBody({
3224
3696
  pad: " ",
3225
3697
  read: "messages.value",
@@ -3231,7 +3703,8 @@ function renderVue(components, ctx) {
3231
3703
  toolLoop: emitToolLoop,
3232
3704
  cards: ctx.emitCards,
3233
3705
  thread: liveThreadBinding("messages.value", "(fn) => { messages.value = fn(messages.value); }"),
3234
- mock: isMock
3706
+ mock: isMock,
3707
+ ...attachmentSubmitOpts
3235
3708
  });
3236
3709
  const mockInit = isMock ? mockResponderInit() : [];
3237
3710
  const modelInit = defaultModel ? [
@@ -3306,7 +3779,8 @@ function renderVue(components, ctx) {
3306
3779
  toolLoop: emitToolLoop,
3307
3780
  cards: ctx.emitCards,
3308
3781
  cardTools: ctx.cardProvider !== null,
3309
- mock: isMock
3782
+ mock: isMock,
3783
+ attachments
3310
3784
  }),
3311
3785
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3312
3786
  vueImports,
@@ -3321,6 +3795,7 @@ function renderVue(components, ctx) {
3321
3795
  ...toolsLines,
3322
3796
  ...runnerLines,
3323
3797
  ...sourcesSeed,
3798
+ ...attachmentScript,
3324
3799
  ``,
3325
3800
  `// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
3326
3801
  `// bindings can apply before the element upgrades, which drops them — re-apply once`,
@@ -3332,13 +3807,14 @@ function renderVue(components, ctx) {
3332
3807
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(", ")}` : ""} });`,
3333
3808
  `});`,
3334
3809
  ``,
3335
- `async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3810
+ `async function onSubmit(e: ${attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>"}) {`,
3336
3811
  onSubmitBody,
3337
3812
  `}`,
3338
3813
  `<\/script>`,
3339
3814
  ``,
3340
3815
  `<template>`,
3341
3816
  ` <div style="${p.style}">`,
3817
+ ...attachmentTemplate ? [attachmentTemplate] : [],
3342
3818
  ...workspaceTemplate,
3343
3819
  ` </div>`,
3344
3820
  `</template>`
@@ -3347,8 +3823,9 @@ function renderVue(components, ctx) {
3347
3823
  function renderSvelte(components, ctx) {
3348
3824
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3349
3825
  const workspace = isWorkspace(components);
3826
+ const attachments = hasAttachments(components);
3350
3827
  const standaloneCompanionTags = components.filter(
3351
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3828
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3352
3829
  );
3353
3830
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3354
3831
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -3368,6 +3845,14 @@ function renderSvelte(components, ctx) {
3368
3845
  }
3369
3846
  }
3370
3847
  const companionLines = companionLinesList.join("\n");
3848
+ const attachmentSubmitOpts = attachments ? {
3849
+ filesExpr: "files",
3850
+ afterValue: attachmentTurnLines({
3851
+ stagedExpr: "staged",
3852
+ clear: ["staged = [];"],
3853
+ fromSubmitEvent: true
3854
+ })
3855
+ } : {};
3371
3856
  const onSubmitBody = realStreamBody({
3372
3857
  pad: " ",
3373
3858
  read: "messages",
@@ -3379,7 +3864,8 @@ function renderSvelte(components, ctx) {
3379
3864
  toolLoop: emitToolLoop,
3380
3865
  cards: ctx.emitCards,
3381
3866
  thread: liveThreadBinding("messages", "(fn) => { messages = fn(messages); }"),
3382
- mock: isMock
3867
+ mock: isMock,
3868
+ ...attachmentSubmitOpts
3383
3869
  });
3384
3870
  const mockInit = isMock ? mockResponderInit(" ") : [];
3385
3871
  const modelInit = defaultModel ? [
@@ -3405,6 +3891,33 @@ function renderSvelte(components, ctx) {
3405
3891
  ` ];`,
3406
3892
  ` $effect(() => { if (sourcesEl) { sourcesEl.sources = sampleSources; } });`
3407
3893
  ] : [];
3894
+ const attachmentScript = attachments ? [
3895
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
3896
+ ` //`,
3897
+ ` // AttachmentData is the kit's own type for a staged file, and it is what`,
3898
+ ` // <kai-attachments>.items and a 'file' MessagePart both hold.`,
3899
+ ` let attachmentsEl = $state<KaiAttachmentsElement | undefined>(undefined);`,
3900
+ ` let staged = $state.raw<AttachmentData[]>([]);`,
3901
+ ` $effect(() => { if (attachmentsEl && defined) { attachmentsEl.items = staged; } });`,
3902
+ ``,
3903
+ ...fileToAttachmentLines(" ", "AttachmentData"),
3904
+ ` async function onFilesAdded(e: CustomEvent<{ files: File[] }>) {`,
3905
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3906
+ ` // list by whichever finished reading first.`,
3907
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
3908
+ ` staged = [...staged, ...added];`,
3909
+ ` }`,
3910
+ ` function onRemoveAttachment(e: CustomEvent<{ id: string }>) {`,
3911
+ ` staged = staged.filter((a) => a.id !== e.detail.id);`,
3912
+ ` }`
3913
+ ] : [];
3914
+ const attachmentMarkup = attachments ? [
3915
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
3916
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3917
+ ` <kai-file-upload accept="image/*,application/pdf" onkai-files-added={onFilesAdded}></kai-file-upload>`,
3918
+ ` <kai-attachments bind:this={attachmentsEl} variant="inline" removable onkai-remove={onRemoveAttachment}></kai-attachments>`,
3919
+ ` </div>`
3920
+ ] : [];
3408
3921
  const workspaceMarkup = workspace ? [
3409
3922
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
3410
3923
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
@@ -3435,14 +3948,19 @@ function renderSvelte(components, ctx) {
3435
3948
  // KaiSourcesElement is only imported when a kai-sources companion is actually
3436
3949
  // declared below: an always-on import would be unused (and fail noUnusedLocals)
3437
3950
  // on every archetype without kai-sources.
3438
- ` import type { ${hasSourcesCompanion ? "KaiChatElement, KaiSourcesElement" : "KaiChatElement"} } from '@kitn.ai/ui/elements';`,
3951
+ ` import type { ${[
3952
+ "KaiChatElement",
3953
+ ...hasSourcesCompanion ? ["KaiSourcesElement"] : [],
3954
+ ...attachments ? ["KaiAttachmentsElement"] : []
3955
+ ].join(", ")} } from '@kitn.ai/ui/elements';`,
3439
3956
  ...wireImportLines({
3440
3957
  pad: " ",
3441
3958
  typed: true,
3442
3959
  toolLoop: emitToolLoop,
3443
3960
  cards: ctx.emitCards,
3444
3961
  cardTools: ctx.cardProvider !== null,
3445
- mock: isMock
3962
+ mock: isMock,
3963
+ attachments
3446
3964
  }),
3447
3965
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3448
3966
  ` import { onMount } from 'svelte';`,
@@ -3468,13 +3986,15 @@ function renderSvelte(components, ctx) {
3468
3986
  ` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
3469
3987
  ` });`,
3470
3988
  ...sourcesReactive,
3989
+ ...attachmentScript,
3471
3990
  ``,
3472
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3991
+ ` async function onSubmit(e: ${attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>"}) {`,
3473
3992
  onSubmitBody,
3474
3993
  ` }`,
3475
3994
  `<\/script>`,
3476
3995
  ``,
3477
3996
  `<div style="${p.style}">`,
3997
+ ...attachmentMarkup,
3478
3998
  ...workspaceMarkup,
3479
3999
  `</div>`
3480
4000
  ].filter((l, i, arr) => !(l === "" && arr[i - 1] === "" && i === arr.length - 1)).join("\n");
@@ -3487,8 +4007,9 @@ function renderTanstackStart(components, ctx) {
3487
4007
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
3488
4008
  const wrapperNames = importTags.map(toPascalCase);
3489
4009
  const importList = wrapperNames.join(", ");
4010
+ const attachments = hasAttachments(components);
3490
4011
  const standaloneCompanionTags = components.filter(
3491
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4012
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3492
4013
  );
3493
4014
  const companionJsxLines = [];
3494
4015
  if (hasEmbedded) {
@@ -3509,6 +4030,41 @@ function renderTanstackStart(components, ctx) {
3509
4030
  }
3510
4031
  }
3511
4032
  const companions = companionJsxLines.join("\n");
4033
+ const attachmentJsx = attachments ? [
4034
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
4035
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
4036
+ ` <FileUpload accept="image/*,application/pdf" onFilesAdded={onFilesAdded} />`,
4037
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
4038
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
4039
+ ` </div>`
4040
+ ].join("\n") : "";
4041
+ const attachmentModuleInit = attachments ? [
4042
+ ...ATTACHMENT_WIRE_NOTE,
4043
+ `//`,
4044
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
4045
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
4046
+ ...fileToAttachmentLines("", "AttachmentData")
4047
+ ] : [];
4048
+ const attachmentStateInit = attachments ? [
4049
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
4050
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
4051
+ ` // Read every file BEFORE appending: a per-file append would order the`,
4052
+ ` // list by whichever finished reading first.`,
4053
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
4054
+ ` setStaged((prev) => [...prev, ...added]);`,
4055
+ ` };`,
4056
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
4057
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`
4058
+ ].join("\n") : "";
4059
+ const attachmentSubmitOpts = attachments ? {
4060
+ filesExpr: "files",
4061
+ afterValue: attachmentTurnLines({
4062
+ stagedExpr: "staged",
4063
+ clear: ["setStaged([]);"],
4064
+ fromSubmitEvent: true
4065
+ })
4066
+ } : {};
4067
+ const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
3512
4068
  const mockInit = isMock ? mockResponderInit() : [];
3513
4069
  const sampleMessagesInit = [
3514
4070
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
@@ -3543,7 +4099,8 @@ function renderTanstackStart(components, ctx) {
3543
4099
  toolLoop: emitToolLoop,
3544
4100
  cards: ctx.emitCards,
3545
4101
  thread: REACT_THREAD,
3546
- mock: isMock
4102
+ mock: isMock,
4103
+ ...attachmentSubmitOpts
3547
4104
  });
3548
4105
  const filePathNote = [
3549
4106
  `// TanStack Start route file — save as: src/routes/chat.tsx`,
@@ -3573,7 +4130,8 @@ function renderTanstackStart(components, ctx) {
3573
4130
  setMessagesType: emitToolLoop,
3574
4131
  cards: ctx.emitCards,
3575
4132
  cardTools: ctx.cardProvider !== null,
3576
- mock: isMock
4133
+ mock: isMock,
4134
+ attachments
3577
4135
  }),
3578
4136
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
3579
4137
  ``,
@@ -3581,6 +4139,7 @@ function renderTanstackStart(components, ctx) {
3581
4139
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3582
4140
  ...mockInit,
3583
4141
  ``,
4142
+ ...attachmentModuleInit,
3584
4143
  ...cardsInit,
3585
4144
  `// ssr: false keeps the Solid-based web component client-only.`,
3586
4145
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -3593,17 +4152,19 @@ function renderTanstackStart(components, ctx) {
3593
4152
  sampleMessagesInit,
3594
4153
  ` const [loading, setLoading] = useState(false);`,
3595
4154
  ` const suggestions = ${jsArray(suggestions)};`,
4155
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3596
4156
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3597
4157
  ...modelInit ? [modelInit] : [],
3598
4158
  ...toolsInit ? [toolsInit] : [],
3599
4159
  ...toolRunner ? [toolRunner] : [],
3600
4160
  ``,
3601
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
4161
+ ` async function onSubmit(e: ${submitParamType}) {`,
3602
4162
  onSubmitBody,
3603
4163
  ` }`,
3604
4164
  ``,
3605
4165
  ` return (`,
3606
4166
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
4167
+ ...attachmentJsx ? [attachmentJsx] : [],
3607
4168
  ...workspace ? [
3608
4169
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3609
4170
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3648,8 +4209,9 @@ function renderTanstackStart(components, ctx) {
3648
4209
  function renderAngular(components, ctx) {
3649
4210
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3650
4211
  const workspace = isWorkspace(components);
4212
+ const attachments = hasAttachments(components);
3651
4213
  const standaloneCompanionTags = components.filter(
3652
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4214
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3653
4215
  );
3654
4216
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3655
4217
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -3670,6 +4232,46 @@ function renderAngular(components, ctx) {
3670
4232
  companionLines.push(` <${t}></${t}>`);
3671
4233
  }
3672
4234
  }
4235
+ const attachmentTemplate = attachments ? [
4236
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
4237
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
4238
+ ` <kai-file-upload accept="image/*,application/pdf" (kai-files-added)="onFilesAdded($event)"></kai-file-upload>`,
4239
+ ` <kai-attachments [items]="staged()" variant="inline" removable (kai-remove)="onRemoveAttachment($event)"></kai-attachments>`,
4240
+ ` </div>`
4241
+ ] : [];
4242
+ const attachmentModuleInit = attachments ? [
4243
+ ...ATTACHMENT_WIRE_NOTE,
4244
+ `//`,
4245
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
4246
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
4247
+ ...fileToAttachmentLines("", "AttachmentData"),
4248
+ ``
4249
+ ] : [];
4250
+ const attachmentFields = attachments ? [
4251
+ ` readonly staged = signal<AttachmentData[]>([]);`,
4252
+ ``,
4253
+ ` // Same Event-not-CustomEvent rule as onSubmit below: strictTemplates types`,
4254
+ ` // $event on an unknown custom-element event as a plain Event.`,
4255
+ ` async onFilesAdded(event: Event) {`,
4256
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
4257
+ ` // Read every file BEFORE appending: a per-file append would order the`,
4258
+ ` // list by whichever finished reading first.`,
4259
+ ` const added = await Promise.all(files.map(toAttachment));`,
4260
+ ` this.staged.set([...this.staged(), ...added]);`,
4261
+ ` }`,
4262
+ ` onRemoveAttachment(event: Event) {`,
4263
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
4264
+ ` this.staged.set(this.staged().filter((a) => a.id !== id));`,
4265
+ ` }`
4266
+ ] : [];
4267
+ const attachmentSubmitOpts = attachments ? {
4268
+ filesExpr: "files",
4269
+ afterValue: attachmentTurnLines({
4270
+ stagedExpr: "this.staged()",
4271
+ clear: ["this.staged.set([]);"],
4272
+ fromSubmitEvent: true
4273
+ })
4274
+ } : {};
3673
4275
  const read = "this.messages()";
3674
4276
  const commit = (value) => `this.messages.set(${value});`;
3675
4277
  const setter = "(fn) => this.messages.set(fn(this.messages()))";
@@ -3684,7 +4286,8 @@ function renderAngular(components, ctx) {
3684
4286
  toolLoop: emitToolLoop,
3685
4287
  cards: ctx.emitCards,
3686
4288
  thread: accessorThreadBinding(read, commit, setter),
3687
- mock: isMock
4289
+ mock: isMock,
4290
+ ...attachmentSubmitOpts
3688
4291
  });
3689
4292
  const modelInit = defaultModel ? [
3690
4293
  `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
@@ -3760,7 +4363,8 @@ function renderAngular(components, ctx) {
3760
4363
  toolLoop: emitToolLoop,
3761
4364
  cards: ctx.emitCards,
3762
4365
  cardTools: ctx.cardProvider !== null,
3763
- mock: isMock
4366
+ mock: isMock,
4367
+ attachments
3764
4368
  }),
3765
4369
  ``,
3766
4370
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -3768,6 +4372,7 @@ function renderAngular(components, ctx) {
3768
4372
  ...isMock ? mockResponderInit() : [],
3769
4373
  ``,
3770
4374
  ...modelInit,
4375
+ ...attachmentModuleInit,
3771
4376
  ...cardsInit,
3772
4377
  ...toolsLines,
3773
4378
  ...runnerLines,
@@ -3779,6 +4384,7 @@ function renderAngular(components, ctx) {
3779
4384
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
3780
4385
  ` template: \``,
3781
4386
  ` <div style="${p.style}">`,
4387
+ ...attachmentTemplate,
3782
4388
  ...templateBody,
3783
4389
  ` </div>`,
3784
4390
  ` \`,`,
@@ -3811,11 +4417,13 @@ function renderAngular(components, ctx) {
3811
4417
  ` });`,
3812
4418
  ` }`,
3813
4419
  ``,
4420
+ ...attachmentFields,
4421
+ ``,
3814
4422
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
3815
4423
  ` // an unknown custom-element event as a plain Event, so the narrowing happens`,
3816
4424
  ` // here rather than in the signature.`,
3817
4425
  ` async onSubmit(event: Event) {`,
3818
- ` const e = event as CustomEvent<{ value: string }>;`,
4426
+ ` const e = event as CustomEvent<{ value: string${attachments ? "; attachments: AttachmentData[]" : ""} }>;`,
3819
4427
  onSubmitBody,
3820
4428
  ` }`,
3821
4429
  `}`
@@ -3824,8 +4432,9 @@ function renderAngular(components, ctx) {
3824
4432
  function renderSolid(components, ctx) {
3825
4433
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3826
4434
  const workspace = isWorkspace(components);
4435
+ const attachments = hasAttachments(components);
3827
4436
  const standaloneCompanionTags = components.filter(
3828
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4437
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3829
4438
  );
3830
4439
  const hasSources = standaloneCompanionTags.includes("kai-sources");
3831
4440
  const hasVoice = standaloneCompanionTags.includes("kai-voice-input");
@@ -3856,7 +4465,13 @@ function renderSolid(components, ctx) {
3856
4465
  "SourceTrigger",
3857
4466
  "Tool",
3858
4467
  ...workspace ? ["Artifact", "ResizableHandle", "ResizablePanel", "ResizablePanelGroup"] : [],
3859
- ...hasVoice ? ["VoiceInput"] : []
4468
+ ...hasVoice ? ["VoiceInput"] : [],
4469
+ // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
4470
+ // unconditional above — `renderPart` draws `file` PARTS with them whatever the
4471
+ // surface asked for. These three are the STAGING half, which only this
4472
+ // capability composes: the dropzone, its trigger, and the per-chip remove
4473
+ // button `renderPart` has no use for (a sent file is not removable).
4474
+ ...attachments ? ["AttachmentRemove", "FileUpload", "FileUploadTrigger"] : []
3860
4475
  ].sort();
3861
4476
  const read = "messages()";
3862
4477
  const commit = (value) => `setMessages(${value});`;
@@ -3873,7 +4488,17 @@ function renderSolid(components, ctx) {
3873
4488
  cards: ctx.emitCards,
3874
4489
  thread: accessorThreadBinding(read, commit, setter),
3875
4490
  valueSource: "input()",
3876
- afterValue: [`setInput('');`],
4491
+ // The staged files are captured BEFORE either list is cleared. `setInput('')`
4492
+ // stays last so the order reads the way it executes.
4493
+ afterValue: [
4494
+ ...attachments ? attachmentTurnLines({
4495
+ stagedExpr: "staged()",
4496
+ clear: ["setStaged([]);"],
4497
+ fromSubmitEvent: false
4498
+ }) : [],
4499
+ `setInput('');`
4500
+ ],
4501
+ ...attachments ? { filesExpr: "files" } : {},
3877
4502
  mock: isMock
3878
4503
  });
3879
4504
  const modelInit = defaultModel ? [
@@ -3887,6 +4512,11 @@ function renderSolid(components, ctx) {
3887
4512
  ` types={cards.components}`,
3888
4513
  ` schemas={cards.validationSchemas}`
3889
4514
  ] : [];
4515
+ const attachmentInit = attachments ? [
4516
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
4517
+ ` const [staged, setStaged] = createSignal<AttachmentData[]>([]);`,
4518
+ ...fileToAttachmentLines(" ", "AttachmentData")
4519
+ ] : [];
3890
4520
  const sourcesInit = hasSources ? [
3891
4521
  ` // Replace sampleSources with your real source data.`,
3892
4522
  ` const sampleSources = [`,
@@ -3962,6 +4592,35 @@ function renderSolid(components, ctx) {
3962
4592
  ` </For>`,
3963
4593
  ` </div>`,
3964
4594
  ` </Show>`,
4595
+ ...attachments ? [
4596
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
4597
+ ` <div class="flex flex-col gap-2 pb-3">`,
4598
+ ` {/* Read every file BEFORE appending: a per-file append would order`,
4599
+ ` the list by whichever finished reading first. */}`,
4600
+ ` <FileUpload`,
4601
+ ` accept="image/*,application/pdf"`,
4602
+ ` onFilesAdded={async (files) => setStaged([...staged(), ...(await Promise.all(files.map(toAttachment)))])}`,
4603
+ ` >`,
4604
+ ` <FileUploadTrigger class="border-border text-muted-foreground w-full rounded-xl border border-dashed px-4 py-3 text-center text-sm">`,
4605
+ ` Click or drop files to attach`,
4606
+ ` </FileUploadTrigger>`,
4607
+ ` </FileUpload>`,
4608
+ ` {/* The staged chips. onRemove is what makes <AttachmentRemove> draw its button. */}`,
4609
+ ` <Show when={staged().length > 0}>`,
4610
+ ` <Attachments variant="inline">`,
4611
+ ` <For each={staged()}>`,
4612
+ ` {(a) => (`,
4613
+ ` <Attachment data={a} onRemove={() => setStaged(staged().filter((x) => x.id !== a.id))}>`,
4614
+ ` <AttachmentPreview />`,
4615
+ ` <AttachmentInfo />`,
4616
+ ` <AttachmentRemove />`,
4617
+ ` </Attachment>`,
4618
+ ` )}`,
4619
+ ` </For>`,
4620
+ ` </Attachments>`,
4621
+ ` </Show>`,
4622
+ ` </div>`
4623
+ ] : [],
3965
4624
  ` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
3966
4625
  ` <div class="flex flex-col">`,
3967
4626
  ` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
@@ -4026,11 +4685,25 @@ function renderSolid(components, ctx) {
4026
4685
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
4027
4686
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
4028
4687
  `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
4688
+ // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
4689
+ // invisible to every compiler on this repo's critical path: src/solid.ts is
4690
+ // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
4691
+ // both specifiers typecheck identically. verify:scaffold's solid project
4692
+ // compiled clean the whole time this was wrong.
4693
+ //
4694
+ // The root is the shared layer EVERY consumer resolves — React, Vue, Svelte
4695
+ // and vanilla included — so it deliberately carries only the chat components,
4696
+ // types and helpers those frameworks can use. The rest of the Solid catalog
4697
+ // lives on ./solid, built as its own bundle so a React app never pays for it.
4698
+ // A Solid app pointed at the root therefore gets the smaller surface and finds
4699
+ // out the hard way, one missing export at a time. The published guide says so
4700
+ // outright ("Import Solid components from @kitn.ai/ui/solid, not from the root
4701
+ // @kitn.ai/ui"), and this generator contradicted it for every Solid scaffold.
4029
4702
  `import {`,
4030
4703
  ...componentImports.map((n) => ` ${n},`),
4031
- `} from '@kitn.ai/ui';`,
4704
+ `} from '@kitn.ai/ui/solid';`,
4032
4705
  `// The kit's own types, from the same entry the components come from.`,
4033
- `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
4706
+ `import type { ${attachments ? "AttachmentData, ChatMessage, MessagePart, MessageSource" : "ChatMessage, MessagePart, MessageSource"} } from '@kitn.ai/ui/solid';`,
4034
4707
  ...wireImportLines({
4035
4708
  typed: false,
4036
4709
  toolLoop: emitToolLoop,
@@ -4230,6 +4903,7 @@ function renderSolid(components, ctx) {
4230
4903
  ` // value, no row moves, and the content updates through accessors instead.`,
4231
4904
  ` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
4232
4905
  ` const suggestions = ${jsArray(suggestions)};`,
4906
+ ...attachmentInit,
4233
4907
  ...sourcesInit,
4234
4908
  ...modelInit,
4235
4909
  ...toolsLines,
@@ -4676,12 +5350,72 @@ const CHAT_REQUEST_BODY_DECL = [
4676
5350
  ` return (await request.json()) as ChatRequestBody;`,
4677
5351
  `}`
4678
5352
  ];
5353
+ const CONTENT_PARTS_DECL = [
5354
+ `/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
5355
+ ` * fetches. Never both. */`,
5356
+ `type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
5357
+ ``,
5358
+ `/** One piece of a turn, with the string and array content forms flattened into`,
5359
+ ` * a single shape. */`,
5360
+ `type WirePart =`,
5361
+ ` | { kind: 'text'; text: string }`,
5362
+ ` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
5363
+ ``,
5364
+ `const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
5365
+ ``,
5366
+ `/**`,
5367
+ ` * Flatten a wire message's content into parts.`,
5368
+ ` *`,
5369
+ ` * An image sent by URL has no media type here — \`image_url\` carries only the`,
5370
+ ` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
5371
+ ` * source needs. Only images can reach that branch: the kit refuses to encode a`,
5372
+ ` * remote PDF rather than guess at one.`,
5373
+ ` */`,
5374
+ `function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
5375
+ ` if (content == null) return [];`,
5376
+ ` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
5377
+ ` return content.map((part): WirePart => {`,
5378
+ ` if (part.type === 'text') return { kind: 'text', text: part.text };`,
5379
+ ` if (part.type === 'image_url') {`,
5380
+ ` const asData = DATA_URI.exec(part.image_url.url);`,
5381
+ ` return asData`,
5382
+ ` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
5383
+ ` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
5384
+ ` }`,
5385
+ ` const asData = DATA_URI.exec(part.file.file_data);`,
5386
+ ` if (!asData) {`,
5387
+ ` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
5388
+ ` // cannot be turned into bytes without fetching it, and forwarding a turn`,
5389
+ ` // with the attachment quietly missing is the bug this whole path exists`,
5390
+ ` // to prevent.`,
5391
+ ` throw new Error(`,
5392
+ ` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
5393
+ ` );`,
5394
+ ` }`,
5395
+ ` return {`,
5396
+ ` kind: 'file',`,
5397
+ ` mediaType: asData[1],`,
5398
+ ` filename: part.file.filename,`,
5399
+ ` source: { type: 'data', data: asData[2] },`,
5400
+ ` };`,
5401
+ ` });`,
5402
+ `}`,
5403
+ ``,
5404
+ `/** Just the text of a turn. System, assistant and tool messages are text-only`,
5405
+ ` * on this wire, so this collapses the array form for them. */`,
5406
+ `function wireText(content: OpenAIWireMessage['content']): string {`,
5407
+ ` return wireParts(content)`,
5408
+ ` .map((p) => (p.kind === 'text' ? p.text : ''))`,
5409
+ ` .join('');`,
5410
+ `}`
5411
+ ];
4679
5412
  function withChatRequestBody(fragment) {
5413
+ const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment) ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL] : CHAT_REQUEST_BODY_DECL;
4680
5414
  const lines = fragment.split("\n");
4681
5415
  let at = lines.findIndex((l) => /^(?:export\s+)?async function chatHandler\b/.test(l));
4682
- if (at < 0) return [...CHAT_REQUEST_BODY_DECL, ``, ...lines].join("\n");
5416
+ if (at < 0) return [...decl, ``, ...lines].join("\n");
4683
5417
  while (at > 0 && /^\s*(?:\/\/|\/\*|\*)/.test(lines[at - 1])) at -= 1;
4684
- return [...lines.slice(0, at), ...CHAT_REQUEST_BODY_DECL, ``, ...lines.slice(at)].join("\n");
5418
+ return [...lines.slice(0, at), ...decl, ``, ...lines.slice(at)].join("\n");
4685
5419
  }
4686
5420
  function webRouteFor(integration, framework) {
4687
5421
  const fragment = integration.webRoute;
@@ -5067,7 +5801,7 @@ function rejectUseCase(id) {
5067
5801
  ``,
5068
5802
  `Valid useCases (presets): ${valid}.`,
5069
5803
  ``,
5070
- // The presets are six points, not the space. A harness that only ever learns
5804
+ // The presets are seven points, not the space. A harness that only ever learns
5071
5805
  // the six ids will ask for the nearest one instead of the surface it wants,
5072
5806
  // so the rejection that teaches the id list is the right place to say so.
5073
5807
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
@@ -5086,14 +5820,14 @@ const scaffold = {
5086
5820
  // catalogs to discover valid ids.
5087
5821
  //
5088
5822
  // `useCase` is OPTIONAL because `components` can carry the surface instead —
5089
- // the archetypes are six points in the components space, not the space. A
5823
+ // the archetypes are seven points in the components space, not the space. A
5090
5824
  // request must still name one of the two, and the handler says so when it
5091
5825
  // names neither.
5092
5826
  useCase: z$1.string().optional().describe(
5093
- 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice". Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.'
5827
+ 'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice", "attachments". Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.'
5094
5828
  ),
5095
5829
  components: z$1.array(z$1.string()).optional().describe(
5096
- 'The kai-* components this surface composes, e.g. ["kai-chat", "kai-tool", "kai-reasoning", "kai-artifact", "kai-resizable"]. The real axis: any combination is renderable, not just the six presets. Include "kai-chat". Wins over `useCase` when both are given.'
5830
+ 'The kai-* components this surface composes, e.g. ["kai-chat", "kai-tool", "kai-reasoning", "kai-artifact", "kai-resizable"]. The real axis: any combination is renderable, not just the seven presets. Include "kai-chat". Wins over `useCase` when both are given.'
5097
5831
  ),
5098
5832
  integration: z$1.string().describe(
5099
5833
  'Backend integration id, e.g. "openrouter", "vercel-ai-sdk", "langgraph", "cloudflare", "ollama", "mastra", "pi", "pydantic-ai".'