@kitn.ai/ui 0.21.0 → 0.22.1

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 +890 -78
  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 +4 -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 +913 -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 +234 -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
@@ -737,14 +737,14 @@ Call component_reference with no name (or name: "list") to list all ${all.length
737
737
  lines.push(`- **${slot.name}**${desc ? ` — ${desc}` : ""}`);
738
738
  }
739
739
  }
740
- const parts = el.cssParts ?? [];
741
- if (parts.length > 0) {
740
+ const parts2 = el.cssParts ?? [];
741
+ if (parts2.length > 0) {
742
742
  lines.push(
743
743
  "",
744
744
  "### Styleable parts (`::part`)",
745
745
  "Restyle these from outside the Shadow DOM via `" + tag + "::part(name) { … }`."
746
746
  );
747
- for (const part of parts) {
747
+ for (const part of parts2) {
748
748
  const desc = part.description?.trim() ?? "";
749
749
  lines.push(`- **${part.name}**${desc ? ` — ${desc}` : ""}`);
750
750
  if (part.recipe) {
@@ -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 = [
@@ -2329,6 +2594,83 @@ function listIntegrations() {
2329
2594
  function listArchetypes() {
2330
2595
  return archetypes;
2331
2596
  }
2597
+ const ENCODABLE = [
2598
+ { pattern: "image/jpeg", kind: "image" },
2599
+ { pattern: "image/png", kind: "image" },
2600
+ { pattern: "image/gif", kind: "image" },
2601
+ { pattern: "image/webp", kind: "image" },
2602
+ { pattern: "application/pdf", kind: "document" },
2603
+ { pattern: "text/*", kind: "text" },
2604
+ { pattern: "application/json", kind: "text" },
2605
+ { pattern: "application/xml", kind: "text" },
2606
+ { pattern: "application/x-yaml", kind: "text" },
2607
+ { pattern: "application/yaml", kind: "text" }
2608
+ ];
2609
+ const UNNAMED_TEXT_MEDIA_TYPE = "text/plain";
2610
+ const NAMES_NOTHING = ["", "application/octet-stream"];
2611
+ const normalize = (value) => value.trim().toLowerCase();
2612
+ const namesNothing = (mediaType) => NAMES_NOTHING.includes(mediaType === void 0 ? "" : normalize(mediaType));
2613
+ const assertMediaType = (entry) => {
2614
+ if (!entry.startsWith(".")) return entry;
2615
+ throw new Error(
2616
+ `\`accept\` entry "${entry}" is a file extension, and this kit takes media types only. HTML's own accept attribute does take extensions, which is exactly why this throws rather than quietly matching nothing. Use a media type instead -- "text/*" covers source files, "image/*" images, "application/pdf" PDFs, and \`encodableMediaTypes()\` is the full set. You lose nothing by dropping the extension: a file the browser cannot name is decided by decoding its bytes, which is stronger than matching its name.`
2617
+ );
2618
+ };
2619
+ const splitFilter = (filter) => (typeof filter === "string" ? filter.split(",") : filter).map(normalize).filter((v2) => v2 !== "").map(assertMediaType);
2620
+ const parts = (pattern) => {
2621
+ const slash = pattern.indexOf("/");
2622
+ return slash === -1 ? [pattern, ""] : [pattern.slice(0, slash), pattern.slice(slash + 1)];
2623
+ };
2624
+ const matches = (pattern, mediaType) => {
2625
+ const [pType, pSub] = parts(pattern);
2626
+ const [mType, mSub] = parts(mediaType);
2627
+ if (pType !== "*" && pType !== mType) return false;
2628
+ return pSub === "*" || pSub === mSub;
2629
+ };
2630
+ const intersect = (capability, filter) => {
2631
+ if (matches(capability, filter)) return filter;
2632
+ if (matches(filter, capability)) return capability;
2633
+ return void 0;
2634
+ };
2635
+ function encodableMediaTypes() {
2636
+ return ENCODABLE.map((entry) => entry.pattern);
2637
+ }
2638
+ function resolveMediaPolicy(options = {}) {
2639
+ const filter = options.accept === void 0 ? void 0 : splitFilter(options.accept);
2640
+ const effective = [];
2641
+ for (const capability of ENCODABLE) {
2642
+ if (filter === void 0) {
2643
+ effective.push({ ...capability });
2644
+ continue;
2645
+ }
2646
+ for (const wanted of filter) {
2647
+ const overlap = intersect(capability.pattern, wanted);
2648
+ if (overlap !== void 0 && !effective.some((e) => e.pattern === overlap)) {
2649
+ effective.push({ pattern: overlap, kind: capability.kind });
2650
+ }
2651
+ }
2652
+ }
2653
+ return {
2654
+ types: effective.map((e) => e.pattern),
2655
+ accept: effective.map((e) => e.pattern).join(","),
2656
+ decide(mediaType) {
2657
+ const type = mediaType === void 0 ? "" : normalize(mediaType);
2658
+ if (namesNothing(type)) {
2659
+ return effective.some((e) => matches(e.pattern, UNNAMED_TEXT_MEDIA_TYPE)) ? { status: "undetermined" } : (
2660
+ // `unsupported` rather than `filtered`, deliberately: `filtered`
2661
+ // claims the kit COULD have encoded this file, and nobody has
2662
+ // established what it is. Unread and unnamed, it is not a text
2663
+ // file the filter excluded -- it is a file with no known type.
2664
+ { status: "unsupported" }
2665
+ );
2666
+ }
2667
+ const hit = effective.find((e) => matches(e.pattern, type));
2668
+ if (hit) return { status: "allowed", kind: hit.kind };
2669
+ return ENCODABLE.some((e) => matches(e.pattern, type)) ? { status: "filtered" } : { status: "unsupported" };
2670
+ }
2671
+ };
2672
+ }
2673
+ resolveMediaPolicy();
2332
2674
  const text = (s) => ({
2333
2675
  content: [{ type: "text", text: s }]
2334
2676
  });
@@ -2433,11 +2775,13 @@ function realStreamBody(opts) {
2433
2775
  cards = false,
2434
2776
  valueSource = "e.detail.value",
2435
2777
  afterValue = [],
2436
- mock: mock2 = false
2778
+ mock: mock2 = false,
2779
+ filesExpr
2437
2780
  } = opts;
2438
2781
  const asConst = strictRoles ? " as const" : "";
2439
2782
  const historyType = strictRoles ? ": ChatMessage[]" : "";
2440
- const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
2783
+ const fileParts = filesExpr ? `...${filesExpr}.map((attachment) => ({ type: 'file'${asConst}, attachment })), ` : "";
2784
+ const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [${fileParts}{ type: 'text', text: value }] }`;
2441
2785
  const open = toolLoop ? thread.open({ pad, userMessage, typed: strictRoles }) : [
2442
2786
  `${pad}const history${historyType} = [...${read}, ${userMessage}];`,
2443
2787
  `${pad}${commitSet("history")}`
@@ -2585,12 +2929,13 @@ function assertCardToolFormat(integration) {
2585
2929
  return provider;
2586
2930
  }
2587
2931
  function wireImportLines(opts) {
2588
- const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false } = opts;
2932
+ const { pad = "", typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock: mock2 = false, attachments = false } = opts;
2589
2933
  const stateNames = [
2590
2934
  "createAssistantStream",
2591
2935
  // The mock's canned reply comes from the kit, not from a copy pasted into
2592
2936
  // this file. One implementation, shared with create-kai and the starters.
2593
2937
  ...mock2 ? ["createMockResponder"] : [],
2938
+ ...attachments ? ["type AttachmentData"] : [],
2594
2939
  ...typed ? ["type ChatMessage"] : [],
2595
2940
  ...typed && setMessagesType ? ["type SetMessages"] : []
2596
2941
  ].join(", ");
@@ -2743,6 +3088,69 @@ const WORKSPACE_STRUCTURAL_TAGS = /* @__PURE__ */ new Set(["kai-resizable", "kai
2743
3088
  function isWorkspace(components) {
2744
3089
  return components.includes("kai-resizable") && components.includes("kai-artifact");
2745
3090
  }
3091
+ const ATTACHMENT_TAGS = /* @__PURE__ */ new Set(["kai-file-upload", "kai-attachments"]);
3092
+ function hasAttachments(components) {
3093
+ return components.includes("kai-file-upload") && components.includes("kai-attachments");
3094
+ }
3095
+ const ATTACHMENT_ACCEPT = encodableMediaTypes().join(",");
3096
+ const ATTACHMENT_WIRE_NOTE = [
3097
+ "// The staged files ride along on the message as `file` parts, so they RENDER",
3098
+ "// in the thread AND reach the model: toOpenAIMessages / toAnthropicMessages",
3099
+ "// encode each one into the content block its API takes.",
3100
+ "// Which is why toAttachment below stages a `data:` URI rather than a",
3101
+ "// URL.createObjectURL blob: an object URL previews fine and resolves ONLY",
3102
+ "// inside this tab, so the encoder rejects it rather than send an address the",
3103
+ "// provider cannot fetch.",
3104
+ "// What can be sent is not a list worth copying into a comment -- it moves, and",
3105
+ '// a copy cannot. `encodableMediaTypes()` from "@kitn.ai/ui/wire" IS the set,',
3106
+ "// and `resolveMediaPolicy().decide(mediaType)` answers for one file. Anything",
3107
+ "// outside it throws at encode time instead of going missing from the request.",
3108
+ "// A file the browser could not name (a .rs, a .toml) is settled by decoding",
3109
+ "// its bytes, never by its extension."
3110
+ ];
3111
+ function fileToAttachmentLines(pad, typeName) {
3112
+ return [
3113
+ `${pad}/** <kai-file-upload> hands over File objects; <kai-attachments> renders these. */`,
3114
+ `${pad}async function toAttachment(file: File): Promise<${typeName}> {`,
3115
+ `${pad} // A data: URI, NOT URL.createObjectURL. Both preview identically, but an`,
3116
+ `${pad} // object URL resolves only inside this tab, so it can never reach a model —`,
3117
+ `${pad} // the encoder rejects one rather than send an address the provider cannot`,
3118
+ `${pad} // fetch. Reading is async, which is why this function is.`,
3119
+ `${pad} const url = await new Promise<string>((resolve, reject) => {`,
3120
+ `${pad} const reader = new FileReader();`,
3121
+ `${pad} reader.onload = () => resolve(String(reader.result));`,
3122
+ `${pad} reader.onerror = () => reject(reader.error ?? new Error('Could not read ' + file.name));`,
3123
+ `${pad} reader.readAsDataURL(file);`,
3124
+ `${pad} });`,
3125
+ `${pad} return {`,
3126
+ `${pad} id: crypto.randomUUID(),`,
3127
+ `${pad} type: 'file',`,
3128
+ `${pad} filename: file.name,`,
3129
+ `${pad} // '' for an extensionless file, and an empty mediaType would render as a`,
3130
+ `${pad} // blank detail line rather than falling back to the generic icon.`,
3131
+ `${pad} mediaType: file.type || undefined,`,
3132
+ `${pad} url,`,
3133
+ `${pad} };`,
3134
+ `${pad}}`
3135
+ ];
3136
+ }
3137
+ const ATTACHMENT_MERGE_NOTE = [
3138
+ "// <kai-chat> renders its OWN paperclip (there is no prop that hides it), and its",
3139
+ "// staged files arrive on this event as detail.attachments. The dropzone above is",
3140
+ "// the second source. Both are folded in — reading either one alone silently",
3141
+ "// drops files the user really attached."
3142
+ ];
3143
+ function attachmentTurnLines(opts) {
3144
+ const { stagedExpr, clear, fromSubmitEvent } = opts;
3145
+ return [
3146
+ ...fromSubmitEvent ? ATTACHMENT_MERGE_NOTE : [
3147
+ "// This target renders PromptInput directly rather than <kai-chat>, so the",
3148
+ "// dropzone is the only source of staged files — there is no kai-submit detail."
3149
+ ],
3150
+ fromSubmitEvent ? `const files = [...${stagedExpr}, ...e.detail.attachments];` : `const files = [...${stagedExpr}];`,
3151
+ ...clear
3152
+ ];
3153
+ }
2746
3154
  const SAMPLE_AGENTIC_MESSAGE = {
2747
3155
  id: "sample-assistant",
2748
3156
  role: "assistant",
@@ -2779,11 +3187,22 @@ function sampleSeedComment(isMock, pad, decl) {
2779
3187
  }
2780
3188
  function componentTags(components, chatFill) {
2781
3189
  const companionTags = components.filter(
2782
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3190
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(hasAttachments(components) && ATTACHMENT_TAGS.has(t))
2783
3191
  );
2784
3192
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2785
3193
  const hasStandaloneCompanions = companionTags.length > 0;
2786
3194
  const lines = [];
3195
+ if (hasAttachments(components)) {
3196
+ lines.push(
3197
+ ` <!-- Drop files here to stage them for the NEXT message. src/main.ts wires`,
3198
+ ` kai-files-added -> the staged list -> kai-attachments' items property. -->`,
3199
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3200
+ ` <kai-file-upload id="upload" accept="${ATTACHMENT_ACCEPT}"></kai-file-upload>`,
3201
+ ` <!-- items is a JS PROPERTY (arrays can't be attributes); 'removable' fires kai-remove. -->`,
3202
+ ` <kai-attachments id="attachments" variant="inline" removable></kai-attachments>`,
3203
+ ` </div>`
3204
+ );
3205
+ }
2787
3206
  if (isWorkspace(components)) {
2788
3207
  lines.push(
2789
3208
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
@@ -2826,6 +3245,40 @@ function componentTags(components, chatFill) {
2826
3245
  function htmlModule(ctx, components) {
2827
3246
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
2828
3247
  const hasSources = components.includes("kai-sources");
3248
+ const attachments = hasAttachments(components);
3249
+ const attachmentModuleLines = attachments ? [
3250
+ ...ATTACHMENT_WIRE_NOTE,
3251
+ `//`,
3252
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
3253
+ `// <kai-attachments>.items holds and the same one a 'file' message part holds.`,
3254
+ `let staged: AttachmentData[] = [];`,
3255
+ ``,
3256
+ ...fileToAttachmentLines("", "AttachmentData"),
3257
+ ``
3258
+ ] : [];
3259
+ const attachmentSetupLines = attachments ? [
3260
+ ` const uploadEl = document.getElementById('upload') as HTMLElement;`,
3261
+ ` const attachmentsEl = document.getElementById('attachments') as KaiAttachmentsElement;`,
3262
+ ` // Same upgrade rule as <kai-chat>: 'items' is an array, so it is a PROPERTY,`,
3263
+ ` // and a property set before the element upgrades is dropped on upgrade.`,
3264
+ ` await customElements.whenDefined('kai-attachments');`,
3265
+ ` // A NEW array per write — same contract as chat.messages.`,
3266
+ ` const showStaged = () => { attachmentsEl.items = [...staged]; };`,
3267
+ ``,
3268
+ ` uploadEl.addEventListener('kai-files-added', async (event: Event) => {`,
3269
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3270
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3271
+ ` // list by whichever finished reading first.`,
3272
+ ` staged = [...staged, ...(await Promise.all(files.map(toAttachment)))];`,
3273
+ ` showStaged();`,
3274
+ ` });`,
3275
+ ` attachmentsEl.addEventListener('kai-remove', (event: Event) => {`,
3276
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3277
+ ` staged = staged.filter((a) => a.id !== id);`,
3278
+ ` showStaged();`,
3279
+ ` });`,
3280
+ ``
3281
+ ] : [];
2829
3282
  const seedLines = hasEmbedded ? [...sampleSeedComment(ctx.isMock, ` `, (literal) => [`chat.messages = [${literal}];`]), ``] : [];
2830
3283
  const sourcesSetupLines = hasSources ? [
2831
3284
  ` const sourcesEl = document.getElementById('sources') as KaiSourcesElement;`,
@@ -2851,7 +3304,11 @@ function htmlModule(ctx, components) {
2851
3304
  ` chat.cardSchemas = cards.validationSchemas;`,
2852
3305
  ``
2853
3306
  ] : [];
2854
- const elementTypes = hasSources ? "KaiChatElement, KaiSourcesElement" : "KaiChatElement";
3307
+ const elementTypes = [
3308
+ "KaiChatElement",
3309
+ ...hasSources ? ["KaiSourcesElement"] : [],
3310
+ ...attachments ? ["KaiAttachmentsElement"] : []
3311
+ ].join(", ");
2855
3312
  const annotatesChatMessage = !ctx.emitToolLoop;
2856
3313
  const head = [
2857
3314
  `// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
@@ -2870,12 +3327,14 @@ function htmlModule(ctx, components) {
2870
3327
  toolLoop: ctx.emitToolLoop,
2871
3328
  cards: ctx.emitCards,
2872
3329
  cardTools: ctx.cardProvider !== null,
2873
- mock: ctx.isMock
3330
+ mock: ctx.isMock,
3331
+ attachments
2874
3332
  }),
2875
3333
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
2876
3334
  ``,
2877
3335
  ...ctx.isMock ? [...mockResponderInit(), ``] : [],
2878
3336
  ...modelLines,
3337
+ ...attachmentModuleLines,
2879
3338
  ...cardsLines,
2880
3339
  ...toolsLines,
2881
3340
  ...runnerLines,
@@ -2891,11 +3350,12 @@ function htmlModule(ctx, components) {
2891
3350
  ``,
2892
3351
  ...cardPropLines,
2893
3352
  ...seedLines,
2894
- ...sourcesSetupLines
3353
+ ...sourcesSetupLines,
3354
+ ...attachmentSetupLines
2895
3355
  ];
2896
3356
  const listenerOpen = [
2897
3357
  ` chat.addEventListener('kai-submit', async (event: Event) => {`,
2898
- ` const e = event as CustomEvent<{ value: string }>;`
3358
+ attachments ? ` const e = event as CustomEvent<{ value: string; attachments: AttachmentData[] }>;` : ` const e = event as CustomEvent<{ value: string }>;`
2899
3359
  ];
2900
3360
  const footer = [
2901
3361
  ` });`,
@@ -2923,7 +3383,15 @@ function htmlModule(ctx, components) {
2923
3383
  "(fn) => { chat.messages = fn(chat.messages); }",
2924
3384
  "chat.messages ?? []"
2925
3385
  ),
2926
- mock: ctx.isMock
3386
+ mock: ctx.isMock,
3387
+ ...attachments ? {
3388
+ filesExpr: "files",
3389
+ afterValue: attachmentTurnLines({
3390
+ stagedExpr: "staged",
3391
+ clear: ["staged = [];", "showStaged();"],
3392
+ fromSubmitEvent: true
3393
+ })
3394
+ } : {}
2927
3395
  }),
2928
3396
  ...footer
2929
3397
  ].join("\n");
@@ -2966,8 +3434,9 @@ function renderJsx(components, ctx, framework) {
2966
3434
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
2967
3435
  const wrapperNames = importTags.map(toPascalCase);
2968
3436
  const importList = wrapperNames.join(", ");
3437
+ const attachments = hasAttachments(components);
2969
3438
  const standaloneCompanionTags = components.filter(
2970
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3439
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
2971
3440
  );
2972
3441
  const companionJsxLines = [];
2973
3442
  if (hasEmbedded) {
@@ -2988,6 +3457,41 @@ function renderJsx(components, ctx, framework) {
2988
3457
  }
2989
3458
  }
2990
3459
  const companions = companionJsxLines.join("\n");
3460
+ const attachmentJsx = attachments ? [
3461
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
3462
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
3463
+ ` <FileUpload accept="${ATTACHMENT_ACCEPT}" onFilesAdded={onFilesAdded} />`,
3464
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
3465
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
3466
+ ` </div>`
3467
+ ].join("\n") : "";
3468
+ const attachmentModuleInit = attachments ? [
3469
+ ...ATTACHMENT_WIRE_NOTE,
3470
+ `//`,
3471
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
3472
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
3473
+ ...fileToAttachmentLines("", "AttachmentData")
3474
+ ] : [];
3475
+ const attachmentStateInit = attachments ? [
3476
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
3477
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
3478
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3479
+ ` // list by whichever finished reading first.`,
3480
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
3481
+ ` setStaged((prev) => [...prev, ...added]);`,
3482
+ ` };`,
3483
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
3484
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`
3485
+ ].join("\n") : "";
3486
+ const attachmentSubmitOpts = attachments ? {
3487
+ filesExpr: "files",
3488
+ afterValue: attachmentTurnLines({
3489
+ stagedExpr: "staged",
3490
+ clear: ["setStaged([]);"],
3491
+ fromSubmitEvent: true
3492
+ })
3493
+ } : {};
3494
+ const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
2991
3495
  const mockInit = isMock ? mockResponderInit() : [];
2992
3496
  const sampleMessagesInit = [
2993
3497
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
@@ -3022,7 +3526,8 @@ function renderJsx(components, ctx, framework) {
3022
3526
  toolLoop: emitToolLoop,
3023
3527
  cards: ctx.emitCards,
3024
3528
  thread: REACT_THREAD,
3025
- mock: isMock
3529
+ mock: isMock,
3530
+ ...attachmentSubmitOpts
3026
3531
  });
3027
3532
  const useClientDirective = framework === "next" ? [`'use client';`, ``] : [];
3028
3533
  if (framework === "next") {
@@ -3043,7 +3548,8 @@ function renderJsx(components, ctx, framework) {
3043
3548
  setMessagesType: emitToolLoop,
3044
3549
  cards: ctx.emitCards,
3045
3550
  cardTools: ctx.cardProvider !== null,
3046
- mock: isMock
3551
+ mock: isMock,
3552
+ attachments
3047
3553
  }),
3048
3554
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3049
3555
  `// <kai-*> are client-only custom elements (the server has no customElements`,
@@ -3056,22 +3562,25 @@ function renderJsx(components, ctx, framework) {
3056
3562
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3057
3563
  ...mockInit,
3058
3564
  ``,
3565
+ ...attachmentModuleInit,
3059
3566
  ...cardsInit,
3060
3567
  `export default function App() {`,
3061
3568
  sampleMessagesInit,
3062
3569
  ` const [loading, setLoading] = useState(false);`,
3063
3570
  ` const suggestions = ${jsArray(suggestions)};`,
3571
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3064
3572
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3065
3573
  ...modelInit ? [modelInit] : [],
3066
3574
  ...toolsInit ? [toolsInit] : [],
3067
3575
  ...toolRunner ? [toolRunner] : [],
3068
3576
  ``,
3069
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3577
+ ` async function onSubmit(e: ${submitParamType}) {`,
3070
3578
  onSubmitBody,
3071
3579
  ` }`,
3072
3580
  ``,
3073
3581
  ` return (`,
3074
3582
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3583
+ ...attachmentJsx ? [attachmentJsx] : [],
3075
3584
  ...workspace ? [
3076
3585
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3077
3586
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3131,7 +3640,8 @@ function renderJsx(components, ctx, framework) {
3131
3640
  setMessagesType: emitToolLoop,
3132
3641
  cards: ctx.emitCards,
3133
3642
  cardTools: ctx.cardProvider !== null,
3134
- mock: isMock
3643
+ mock: isMock,
3644
+ attachments
3135
3645
  }),
3136
3646
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3137
3647
  ``,
@@ -3140,22 +3650,25 @@ function renderJsx(components, ctx, framework) {
3140
3650
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3141
3651
  ...mockInit,
3142
3652
  ``,
3653
+ ...attachmentModuleInit,
3143
3654
  ...cardsInit,
3144
3655
  `export default function App() {`,
3145
3656
  sampleMessagesInit,
3146
3657
  ` const [loading, setLoading] = useState(false);`,
3147
3658
  ` const suggestions = ${jsArray(suggestions)};`,
3659
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3148
3660
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3149
3661
  ...modelInit ? [modelInit] : [],
3150
3662
  ...toolsInit ? [toolsInit] : [],
3151
3663
  ...toolRunner ? [toolRunner] : [],
3152
3664
  ``,
3153
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3665
+ ` async function onSubmit(e: ${submitParamType}) {`,
3154
3666
  onSubmitBody,
3155
3667
  ` }`,
3156
3668
  ``,
3157
3669
  ` return (`,
3158
3670
  ` <div style={{ ${jsxStyle(p.style)} }}>`,
3671
+ ...attachmentJsx ? [attachmentJsx] : [],
3159
3672
  ...workspace ? [
3160
3673
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3161
3674
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3200,8 +3713,9 @@ function renderJsx(components, ctx, framework) {
3200
3713
  function renderVue(components, ctx) {
3201
3714
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3202
3715
  const workspace = isWorkspace(components);
3716
+ const attachments = hasAttachments(components);
3203
3717
  const standaloneCompanionTags = components.filter(
3204
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3718
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3205
3719
  );
3206
3720
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3207
3721
  const companionLines = [];
@@ -3220,6 +3734,42 @@ function renderVue(components, ctx) {
3220
3734
  }
3221
3735
  }
3222
3736
  const companions = companionLines.join("\n");
3737
+ const attachmentTemplate = attachments ? [
3738
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
3739
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3740
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" @kai-files-added="onFilesAdded" />`,
3741
+ ` <kai-attachments :items.prop="staged" variant="inline" removable @kai-remove="onRemoveAttachment" />`,
3742
+ ` </div>`
3743
+ ].join("\n") : "";
3744
+ const attachmentScript = attachments ? [
3745
+ ...ATTACHMENT_WIRE_NOTE,
3746
+ `//`,
3747
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
3748
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
3749
+ `const staged = ref<AttachmentData[]>([]);`,
3750
+ ``,
3751
+ ...fileToAttachmentLines("", "AttachmentData"),
3752
+ ``,
3753
+ `async function onFilesAdded(event: Event) {`,
3754
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
3755
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3756
+ ` // list by whichever finished reading first.`,
3757
+ ` const added = await Promise.all(files.map(toAttachment));`,
3758
+ ` staged.value = [...staged.value, ...added];`,
3759
+ `}`,
3760
+ `function onRemoveAttachment(event: Event) {`,
3761
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
3762
+ ` staged.value = staged.value.filter((a) => a.id !== id);`,
3763
+ `}`
3764
+ ] : [];
3765
+ const attachmentSubmitOpts = attachments ? {
3766
+ filesExpr: "files",
3767
+ afterValue: attachmentTurnLines({
3768
+ stagedExpr: "staged.value",
3769
+ clear: ["staged.value = [];"],
3770
+ fromSubmitEvent: true
3771
+ })
3772
+ } : {};
3223
3773
  const onSubmitBody = realStreamBody({
3224
3774
  pad: " ",
3225
3775
  read: "messages.value",
@@ -3231,7 +3781,8 @@ function renderVue(components, ctx) {
3231
3781
  toolLoop: emitToolLoop,
3232
3782
  cards: ctx.emitCards,
3233
3783
  thread: liveThreadBinding("messages.value", "(fn) => { messages.value = fn(messages.value); }"),
3234
- mock: isMock
3784
+ mock: isMock,
3785
+ ...attachmentSubmitOpts
3235
3786
  });
3236
3787
  const mockInit = isMock ? mockResponderInit() : [];
3237
3788
  const modelInit = defaultModel ? [
@@ -3306,7 +3857,8 @@ function renderVue(components, ctx) {
3306
3857
  toolLoop: emitToolLoop,
3307
3858
  cards: ctx.emitCards,
3308
3859
  cardTools: ctx.cardProvider !== null,
3309
- mock: isMock
3860
+ mock: isMock,
3861
+ attachments
3310
3862
  }),
3311
3863
  `import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3312
3864
  vueImports,
@@ -3321,6 +3873,7 @@ function renderVue(components, ctx) {
3321
3873
  ...toolsLines,
3322
3874
  ...runnerLines,
3323
3875
  ...sourcesSeed,
3876
+ ...attachmentScript,
3324
3877
  ``,
3325
3878
  `// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
3326
3879
  `// bindings can apply before the element upgrades, which drops them — re-apply once`,
@@ -3332,13 +3885,14 @@ function renderVue(components, ctx) {
3332
3885
  ` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(", ")}` : ""} });`,
3333
3886
  `});`,
3334
3887
  ``,
3335
- `async function onSubmit(e: CustomEvent<{ value: string }>) {`,
3888
+ `async function onSubmit(e: ${attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>"}) {`,
3336
3889
  onSubmitBody,
3337
3890
  `}`,
3338
3891
  `<\/script>`,
3339
3892
  ``,
3340
3893
  `<template>`,
3341
3894
  ` <div style="${p.style}">`,
3895
+ ...attachmentTemplate ? [attachmentTemplate] : [],
3342
3896
  ...workspaceTemplate,
3343
3897
  ` </div>`,
3344
3898
  `</template>`
@@ -3347,8 +3901,9 @@ function renderVue(components, ctx) {
3347
3901
  function renderSvelte(components, ctx) {
3348
3902
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3349
3903
  const workspace = isWorkspace(components);
3904
+ const attachments = hasAttachments(components);
3350
3905
  const standaloneCompanionTags = components.filter(
3351
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
3906
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3352
3907
  );
3353
3908
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3354
3909
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -3368,6 +3923,14 @@ function renderSvelte(components, ctx) {
3368
3923
  }
3369
3924
  }
3370
3925
  const companionLines = companionLinesList.join("\n");
3926
+ const attachmentSubmitOpts = attachments ? {
3927
+ filesExpr: "files",
3928
+ afterValue: attachmentTurnLines({
3929
+ stagedExpr: "staged",
3930
+ clear: ["staged = [];"],
3931
+ fromSubmitEvent: true
3932
+ })
3933
+ } : {};
3371
3934
  const onSubmitBody = realStreamBody({
3372
3935
  pad: " ",
3373
3936
  read: "messages",
@@ -3379,7 +3942,8 @@ function renderSvelte(components, ctx) {
3379
3942
  toolLoop: emitToolLoop,
3380
3943
  cards: ctx.emitCards,
3381
3944
  thread: liveThreadBinding("messages", "(fn) => { messages = fn(messages); }"),
3382
- mock: isMock
3945
+ mock: isMock,
3946
+ ...attachmentSubmitOpts
3383
3947
  });
3384
3948
  const mockInit = isMock ? mockResponderInit(" ") : [];
3385
3949
  const modelInit = defaultModel ? [
@@ -3405,6 +3969,33 @@ function renderSvelte(components, ctx) {
3405
3969
  ` ];`,
3406
3970
  ` $effect(() => { if (sourcesEl) { sourcesEl.sources = sampleSources; } });`
3407
3971
  ] : [];
3972
+ const attachmentScript = attachments ? [
3973
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
3974
+ ` //`,
3975
+ ` // AttachmentData is the kit's own type for a staged file, and it is what`,
3976
+ ` // <kai-attachments>.items and a 'file' MessagePart both hold.`,
3977
+ ` let attachmentsEl = $state<KaiAttachmentsElement | undefined>(undefined);`,
3978
+ ` let staged = $state.raw<AttachmentData[]>([]);`,
3979
+ ` $effect(() => { if (attachmentsEl && defined) { attachmentsEl.items = staged; } });`,
3980
+ ``,
3981
+ ...fileToAttachmentLines(" ", "AttachmentData"),
3982
+ ` async function onFilesAdded(e: CustomEvent<{ files: File[] }>) {`,
3983
+ ` // Read every file BEFORE appending: a per-file append would order the`,
3984
+ ` // list by whichever finished reading first.`,
3985
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
3986
+ ` staged = [...staged, ...added];`,
3987
+ ` }`,
3988
+ ` function onRemoveAttachment(e: CustomEvent<{ id: string }>) {`,
3989
+ ` staged = staged.filter((a) => a.id !== e.detail.id);`,
3990
+ ` }`
3991
+ ] : [];
3992
+ const attachmentMarkup = attachments ? [
3993
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
3994
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
3995
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" onkai-files-added={onFilesAdded}></kai-file-upload>`,
3996
+ ` <kai-attachments bind:this={attachmentsEl} variant="inline" removable onkai-remove={onRemoveAttachment}></kai-attachments>`,
3997
+ ` </div>`
3998
+ ] : [];
3408
3999
  const workspaceMarkup = workspace ? [
3409
4000
  ` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
3410
4001
  ` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
@@ -3435,14 +4026,19 @@ function renderSvelte(components, ctx) {
3435
4026
  // KaiSourcesElement is only imported when a kai-sources companion is actually
3436
4027
  // declared below: an always-on import would be unused (and fail noUnusedLocals)
3437
4028
  // on every archetype without kai-sources.
3438
- ` import type { ${hasSourcesCompanion ? "KaiChatElement, KaiSourcesElement" : "KaiChatElement"} } from '@kitn.ai/ui/elements';`,
4029
+ ` import type { ${[
4030
+ "KaiChatElement",
4031
+ ...hasSourcesCompanion ? ["KaiSourcesElement"] : [],
4032
+ ...attachments ? ["KaiAttachmentsElement"] : []
4033
+ ].join(", ")} } from '@kitn.ai/ui/elements';`,
3439
4034
  ...wireImportLines({
3440
4035
  pad: " ",
3441
4036
  typed: true,
3442
4037
  toolLoop: emitToolLoop,
3443
4038
  cards: ctx.emitCards,
3444
4039
  cardTools: ctx.cardProvider !== null,
3445
- mock: isMock
4040
+ mock: isMock,
4041
+ attachments
3446
4042
  }),
3447
4043
  ` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
3448
4044
  ` import { onMount } from 'svelte';`,
@@ -3468,13 +4064,15 @@ function renderSvelte(components, ctx) {
3468
4064
  ` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
3469
4065
  ` });`,
3470
4066
  ...sourcesReactive,
4067
+ ...attachmentScript,
3471
4068
  ``,
3472
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
4069
+ ` async function onSubmit(e: ${attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>"}) {`,
3473
4070
  onSubmitBody,
3474
4071
  ` }`,
3475
4072
  `<\/script>`,
3476
4073
  ``,
3477
4074
  `<div style="${p.style}">`,
4075
+ ...attachmentMarkup,
3478
4076
  ...workspaceMarkup,
3479
4077
  `</div>`
3480
4078
  ].filter((l, i, arr) => !(l === "" && arr[i - 1] === "" && i === arr.length - 1)).join("\n");
@@ -3487,8 +4085,9 @@ function renderTanstackStart(components, ctx) {
3487
4085
  const importTags = workspace ? [.../* @__PURE__ */ new Set([...renderableTags.filter((t) => t !== "kai-resizable"), "kai-resizable", "kai-resizable-item"])] : renderableTags;
3488
4086
  const wrapperNames = importTags.map(toPascalCase);
3489
4087
  const importList = wrapperNames.join(", ");
4088
+ const attachments = hasAttachments(components);
3490
4089
  const standaloneCompanionTags = components.filter(
3491
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4090
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3492
4091
  );
3493
4092
  const companionJsxLines = [];
3494
4093
  if (hasEmbedded) {
@@ -3509,6 +4108,41 @@ function renderTanstackStart(components, ctx) {
3509
4108
  }
3510
4109
  }
3511
4110
  const companions = companionJsxLines.join("\n");
4111
+ const attachmentJsx = attachments ? [
4112
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
4113
+ ` <div style={{ flex: '0 0 auto', display: 'flex', flexDirection: 'column', gap: '0.5rem', padding: '0.75rem' }}>`,
4114
+ ` <FileUpload accept="${ATTACHMENT_ACCEPT}" onFilesAdded={onFilesAdded} />`,
4115
+ ` {/* items is an ARRAY, so the wrapper sets it as a DOM property, never an attribute. */}`,
4116
+ ` <Attachments items={staged} variant="inline" removable onRemove={onRemoveAttachment} />`,
4117
+ ` </div>`
4118
+ ].join("\n") : "";
4119
+ const attachmentModuleInit = attachments ? [
4120
+ ...ATTACHMENT_WIRE_NOTE,
4121
+ `//`,
4122
+ `// AttachmentData is the kit's own type for a staged file — the same one`,
4123
+ `// <Attachments>.items holds and the same one a 'file' message part holds.`,
4124
+ ...fileToAttachmentLines("", "AttachmentData")
4125
+ ] : [];
4126
+ const attachmentStateInit = attachments ? [
4127
+ ` const [staged, setStaged] = useState<AttachmentData[]>([]);`,
4128
+ ` const onFilesAdded = async (e: CustomEvent<{ files: File[] }>) => {`,
4129
+ ` // Read every file BEFORE appending: a per-file append would order the`,
4130
+ ` // list by whichever finished reading first.`,
4131
+ ` const added = await Promise.all(e.detail.files.map(toAttachment));`,
4132
+ ` setStaged((prev) => [...prev, ...added]);`,
4133
+ ` };`,
4134
+ ` const onRemoveAttachment = (e: CustomEvent<{ id: string }>) =>`,
4135
+ ` setStaged((prev) => prev.filter((a) => a.id !== e.detail.id));`
4136
+ ].join("\n") : "";
4137
+ const attachmentSubmitOpts = attachments ? {
4138
+ filesExpr: "files",
4139
+ afterValue: attachmentTurnLines({
4140
+ stagedExpr: "staged",
4141
+ clear: ["setStaged([]);"],
4142
+ fromSubmitEvent: true
4143
+ })
4144
+ } : {};
4145
+ const submitParamType = attachments ? "CustomEvent<{ value: string; attachments: AttachmentData[] }>" : "CustomEvent<{ value: string }>";
3512
4146
  const mockInit = isMock ? mockResponderInit() : [];
3513
4147
  const sampleMessagesInit = [
3514
4148
  ...hasEmbedded ? sampleSeedComment(isMock, " ", (literal) => [
@@ -3543,7 +4177,8 @@ function renderTanstackStart(components, ctx) {
3543
4177
  toolLoop: emitToolLoop,
3544
4178
  cards: ctx.emitCards,
3545
4179
  thread: REACT_THREAD,
3546
- mock: isMock
4180
+ mock: isMock,
4181
+ ...attachmentSubmitOpts
3547
4182
  });
3548
4183
  const filePathNote = [
3549
4184
  `// TanStack Start route file — save as: src/routes/chat.tsx`,
@@ -3573,7 +4208,8 @@ function renderTanstackStart(components, ctx) {
3573
4208
  setMessagesType: emitToolLoop,
3574
4209
  cards: ctx.emitCards,
3575
4210
  cardTools: ctx.cardProvider !== null,
3576
- mock: isMock
4211
+ mock: isMock,
4212
+ attachments
3577
4213
  }),
3578
4214
  `import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
3579
4215
  ``,
@@ -3581,6 +4217,7 @@ function renderTanstackStart(components, ctx) {
3581
4217
  ...(p.altNote ?? []).map((l) => `// ${l}`),
3582
4218
  ...mockInit,
3583
4219
  ``,
4220
+ ...attachmentModuleInit,
3584
4221
  ...cardsInit,
3585
4222
  `// ssr: false keeps the Solid-based web component client-only.`,
3586
4223
  `// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
@@ -3593,17 +4230,19 @@ function renderTanstackStart(components, ctx) {
3593
4230
  sampleMessagesInit,
3594
4231
  ` const [loading, setLoading] = useState(false);`,
3595
4232
  ` const suggestions = ${jsArray(suggestions)};`,
4233
+ ...attachmentStateInit ? [attachmentStateInit] : [],
3596
4234
  ...sampleSourcesInit ? [sampleSourcesInit] : [],
3597
4235
  ...modelInit ? [modelInit] : [],
3598
4236
  ...toolsInit ? [toolsInit] : [],
3599
4237
  ...toolRunner ? [toolRunner] : [],
3600
4238
  ``,
3601
- ` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
4239
+ ` async function onSubmit(e: ${submitParamType}) {`,
3602
4240
  onSubmitBody,
3603
4241
  ` }`,
3604
4242
  ``,
3605
4243
  ` return (`,
3606
4244
  ` <main style={{ ${jsxStyle(p.style)} }}>`,
4245
+ ...attachmentJsx ? [attachmentJsx] : [],
3607
4246
  ...workspace ? [
3608
4247
  ` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
3609
4248
  ` {/* Resizable needs ResizableItem children to render panels. */}`,
@@ -3648,8 +4287,9 @@ function renderTanstackStart(components, ctx) {
3648
4287
  function renderAngular(components, ctx) {
3649
4288
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3650
4289
  const workspace = isWorkspace(components);
4290
+ const attachments = hasAttachments(components);
3651
4291
  const standaloneCompanionTags = components.filter(
3652
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4292
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3653
4293
  );
3654
4294
  const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
3655
4295
  const hasSourcesCompanion = standaloneCompanionTags.includes("kai-sources");
@@ -3670,6 +4310,46 @@ function renderAngular(components, ctx) {
3670
4310
  companionLines.push(` <${t}></${t}>`);
3671
4311
  }
3672
4312
  }
4313
+ const attachmentTemplate = attachments ? [
4314
+ ` <!-- Drop files here to stage them for the NEXT message. -->`,
4315
+ ` <div style="flex: 0 0 auto; display: flex; flex-direction: column; gap: 0.5rem; padding: 0.75rem;">`,
4316
+ ` <kai-file-upload accept="${ATTACHMENT_ACCEPT}" (kai-files-added)="onFilesAdded($event)"></kai-file-upload>`,
4317
+ ` <kai-attachments [items]="staged()" variant="inline" removable (kai-remove)="onRemoveAttachment($event)"></kai-attachments>`,
4318
+ ` </div>`
4319
+ ] : [];
4320
+ const attachmentModuleInit = attachments ? [
4321
+ ...ATTACHMENT_WIRE_NOTE,
4322
+ `//`,
4323
+ `// AttachmentData is the kit's own type for a staged file, and it is what`,
4324
+ `// <kai-attachments>.items and a 'file' MessagePart both hold.`,
4325
+ ...fileToAttachmentLines("", "AttachmentData"),
4326
+ ``
4327
+ ] : [];
4328
+ const attachmentFields = attachments ? [
4329
+ ` readonly staged = signal<AttachmentData[]>([]);`,
4330
+ ``,
4331
+ ` // Same Event-not-CustomEvent rule as onSubmit below: strictTemplates types`,
4332
+ ` // $event on an unknown custom-element event as a plain Event.`,
4333
+ ` async onFilesAdded(event: Event) {`,
4334
+ ` const { files } = (event as CustomEvent<{ files: File[] }>).detail;`,
4335
+ ` // Read every file BEFORE appending: a per-file append would order the`,
4336
+ ` // list by whichever finished reading first.`,
4337
+ ` const added = await Promise.all(files.map(toAttachment));`,
4338
+ ` this.staged.set([...this.staged(), ...added]);`,
4339
+ ` }`,
4340
+ ` onRemoveAttachment(event: Event) {`,
4341
+ ` const { id } = (event as CustomEvent<{ id: string }>).detail;`,
4342
+ ` this.staged.set(this.staged().filter((a) => a.id !== id));`,
4343
+ ` }`
4344
+ ] : [];
4345
+ const attachmentSubmitOpts = attachments ? {
4346
+ filesExpr: "files",
4347
+ afterValue: attachmentTurnLines({
4348
+ stagedExpr: "this.staged()",
4349
+ clear: ["this.staged.set([]);"],
4350
+ fromSubmitEvent: true
4351
+ })
4352
+ } : {};
3673
4353
  const read = "this.messages()";
3674
4354
  const commit = (value) => `this.messages.set(${value});`;
3675
4355
  const setter = "(fn) => this.messages.set(fn(this.messages()))";
@@ -3684,7 +4364,8 @@ function renderAngular(components, ctx) {
3684
4364
  toolLoop: emitToolLoop,
3685
4365
  cards: ctx.emitCards,
3686
4366
  thread: accessorThreadBinding(read, commit, setter),
3687
- mock: isMock
4367
+ mock: isMock,
4368
+ ...attachmentSubmitOpts
3688
4369
  });
3689
4370
  const modelInit = defaultModel ? [
3690
4371
  `// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
@@ -3760,7 +4441,8 @@ function renderAngular(components, ctx) {
3760
4441
  toolLoop: emitToolLoop,
3761
4442
  cards: ctx.emitCards,
3762
4443
  cardTools: ctx.cardProvider !== null,
3763
- mock: isMock
4444
+ mock: isMock,
4445
+ attachments
3764
4446
  }),
3765
4447
  ``,
3766
4448
  `// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
@@ -3768,6 +4450,7 @@ function renderAngular(components, ctx) {
3768
4450
  ...isMock ? mockResponderInit() : [],
3769
4451
  ``,
3770
4452
  ...modelInit,
4453
+ ...attachmentModuleInit,
3771
4454
  ...cardsInit,
3772
4455
  ...toolsLines,
3773
4456
  ...runnerLines,
@@ -3779,6 +4462,7 @@ function renderAngular(components, ctx) {
3779
4462
  ` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
3780
4463
  ` template: \``,
3781
4464
  ` <div style="${p.style}">`,
4465
+ ...attachmentTemplate,
3782
4466
  ...templateBody,
3783
4467
  ` </div>`,
3784
4468
  ` \`,`,
@@ -3811,11 +4495,13 @@ function renderAngular(components, ctx) {
3811
4495
  ` });`,
3812
4496
  ` }`,
3813
4497
  ``,
4498
+ ...attachmentFields,
4499
+ ``,
3814
4500
  ` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
3815
4501
  ` // an unknown custom-element event as a plain Event, so the narrowing happens`,
3816
4502
  ` // here rather than in the signature.`,
3817
4503
  ` async onSubmit(event: Event) {`,
3818
- ` const e = event as CustomEvent<{ value: string }>;`,
4504
+ ` const e = event as CustomEvent<{ value: string${attachments ? "; attachments: AttachmentData[]" : ""} }>;`,
3819
4505
  onSubmitBody,
3820
4506
  ` }`,
3821
4507
  `}`
@@ -3824,8 +4510,9 @@ function renderAngular(components, ctx) {
3824
4510
  function renderSolid(components, ctx) {
3825
4511
  const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
3826
4512
  const workspace = isWorkspace(components);
4513
+ const attachments = hasAttachments(components);
3827
4514
  const standaloneCompanionTags = components.filter(
3828
- (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t)
4515
+ (t) => t !== "kai-chat" && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t) && !(attachments && ATTACHMENT_TAGS.has(t))
3829
4516
  );
3830
4517
  const hasSources = standaloneCompanionTags.includes("kai-sources");
3831
4518
  const hasVoice = standaloneCompanionTags.includes("kai-voice-input");
@@ -3856,7 +4543,13 @@ function renderSolid(components, ctx) {
3856
4543
  "SourceTrigger",
3857
4544
  "Tool",
3858
4545
  ...workspace ? ["Artifact", "ResizableHandle", "ResizablePanel", "ResizablePanelGroup"] : [],
3859
- ...hasVoice ? ["VoiceInput"] : []
4546
+ ...hasVoice ? ["VoiceInput"] : [],
4547
+ // `Attachments`/`Attachment`/`AttachmentPreview`/`AttachmentInfo` are already
4548
+ // unconditional above — `renderPart` draws `file` PARTS with them whatever the
4549
+ // surface asked for. These three are the STAGING half, which only this
4550
+ // capability composes: the dropzone, its trigger, and the per-chip remove
4551
+ // button `renderPart` has no use for (a sent file is not removable).
4552
+ ...attachments ? ["AttachmentRemove", "FileUpload", "FileUploadTrigger"] : []
3860
4553
  ].sort();
3861
4554
  const read = "messages()";
3862
4555
  const commit = (value) => `setMessages(${value});`;
@@ -3873,7 +4566,17 @@ function renderSolid(components, ctx) {
3873
4566
  cards: ctx.emitCards,
3874
4567
  thread: accessorThreadBinding(read, commit, setter),
3875
4568
  valueSource: "input()",
3876
- afterValue: [`setInput('');`],
4569
+ // The staged files are captured BEFORE either list is cleared. `setInput('')`
4570
+ // stays last so the order reads the way it executes.
4571
+ afterValue: [
4572
+ ...attachments ? attachmentTurnLines({
4573
+ stagedExpr: "staged()",
4574
+ clear: ["setStaged([]);"],
4575
+ fromSubmitEvent: false
4576
+ }) : [],
4577
+ `setInput('');`
4578
+ ],
4579
+ ...attachments ? { filesExpr: "files" } : {},
3877
4580
  mock: isMock
3878
4581
  });
3879
4582
  const modelInit = defaultModel ? [
@@ -3887,6 +4590,11 @@ function renderSolid(components, ctx) {
3887
4590
  ` types={cards.components}`,
3888
4591
  ` schemas={cards.validationSchemas}`
3889
4592
  ] : [];
4593
+ const attachmentInit = attachments ? [
4594
+ ...ATTACHMENT_WIRE_NOTE.map((l) => ` ${l}`),
4595
+ ` const [staged, setStaged] = createSignal<AttachmentData[]>([]);`,
4596
+ ...fileToAttachmentLines(" ", "AttachmentData")
4597
+ ] : [];
3890
4598
  const sourcesInit = hasSources ? [
3891
4599
  ` // Replace sampleSources with your real source data.`,
3892
4600
  ` const sampleSources = [`,
@@ -3962,6 +4670,35 @@ function renderSolid(components, ctx) {
3962
4670
  ` </For>`,
3963
4671
  ` </div>`,
3964
4672
  ` </Show>`,
4673
+ ...attachments ? [
4674
+ ` {/* Drop files here to stage them for the NEXT message. */}`,
4675
+ ` <div class="flex flex-col gap-2 pb-3">`,
4676
+ ` {/* Read every file BEFORE appending: a per-file append would order`,
4677
+ ` the list by whichever finished reading first. */}`,
4678
+ ` <FileUpload`,
4679
+ ` accept="${ATTACHMENT_ACCEPT}"`,
4680
+ ` onFilesAdded={async (files) => setStaged([...staged(), ...(await Promise.all(files.map(toAttachment)))])}`,
4681
+ ` >`,
4682
+ ` <FileUploadTrigger class="border-border text-muted-foreground w-full rounded-xl border border-dashed px-4 py-3 text-center text-sm">`,
4683
+ ` Click or drop files to attach`,
4684
+ ` </FileUploadTrigger>`,
4685
+ ` </FileUpload>`,
4686
+ ` {/* The staged chips. onRemove is what makes <AttachmentRemove> draw its button. */}`,
4687
+ ` <Show when={staged().length > 0}>`,
4688
+ ` <Attachments variant="inline">`,
4689
+ ` <For each={staged()}>`,
4690
+ ` {(a) => (`,
4691
+ ` <Attachment data={a} onRemove={() => setStaged(staged().filter((x) => x.id !== a.id))}>`,
4692
+ ` <AttachmentPreview />`,
4693
+ ` <AttachmentInfo />`,
4694
+ ` <AttachmentRemove />`,
4695
+ ` </Attachment>`,
4696
+ ` )}`,
4697
+ ` </For>`,
4698
+ ` </Attachments>`,
4699
+ ` </Show>`,
4700
+ ` </div>`
4701
+ ] : [],
3965
4702
  ` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
3966
4703
  ` <div class="flex flex-col">`,
3967
4704
  ` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
@@ -4026,11 +4763,25 @@ function renderSolid(components, ctx) {
4026
4763
  `// every kit utility class as unused, and the whole UI renders unstyled.`,
4027
4764
  `// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
4028
4765
  `import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
4766
+ // '@kitn.ai/ui/solid', NOT the root '@kitn.ai/ui', and the difference is
4767
+ // invisible to every compiler on this repo's critical path: src/solid.ts is
4768
+ // `export * from './index'`, so ./solid is a strict SUPERSET of the root and
4769
+ // both specifiers typecheck identically. verify:scaffold's solid project
4770
+ // compiled clean the whole time this was wrong.
4771
+ //
4772
+ // The root is the shared layer EVERY consumer resolves — React, Vue, Svelte
4773
+ // and vanilla included — so it deliberately carries only the chat components,
4774
+ // types and helpers those frameworks can use. The rest of the Solid catalog
4775
+ // lives on ./solid, built as its own bundle so a React app never pays for it.
4776
+ // A Solid app pointed at the root therefore gets the smaller surface and finds
4777
+ // out the hard way, one missing export at a time. The published guide says so
4778
+ // outright ("Import Solid components from @kitn.ai/ui/solid, not from the root
4779
+ // @kitn.ai/ui"), and this generator contradicted it for every Solid scaffold.
4029
4780
  `import {`,
4030
4781
  ...componentImports.map((n) => ` ${n},`),
4031
- `} from '@kitn.ai/ui';`,
4782
+ `} from '@kitn.ai/ui/solid';`,
4032
4783
  `// The kit's own types, from the same entry the components come from.`,
4033
- `import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
4784
+ `import type { ${attachments ? "AttachmentData, ChatMessage, MessagePart, MessageSource" : "ChatMessage, MessagePart, MessageSource"} } from '@kitn.ai/ui/solid';`,
4034
4785
  ...wireImportLines({
4035
4786
  typed: false,
4036
4787
  toolLoop: emitToolLoop,
@@ -4230,6 +4981,7 @@ function renderSolid(components, ctx) {
4230
4981
  ` // value, no row moves, and the content updates through accessors instead.`,
4231
4982
  ` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
4232
4983
  ` const suggestions = ${jsArray(suggestions)};`,
4984
+ ...attachmentInit,
4233
4985
  ...sourcesInit,
4234
4986
  ...modelInit,
4235
4987
  ...toolsLines,
@@ -4676,12 +5428,72 @@ const CHAT_REQUEST_BODY_DECL = [
4676
5428
  ` return (await request.json()) as ChatRequestBody;`,
4677
5429
  `}`
4678
5430
  ];
5431
+ const CONTENT_PARTS_DECL = [
5432
+ `/** Where an attachment's bytes are: inline base64, or an address the PROVIDER`,
5433
+ ` * fetches. Never both. */`,
5434
+ `type WireFileSource = { type: 'data'; data: string } | { type: 'url'; url: string };`,
5435
+ ``,
5436
+ `/** One piece of a turn, with the string and array content forms flattened into`,
5437
+ ` * a single shape. */`,
5438
+ `type WirePart =`,
5439
+ ` | { kind: 'text'; text: string }`,
5440
+ ` | { kind: 'file'; mediaType: string; filename?: string; source: WireFileSource };`,
5441
+ ``,
5442
+ `const DATA_URI = /^data:([^;,]+);base64,([\\s\\S]*)$/;`,
5443
+ ``,
5444
+ `/**`,
5445
+ ` * Flatten a wire message's content into parts.`,
5446
+ ` *`,
5447
+ ` * An image sent by URL has no media type here — \`image_url\` carries only the`,
5448
+ ` * address — so it reports the top-level segment \`'image'\`, which is all a URL`,
5449
+ ` * source needs. Only images can reach that branch: the kit refuses to encode a`,
5450
+ ` * remote PDF rather than guess at one.`,
5451
+ ` */`,
5452
+ `function wireParts(content: OpenAIWireMessage['content']): WirePart[] {`,
5453
+ ` if (content == null) return [];`,
5454
+ ` if (typeof content === 'string') return content === '' ? [] : [{ kind: 'text', text: content }];`,
5455
+ ` return content.map((part): WirePart => {`,
5456
+ ` if (part.type === 'text') return { kind: 'text', text: part.text };`,
5457
+ ` if (part.type === 'image_url') {`,
5458
+ ` const asData = DATA_URI.exec(part.image_url.url);`,
5459
+ ` return asData`,
5460
+ ` ? { kind: 'file', mediaType: asData[1], source: { type: 'data', data: asData[2] } }`,
5461
+ ` : { kind: 'file', mediaType: 'image', source: { type: 'url', url: part.image_url.url } };`,
5462
+ ` }`,
5463
+ ` const asData = DATA_URI.exec(part.file.file_data);`,
5464
+ ` if (!asData) {`,
5465
+ ` // LOUD on purpose. \`file_data\` is a data URI on this wire; anything else`,
5466
+ ` // cannot be turned into bytes without fetching it, and forwarding a turn`,
5467
+ ` // with the attachment quietly missing is the bug this whole path exists`,
5468
+ ` // to prevent.`,
5469
+ ` throw new Error(`,
5470
+ ` 'Unsupported file content part: file_data must be a data: URI of the form data:<media type>;base64,<data>.',`,
5471
+ ` );`,
5472
+ ` }`,
5473
+ ` return {`,
5474
+ ` kind: 'file',`,
5475
+ ` mediaType: asData[1],`,
5476
+ ` filename: part.file.filename,`,
5477
+ ` source: { type: 'data', data: asData[2] },`,
5478
+ ` };`,
5479
+ ` });`,
5480
+ `}`,
5481
+ ``,
5482
+ `/** Just the text of a turn. System, assistant and tool messages are text-only`,
5483
+ ` * on this wire, so this collapses the array form for them. */`,
5484
+ `function wireText(content: OpenAIWireMessage['content']): string {`,
5485
+ ` return wireParts(content)`,
5486
+ ` .map((p) => (p.kind === 'text' ? p.text : ''))`,
5487
+ ` .join('');`,
5488
+ `}`
5489
+ ];
4679
5490
  function withChatRequestBody(fragment) {
5491
+ const decl = /\bwire(?:Parts|Text)\s*\(/.test(fragment) ? [...CHAT_REQUEST_BODY_DECL, ``, ...CONTENT_PARTS_DECL] : CHAT_REQUEST_BODY_DECL;
4680
5492
  const lines = fragment.split("\n");
4681
5493
  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");
5494
+ if (at < 0) return [...decl, ``, ...lines].join("\n");
4683
5495
  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");
5496
+ return [...lines.slice(0, at), ...decl, ``, ...lines.slice(at)].join("\n");
4685
5497
  }
4686
5498
  function webRouteFor(integration, framework) {
4687
5499
  const fragment = integration.webRoute;
@@ -5067,7 +5879,7 @@ function rejectUseCase(id) {
5067
5879
  ``,
5068
5880
  `Valid useCases (presets): ${valid}.`,
5069
5881
  ``,
5070
- // The presets are six points, not the space. A harness that only ever learns
5882
+ // The presets are seven points, not the space. A harness that only ever learns
5071
5883
  // the six ids will ask for the nearest one instead of the surface it wants,
5072
5884
  // so the rejection that teaches the id list is the right place to say so.
5073
5885
  `These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
@@ -5086,14 +5898,14 @@ const scaffold = {
5086
5898
  // catalogs to discover valid ids.
5087
5899
  //
5088
5900
  // `useCase` is OPTIONAL because `components` can carry the surface instead —
5089
- // the archetypes are six points in the components space, not the space. A
5901
+ // the archetypes are seven points in the components space, not the space. A
5090
5902
  // request must still name one of the two, and the handler says so when it
5091
5903
  // names neither.
5092
5904
  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.'
5905
+ '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
5906
  ),
5095
5907
  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.'
5908
+ '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
5909
  ),
5098
5910
  integration: z$1.string().describe(
5099
5911
  'Backend integration id, e.g. "openrouter", "vercel-ai-sdk", "langgraph", "cloudflare", "ollama", "mastra", "pi", "pydantic-ai".'