@kitn.ai/ui 0.20.1 → 0.21.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +44 -45
- package/dist/components/artifact-card.d.ts +51 -0
- package/dist/components/artifact.d.ts +1 -1
- package/dist/components/attachments.d.ts +2 -2
- package/dist/components/audio-visualizer/index.d.ts +3 -3
- package/dist/components/audio-visualizer/labs/lab-choreography.d.ts +59 -0
- package/dist/components/audio-visualizer/labs/lab-shaders.d.ts +22 -0
- package/dist/components/audio-visualizer/labs/lab-visualizer.d.ts +18 -0
- package/dist/components/audio-visualizer/variant-aurora.d.ts +2 -2
- package/dist/components/audio-visualizer/variant-bar.d.ts +2 -2
- package/dist/components/audio-visualizer/variant-custom.d.ts +2 -2
- package/dist/components/audio-visualizer/variant-grid.d.ts +1 -1
- package/dist/components/audio-visualizer/variant-radial.d.ts +1 -1
- package/dist/components/audio-visualizer/variant-wave.d.ts +1 -1
- package/dist/components/card-fallback.d.ts +12 -2
- package/dist/components/card-renderer.d.ts +63 -2
- package/dist/components/chat-container.d.ts +3 -0
- package/dist/components/chat-scope-picker.d.ts +2 -2
- package/dist/components/chat-thread.d.ts +68 -18
- package/dist/components/choice-card.d.ts +3 -28
- package/dist/components/composer-dom.d.ts +1 -1
- package/dist/components/composer-highlight.d.ts +1 -1
- package/dist/components/composer-history.d.ts +1 -1
- package/dist/components/composer.d.ts +1 -1
- package/dist/components/confirm-card.d.ts +3 -18
- package/dist/components/context.d.ts +8 -4
- package/dist/components/conversation-item.d.ts +1 -1
- package/dist/components/conversation-list.d.ts +4 -3
- package/dist/components/embed.d.ts +3 -3
- package/dist/components/file-tree.d.ts +11 -22
- package/dist/components/form-widgets.d.ts +1 -1
- package/dist/components/form.d.ts +3 -44
- package/dist/components/link-preview.d.ts +3 -3
- package/dist/components/loader.d.ts +30 -48
- package/dist/components/message-skills.d.ts +4 -0
- package/dist/components/message.d.ts +84 -21
- package/dist/components/model-switcher.d.ts +2 -2
- package/dist/components/prompt-input.d.ts +2 -2
- package/dist/components/response-compare-types.d.ts +11 -2
- package/dist/components/response-compare.d.ts +3 -3
- package/dist/components/source.d.ts +12 -3
- package/dist/components/tasks-card.d.ts +3 -31
- package/dist/components/thread.d.ts +17 -5
- package/dist/components/toast.d.ts +2 -2
- package/dist/components/tool-classify.d.ts +8 -0
- package/dist/components/tool-types.d.ts +29 -1
- package/dist/components/tool.d.ts +3 -3
- package/dist/components/use-card-resolution.d.ts +1 -1
- package/dist/create-tween-D3Y5qiij.js +338 -0
- package/dist/create-tween-DGKY1FDY.js +341 -0
- package/dist/create-tween-paho5Rh-.js +7 -0
- package/dist/custom-elements.json +1681 -454
- package/dist/elements/agent-card.js +1 -1
- package/dist/elements/artifact.js +1 -1
- package/dist/elements/attachments.js +1 -1
- package/dist/elements/audio-visualizer.js +1 -1
- package/dist/elements/avatar.js +1 -1
- package/dist/elements/badge.js +1 -1
- package/dist/elements/button.js +1 -1
- package/dist/elements/card.js +1 -1
- package/dist/elements/cards.js +1 -1
- package/dist/elements/chain-of-thought.js +1 -1
- package/dist/elements/chat-scope-picker.js +1 -1
- package/dist/elements/chat-types.d.ts +70 -10
- package/dist/elements/chat-workspace.js +1 -1
- package/dist/elements/chat.js +1 -1
- package/dist/elements/checkpoint.js +1 -1
- package/dist/elements/choice.js +1 -1
- package/dist/elements/chunks/{Icon-Tj58ES_d.js → Icon-C-2LvGDG.js} +1 -1
- package/dist/elements/chunks/{action-icons-ns1Fnedj.js → action-icons-CaYoImEk.js} +1 -1
- package/dist/elements/chunks/arrow-left-Bb46H7w9.js +1 -0
- package/dist/elements/chunks/artifact-BBIscOxJ.js +1 -0
- package/dist/elements/chunks/{attachments-mCB5F1Fq.js → attachments-BB56zoji.js} +1 -1
- package/dist/elements/chunks/audio-visualizer-BxOx4THz.js +1 -0
- package/dist/elements/chunks/{badge-B4Zuajl2.js → badge-SxLSZ19R.js} +1 -1
- package/dist/elements/chunks/{button-ZWNif3--.js → button-CmkUkz6c.js} +1 -1
- package/dist/elements/chunks/card-renderer-Db4ffkWv.js +1 -0
- package/dist/elements/chunks/card-tags-D8lZ-C_U.js +1 -0
- package/dist/elements/chunks/chat-thread-B3Ks-cKr.js +1 -0
- package/dist/elements/chunks/check-C8desc_h.js +1 -0
- package/dist/elements/chunks/chevron-down-DKtc2HNB.js +1 -0
- package/dist/elements/chunks/chevron-right-Dw4GPhwL.js +1 -0
- package/dist/elements/chunks/choice-card-Q9cYYDyM.js +1 -0
- package/dist/elements/chunks/circle-BeokBsvB.js +1 -0
- package/dist/elements/chunks/circle-check-wJSqt859.js +1 -0
- package/dist/elements/chunks/{circle-x-B7faJFlY.js → circle-x-D0MEfxOM.js} +1 -1
- package/dist/elements/chunks/code-block-C8VzAraE.js +1 -0
- package/dist/elements/chunks/{collapsible-DM1Ckd4e.js → collapsible-Cvj61obE.js} +1 -1
- package/dist/elements/chunks/{composer-BguS0QL_.js → composer-D86ztyL0.js} +2 -2
- package/dist/elements/chunks/confirm-card-BdyDAGLw.js +1 -0
- package/dist/elements/chunks/context-WHDl-xSx.js +1 -0
- package/dist/elements/chunks/conversation-list-CdR14_Y4.js +1 -0
- package/dist/elements/chunks/create-tween-C_A_B638.js +7 -0
- package/dist/elements/chunks/{default-input-Cqqilg9t.js → default-input-CSZqUM81.js} +1 -1
- package/dist/elements/chunks/define-BzPnNsXg.js +1 -0
- package/dist/elements/chunks/{disclosure-CIZ5c8nO.js → disclosure-DVKsoc1F.js} +1 -1
- package/dist/elements/chunks/{download-Dsbg3ste.js → download-BjA54IMJ.js} +1 -1
- package/dist/elements/chunks/{dropdown-BDd1-Zgi.js → dropdown-BjVVHWXH.js} +1 -1
- package/dist/elements/chunks/{ellipsis-Dw6qrss-.js → ellipsis-BNedmqfi.js} +1 -1
- package/dist/elements/chunks/embed-DQsl4XEq.js +1 -0
- package/dist/elements/chunks/{external-link-Vt74zAoL.js → external-link-Di-dw2CU.js} +1 -1
- package/dist/elements/chunks/{file-text-CZrZF6_v.js → file-text-B3sAODCV.js} +1 -1
- package/dist/elements/chunks/{file-tree-DF4okKyo.js → file-tree-Dgto-bMn.js} +1 -1
- package/dist/elements/chunks/{folder-glP5rha4.js → folder-D6rMadFV.js} +1 -1
- package/dist/elements/chunks/form-CILxvhQ7.js +1 -0
- package/dist/elements/chunks/hover-card-BtiF4XiU.js +1 -0
- package/dist/elements/chunks/{icon-BLb8beoi.js → icon-DKkmze3y.js} +1 -1
- package/dist/elements/chunks/{info-CIsv92JK.js → info-CWRjZkuy.js} +1 -1
- package/dist/elements/chunks/{input-Dds1kFyt.js → input--QJBK6BA.js} +1 -1
- package/dist/elements/chunks/{kbd-De_xbgi9.js → kbd-C3zuKh8R.js} +1 -1
- package/dist/elements/chunks/{link-i4r_k7z4.js → link-BL5JA7Q0.js} +1 -1
- package/dist/elements/chunks/link-preview-CU37bbXh.js +1 -0
- package/dist/elements/chunks/{loader-BmQH4s3V.js → loader-XmpyxyRH.js} +1 -1
- package/dist/elements/chunks/{markdown-Zi21WhUz.js → markdown-3Gv5UZ_o.js} +1 -1
- package/dist/elements/chunks/message-CnBA2OUt.js +1 -0
- package/dist/elements/chunks/message-X5NZbq-W.js +1 -0
- package/dist/elements/chunks/message-square-CnpAm_P0.js +1 -0
- package/dist/elements/chunks/{minimize-2--ifeCGrl.js → minimize-2-DwyrUTcM.js} +1 -1
- package/dist/elements/chunks/{model-switcher-B94YAKsM.js → model-switcher-CclaJEHt.js} +1 -1
- package/dist/elements/chunks/{overlay-kD8dwRv_.js → overlay-Cepd-KcS.js} +1 -1
- package/dist/elements/chunks/{paperclip-q21dYOSK.js → paperclip-CdwKjrlu.js} +1 -1
- package/dist/elements/chunks/{progress-bar-BrAIIaaJ.js → progress-bar-DMVXBD3b.js} +1 -1
- package/dist/elements/chunks/{prompt-suggestion-Bjn2K-fR.js → prompt-suggestion-Di_WZE_5.js} +1 -1
- package/dist/elements/chunks/reasoning-D0jMch5u.js +1 -0
- package/dist/elements/chunks/{resizable-Dp4JExql.js → resizable-1znbL1Gv.js} +1 -1
- package/dist/elements/chunks/{rotate-cw-CFWWkpj2.js → rotate-cw-DVhhgx8n.js} +1 -1
- package/dist/elements/chunks/{scroll-area-Bb0kHH1j.js → scroll-area-Bs4bFS1P.js} +1 -1
- package/dist/elements/chunks/scroll-button-Bet5NLc4.js +1 -0
- package/dist/elements/chunks/{separator-9kRgr1X9.js → separator-C5Gh8qGC.js} +1 -1
- package/dist/elements/chunks/{settings-B8lmec4D.js → settings-5vmEFg0K.js} +1 -1
- package/dist/elements/chunks/{settings-group-I0hEuXiN.js → settings-group-BWLB38kz.js} +1 -1
- package/dist/elements/chunks/{share-C3lTiYGt.js → share-BZ3G_DaB.js} +1 -1
- package/dist/elements/chunks/skeleton-DafPJQTp.js +1 -0
- package/dist/elements/chunks/slots-CIw9RlAe.js +1 -0
- package/dist/elements/chunks/source-D_qj_6uJ.js +1 -0
- package/dist/elements/chunks/{star-CSJtGxgv.js → star-Cuv7j2IE.js} +1 -1
- package/dist/elements/chunks/{store-CHyW95TP.js → store-C1StPHfW.js} +1 -1
- package/dist/elements/chunks/tasks-card-EyoBZfpA.js +1 -0
- package/dist/elements/chunks/{text-shimmer-Cn9TmNpI.js → text-shimmer-D9gRSh34.js} +1 -1
- package/dist/elements/chunks/textarea-DuhtHfcm.js +1 -0
- package/dist/elements/chunks/{thumbs-up-PNVVThEF.js → thumbs-up-B9hwv43w.js} +1 -1
- package/dist/elements/chunks/{toast-store-DKbQn3Wn.js → toast-store-Bm3DUEDe.js} +1 -1
- package/dist/elements/chunks/{tool-DjTRpIpl.js → tool-CDtW7L0z.js} +1 -1
- package/dist/elements/chunks/tooltip-eB226mGK.js +1 -0
- package/dist/elements/chunks/{triangle-alert-DsHTHCPm.js → triangle-alert-C1SNzTqK.js} +1 -1
- package/dist/elements/chunks/use-card-resolution-YZh2MvI5.js +1 -0
- package/dist/elements/chunks/{variant-aurora-Dc4nQUia.js → variant-aurora-Cj1F7D0l.js} +2 -2
- package/dist/elements/chunks/{variant-custom-y_3kVQhS.js → variant-custom-T9gpt7QE.js} +1 -1
- package/dist/elements/chunks/{variant-wave-D5OR1-9Z.js → variant-wave-CxU9z-TE.js} +2 -2
- package/dist/elements/chunks/{video-B7vmenoC.js → video-CK4R7Rus.js} +1 -1
- package/dist/elements/chunks/x-D1qx0aLd.js +1 -0
- package/dist/elements/coachmark.js +1 -1
- package/dist/elements/code-block.js +1 -1
- package/dist/elements/command.js +1 -1
- package/dist/elements/compare.js +1 -1
- package/dist/elements/composer.js +1 -1
- package/dist/elements/confirm-card.js +1 -1
- package/dist/elements/context-meter.js +1 -1
- package/dist/elements/conversation-list.js +1 -1
- package/dist/elements/default-input.d.ts +7 -7
- package/dist/elements/define.d.ts +1 -0
- package/dist/elements/dialog.js +1 -1
- package/dist/elements/disclosure.d.ts +1 -1
- package/dist/elements/editable-label.js +1 -1
- package/dist/elements/element-data-types.d.ts +107 -0
- package/dist/elements/embed.js +1 -1
- package/dist/elements/empty.js +1 -1
- package/dist/elements/feedback-bar.js +1 -1
- package/dist/elements/file-tree.js +1 -1
- package/dist/elements/file-upload.js +1 -1
- package/dist/elements/form.js +1 -1
- package/dist/elements/hover-card.js +1 -1
- package/dist/elements/icon.js +1 -1
- package/dist/elements/image.js +1 -1
- package/dist/elements/input.js +1 -1
- package/dist/elements/kbd.js +1 -1
- package/dist/elements/link-preview.js +1 -1
- package/dist/elements/loader.js +1 -1
- package/dist/elements/markdown.js +1 -1
- package/dist/elements/menu.js +1 -1
- package/dist/elements/message-skills.js +1 -1
- package/dist/elements/message.js +1 -1
- package/dist/elements/model-switcher.js +1 -1
- package/dist/elements/nav.js +1 -1
- package/dist/elements/notice.js +1 -1
- package/dist/elements/pane-group.js +1 -1
- package/dist/elements/pane.js +1 -1
- package/dist/elements/popover.js +1 -1
- package/dist/elements/progress-bar.js +1 -1
- package/dist/elements/prompt-dock.js +1 -1
- package/dist/elements/prompt-input.js +1 -1
- package/dist/elements/prompt-suggestions.js +1 -1
- package/dist/elements/reasoning.js +1 -1
- package/dist/elements/register.d.ts +8 -6
- package/dist/elements/remote.js +1 -1
- package/dist/elements/resizable.js +1 -1
- package/dist/elements/response-stream.js +2 -2
- package/dist/elements/screen.js +1 -1
- package/dist/elements/scroll-area.js +1 -1
- package/dist/elements/scroll-button.js +1 -1
- package/dist/elements/search.js +1 -1
- package/dist/elements/segmented.js +1 -1
- package/dist/elements/separator.js +1 -1
- package/dist/elements/setting-item.js +1 -1
- package/dist/elements/settings-group.js +1 -1
- package/dist/elements/skeleton.js +1 -1
- package/dist/elements/slots.d.ts +16 -0
- package/dist/elements/source.js +1 -1
- package/dist/elements/status.js +1 -1
- package/dist/elements/switch.js +1 -1
- package/dist/elements/tabs.js +1 -1
- package/dist/elements/tasks.js +1 -1
- package/dist/elements/text-shimmer.js +1 -1
- package/dist/elements/thinking-bar.js +1 -1
- package/dist/elements/thread.js +1 -1
- package/dist/elements/toast.js +1 -1
- package/dist/elements/tool.js +1 -1
- package/dist/elements/tooltip.js +1 -1
- package/dist/elements/validate-messages.d.ts +25 -0
- package/dist/elements/voice-input.js +1 -1
- package/dist/elements/voice-output.js +1 -1
- package/dist/elements.d.ts +2725 -214
- package/dist/index.d.ts +163 -125
- package/dist/index.js +6336 -5802
- package/dist/index.server.d.ts +5 -0
- package/dist/index.server.js +5446 -4937
- package/dist/kai-provider.es.d.ts +5 -0
- package/dist/kai-provider.es.js +79 -77
- package/dist/kai.es.d.ts +5 -0
- package/dist/kai.es.js +1 -1
- package/dist/llms/llms-full.txt +762 -148
- package/dist/llms/llms.txt +57 -5
- package/dist/mcp.es.js +4010 -770
- package/dist/primitives/card-component-types.d.ts +11 -0
- package/dist/primitives/card-data-types.d.ts +196 -0
- package/dist/primitives/card-host.d.ts +1 -1
- package/dist/primitives/card-recovery.d.ts +3 -3
- package/dist/primitives/card-registry.d.ts +4 -14
- package/dist/primitives/card-resolution.d.ts +1 -1
- package/dist/primitives/card-routing.d.ts +1 -1
- package/dist/primitives/card-tags.d.ts +11 -0
- package/dist/primitives/card-validate-cards.d.ts +57 -0
- package/dist/primitives/card-validate-generator.testlib.d.ts +20 -0
- package/dist/primitives/card-validate-schemas.d.ts +17 -0
- package/dist/primitives/card-validate.d.ts +24 -0
- package/dist/primitives/create-kai-chat.d.ts +3 -3
- package/dist/primitives/embed-providers.d.ts +1 -1
- package/dist/primitives/link-preview.d.ts +1 -1
- package/dist/primitives/message-feedback.d.ts +2 -2
- package/dist/primitives/toast-store.d.ts +1 -1
- package/dist/react/index.d.ts +717 -299
- package/dist/react/use-kai-chat.d.ts +8 -4
- package/dist/react.d.ts +5 -0
- package/dist/react.js +417 -316
- package/dist/register-impl-bzrZ44ft.js +145 -0
- package/dist/remote/host-embed.d.ts +1 -1
- package/dist/remote/index.d.ts +2 -2
- package/dist/remote/provider-runtime.d.ts +2 -2
- package/dist/remote/provider.d.ts +2 -2
- package/dist/remote/validate.d.ts +1 -1
- package/dist/remote/wire.d.ts +1 -1
- package/dist/schemas/artifact.schema.json +92 -0
- package/dist/schemas/embed.schema.json +0 -2
- package/dist/schemas/from-tool-call.d.ts +48 -0
- package/dist/schemas/index.d.ts +48 -0
- package/dist/schemas/link.schema.json +0 -2
- package/dist/schemas/provider-subsets.d.ts +136 -0
- package/dist/schemas/registry.d.ts +140 -0
- package/dist/schemas/tool-defs.d.ts +121 -0
- package/dist/schemas.d.ts +5 -0
- package/dist/schemas.js +717 -0
- package/dist/solid-BCJUQqwS.js +22750 -0
- package/dist/solid-BdENBcEq.js +20108 -0
- package/dist/solid.d.ts +66 -0
- package/dist/solid.js +261 -0
- package/dist/solid.server.d.ts +5 -0
- package/dist/solid.server.js +261 -0
- package/dist/state/index.d.ts +14 -4
- package/dist/state/messages.d.ts +8 -3
- package/dist/state/mock.d.ts +46 -0
- package/dist/state/parts.d.ts +85 -0
- package/dist/state/stream.d.ts +15 -9
- package/dist/state.d.ts +5 -0
- package/dist/state.js +253 -84
- package/dist/ui/action-icons.d.ts +1 -1
- package/dist/ui/collapsible.d.ts +11 -6
- package/dist/ui/dropdown.d.ts +47 -42
- package/dist/ui/pane-group.d.ts +1 -1
- package/dist/variant-aurora-1wMcXRUS.js +381 -0
- package/dist/variant-aurora-BKHHiOgL.js +391 -0
- package/dist/{variant-aurora-DVtGqVH6.js → variant-aurora-DOb_o4pC.js} +1 -1
- package/dist/{variant-custom-Dif-IV4u.js → variant-custom-CJGB0Kbw.js} +1 -1
- package/dist/variant-custom-Co0CkXJF.js +200 -0
- package/dist/variant-custom-DWSSgmd2.js +170 -0
- package/dist/variant-wave-C3-45cL6.js +218 -0
- package/dist/{variant-wave-DbfNdvSi.js → variant-wave-CEODq-9q.js} +1 -1
- package/dist/variant-wave-jdXBnCUZ.js +208 -0
- package/dist/wire/chunk.d.ts +171 -0
- package/dist/wire/consume.d.ts +26 -0
- package/dist/wire/encode.d.ts +148 -0
- package/dist/wire/formats/anthropic.d.ts +2 -0
- package/dist/wire/formats/openai.d.ts +5 -0
- package/dist/wire/index.d.ts +14 -0
- package/dist/wire/read.d.ts +26 -0
- package/dist/wire/sink-helpers.d.ts +21 -0
- package/dist/wire/sse.d.ts +33 -0
- package/dist/wire.d.ts +5 -53
- package/dist/wire.js +898 -0
- package/frameworks/react/index.tsx +183 -154
- package/frameworks/react/runtime.tsx +21 -1
- package/frameworks/react/use-kai-chat.tsx +11 -1
- package/llms-full.txt +762 -148
- package/llms.txt +57 -5
- package/package.json +61 -8
- package/src/agent-tooling/archetypes.ts +96 -0
- package/src/agent-tooling/integrations/anthropic.ts +412 -0
- package/src/agent-tooling/integrations/cloudflare.ts +136 -52
- package/src/agent-tooling/integrations/langgraph.ts +67 -13
- package/src/agent-tooling/integrations/mastra.ts +120 -12
- package/src/agent-tooling/integrations/mock.ts +43 -8
- package/src/agent-tooling/integrations/ollama.ts +61 -24
- package/src/agent-tooling/integrations/openai.ts +87 -0
- package/src/agent-tooling/integrations/openrouter.ts +56 -8
- package/src/agent-tooling/integrations/pi.ts +79 -23
- package/src/agent-tooling/integrations/pydantic-ai.ts +24 -2
- package/src/agent-tooling/integrations/vercel-ai-sdk.ts +84 -13
- package/src/agent-tooling/mcp/manifest.ts +206 -25
- package/src/agent-tooling/mcp/tools/debug.ts +13 -7
- package/src/agent-tooling/mcp/tools/reference.ts +285 -6
- package/src/agent-tooling/mcp/tools/scaffold.ts +3345 -675
- package/src/agent-tooling/registry.ts +17 -0
- package/src/agent-tooling/types.ts +229 -1
- package/src/components/artifact-card.tsx +120 -0
- package/src/components/audio-visualizer/index.tsx +9 -0
- package/src/components/audio-visualizer/labs/lab-choreography.ts +187 -0
- package/src/components/audio-visualizer/labs/lab-shaders.ts +363 -0
- package/src/components/audio-visualizer/labs/lab-visualizer.tsx +102 -0
- package/src/components/audio-visualizer/shader-canvas.tsx +31 -1
- package/src/components/card-fallback.tsx +42 -10
- package/src/components/card-renderer.tsx +146 -9
- package/src/components/chat-container.tsx +4 -0
- package/src/components/chat-thread.tsx +118 -51
- package/src/components/choice-card.tsx +17 -29
- package/src/components/composer.tsx +12 -2
- package/src/components/confirm-card.tsx +24 -20
- package/src/components/context.tsx +12 -4
- package/src/components/conversation-list.tsx +18 -2
- package/src/components/file-tree.tsx +11 -22
- package/src/components/file-upload.tsx +14 -8
- package/src/components/form.tsx +14 -47
- package/src/components/loader.tsx +39 -12
- package/src/components/message-skills.tsx +4 -0
- package/src/components/message.tsx +294 -46
- package/src/components/reasoning.tsx +92 -5
- package/src/components/response-compare-types.ts +25 -0
- package/src/components/response-compare.tsx +4 -9
- package/src/components/source.tsx +50 -24
- package/src/components/tasks-card.tsx +10 -33
- package/src/components/thread.tsx +84 -42
- package/src/components/toast.tsx +11 -5
- package/src/components/tool-classify.ts +26 -0
- package/src/components/tool-types.ts +31 -1
- package/src/elements/agent-card.tsx +11 -11
- package/src/elements/artifact.tsx +15 -7
- package/src/elements/attachments.tsx +6 -4
- package/src/elements/avatar.tsx +1 -1
- package/src/elements/button.tsx +3 -3
- package/src/elements/card.tsx +2 -2
- package/src/elements/cards.tsx +109 -14
- package/src/elements/chain-of-thought.tsx +14 -10
- package/src/elements/chat-scope-picker.tsx +9 -7
- package/src/elements/chat-types.ts +59 -6
- package/src/elements/chat-workspace.tsx +50 -9
- package/src/elements/chat.tsx +64 -3
- package/src/elements/choice.tsx +8 -8
- package/src/elements/coachmark.tsx +1 -1
- package/src/elements/command.tsx +8 -23
- package/src/elements/compare.tsx +7 -6
- package/src/elements/compiled.css +1 -1
- package/src/elements/composer.tsx +8 -8
- package/src/elements/confirm-card.tsx +9 -9
- package/src/elements/context-meter.tsx +4 -18
- package/src/elements/conversation-list.tsx +14 -9
- package/src/elements/default-input.tsx +3 -3
- package/src/elements/define.tsx +178 -43
- package/src/elements/editable-label.tsx +17 -2
- package/src/elements/element-data-types.ts +112 -0
- package/src/elements/element-meta.json +1596 -304
- package/src/elements/element-types.d.ts +2636 -211
- package/src/elements/feedback-bar.tsx +1 -1
- package/src/elements/file-tree.tsx +1 -1
- package/src/elements/file-upload.tsx +1 -1
- package/src/elements/form.tsx +14 -6
- package/src/elements/input.tsx +1 -1
- package/src/elements/kbd.tsx +1 -1
- package/src/elements/menu.tsx +10 -29
- package/src/elements/message-skills.tsx +7 -9
- package/src/elements/message.tsx +200 -32
- package/src/elements/model-switcher.tsx +5 -3
- package/src/elements/pane.tsx +1 -1
- package/src/elements/prompt-dock.tsx +8 -8
- package/src/elements/prompt-input.tsx +10 -10
- package/src/elements/prompt-suggestions.tsx +7 -4
- package/src/elements/reasoning.tsx +1 -1
- package/src/elements/register.ts +9 -0
- package/src/elements/resizable.globals.d.ts +36 -0
- package/src/elements/resizable.tsx +24 -13
- package/src/elements/response-stream.tsx +1 -1
- package/src/elements/scroll-button.tsx +1 -1
- package/src/elements/search.tsx +6 -1
- package/src/elements/segmented.tsx +5 -11
- package/src/elements/separator.tsx +1 -1
- package/src/elements/slots.ts +57 -24
- package/src/elements/source.tsx +21 -22
- package/src/elements/switch.tsx +3 -3
- package/src/elements/tasks.tsx +5 -4
- package/src/elements/thread.tsx +36 -4
- package/src/elements/toast.tsx +3 -2
- package/src/elements/tool.tsx +1 -1
- package/src/elements/validate-messages.ts +76 -0
- package/src/elements/voice-input.tsx +7 -7
- package/src/elements/voice-output.tsx +4 -4
- package/src/index.ts +141 -6
- package/src/primitives/card-component-types.ts +63 -0
- package/src/primitives/card-data-types.ts +312 -0
- package/src/primitives/card-recovery.ts +13 -2
- package/src/primitives/card-registry.tsx +34 -23
- package/src/primitives/card-schemas/artifact.schema.json +92 -0
- package/src/primitives/card-schemas/embed.schema.json +0 -2
- package/src/primitives/card-schemas/link.schema.json +0 -2
- package/src/primitives/card-tags.ts +61 -0
- package/src/primitives/card-validate-cards.ts +255 -0
- package/src/primitives/card-validate-generator.testlib.ts +38 -0
- package/src/primitives/card-validate-schemas.ts +58 -0
- package/src/primitives/card-validate.ts +48 -20
- package/src/primitives/create-tween.ts +54 -2
- package/src/primitives/message-feedback.ts +3 -2
- package/src/primitives/use-audio-analysis.ts +20 -1
- package/src/primitives/use-sequencer.ts +20 -1
- package/src/schemas/from-tool-call.ts +151 -0
- package/src/schemas/index.ts +340 -0
- package/src/schemas/provider-subsets.ts +538 -0
- package/src/schemas/registry.ts +355 -0
- package/src/schemas/tool-defs.ts +492 -0
- package/src/solid.ts +150 -0
- package/src/state/index.ts +27 -1
- package/src/state/messages.ts +21 -4
- package/src/state/mock.ts +211 -0
- package/src/state/parts.ts +295 -0
- package/src/state/stream.ts +56 -58
- package/src/ui/collapsible.tsx +17 -5
- package/src/ui/dropdown.tsx +46 -12
- package/src/ui/overlay.tsx +10 -4
- package/src/wire/chunk.ts +201 -0
- package/src/wire/consume.ts +440 -0
- package/src/wire/encode.ts +474 -0
- package/src/wire/formats/anthropic.ts +267 -0
- package/src/wire/formats/openai.ts +187 -0
- package/src/wire/index.ts +52 -0
- package/src/wire/read.ts +152 -0
- package/src/wire/sink-helpers.ts +50 -0
- package/src/wire/sse.ts +140 -0
- package/dist/create-tween-hvpmSuwW.js +0 -7
- package/dist/elements/chunks/arrow-left-DQocUFvy.js +0 -1
- package/dist/elements/chunks/audio-visualizer-BqNy7dfm.js +0 -1
- package/dist/elements/chunks/card-BkHbp4BA.js +0 -1
- package/dist/elements/chunks/chat-thread-CkRN6pZ7.js +0 -1
- package/dist/elements/chunks/check-B9-gn6SS.js +0 -1
- package/dist/elements/chunks/chevron-down-BNsxc2hk.js +0 -1
- package/dist/elements/chunks/chevron-right-Hv00StBO.js +0 -1
- package/dist/elements/chunks/circle-0e3KJTDr.js +0 -1
- package/dist/elements/chunks/circle-check-BNAS0JEy.js +0 -1
- package/dist/elements/chunks/code-block-BFUHn6Va.js +0 -1
- package/dist/elements/chunks/context-Cb7k4F-v.js +0 -1
- package/dist/elements/chunks/conversation-list-CvoAkWhN.js +0 -1
- package/dist/elements/chunks/create-tween-oVvAIG8r.js +0 -7
- package/dist/elements/chunks/define-D1din7Ld.js +0 -1
- package/dist/elements/chunks/hover-card-Cmm6Q53m.js +0 -1
- package/dist/elements/chunks/message-BqyXDN3-.js +0 -1
- package/dist/elements/chunks/message-feedback-Djr3z6Yb.js +0 -1
- package/dist/elements/chunks/message-square-vFAgbDaX.js +0 -1
- package/dist/elements/chunks/reasoning-DTpCGtnY.js +0 -1
- package/dist/elements/chunks/scroll-button-B0cH5fXf.js +0 -1
- package/dist/elements/chunks/skeleton-DSXW5Yya.js +0 -1
- package/dist/elements/chunks/slots--V19HwUb.js +0 -1
- package/dist/elements/chunks/textarea-CUwqAX0G.js +0 -1
- package/dist/elements/chunks/tooltip-CgBZCVKp.js +0 -1
- package/dist/elements/chunks/use-card-resolution-BSu_XHjH.js +0 -1
- package/dist/elements/chunks/x-GRUeeQFT.js +0 -1
- package/dist/host-embed.d.ts +0 -33
- package/dist/kai-provider.d.ts +0 -2
- package/dist/origin.d.ts +0 -5
- package/dist/provider-runtime.d.ts +0 -27
- package/dist/register-impl-DQyejdfb.js +0 -145
- package/dist/validate.d.ts +0 -4
- package/dist/version.d.ts +0 -4
- package/src/components/component-meta.json +0 -4550
- package/src/elements/card-media.jpg +0 -0
- package/src/elements/resizable.d.ts +0 -27
- package/src/elements/video-poster.jpg +0 -0
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { z } from 'zod';
|
|
2
2
|
import type { Tool } from './types';
|
|
3
|
-
import { Placement, Framework } from '../../types';
|
|
4
|
-
import type { Integration
|
|
3
|
+
import { Placement, Framework, SECRET_ENV_VAR } from '../../types';
|
|
4
|
+
import type { Integration } from '../../types';
|
|
5
5
|
import {
|
|
6
6
|
getArchetype,
|
|
7
7
|
getIntegration,
|
|
@@ -12,16 +12,34 @@ import {
|
|
|
12
12
|
/**
|
|
13
13
|
* scaffold — the keystone tool. Composes a working chat surface from four axes:
|
|
14
14
|
*
|
|
15
|
-
*
|
|
15
|
+
* components × integration × placement × framework
|
|
16
16
|
*
|
|
17
17
|
* and emits three labeled blocks an AI consumer can paste straight in:
|
|
18
|
-
* (1) Front-end — the
|
|
18
|
+
* (1) Front-end — the surface's kai-* components, rendered for the chosen
|
|
19
19
|
* framework and sized for the placement, wired with the
|
|
20
20
|
* `messages` property + `kai-submit` per the Streaming recipe.
|
|
21
21
|
* (2) Backend — the integration's route template for the framework (with a
|
|
22
22
|
* language-aware fallback when there's no exact match).
|
|
23
23
|
* (3) Run note — how to run it + the env vars to set.
|
|
24
24
|
*
|
|
25
|
+
* THE SURFACE AXIS IS A COMPONENTS LIST, NOT AN ARCHETYPE ID, AND THAT IS THE
|
|
26
|
+
* POINT OF THIS FILE'S SHAPE.
|
|
27
|
+
*
|
|
28
|
+
* It used to be an archetype id, and that made the six presets the whole of the
|
|
29
|
+
* expressible space: `agentic` and `workspace` differ by nothing except which
|
|
30
|
+
* components compose, so a builder who wanted both — a resizable artifact pane
|
|
31
|
+
* that ALSO renders the tool calls that produced the artifact — could not ask for
|
|
32
|
+
* it, and neither could `create-kai`'s feature multi-select. Adding a seventh
|
|
33
|
+
* preset for every such combination is 2^n presets in the limit.
|
|
34
|
+
*
|
|
35
|
+
* So `renderSurface({ framework, components, integration })` is the renderer, the
|
|
36
|
+
* archetypes are DATA over it (a preset is a named components list plus a default
|
|
37
|
+
* placement), and there is exactly one of each. `create-kai` imports the same
|
|
38
|
+
* function rather than growing a second one — a parallel renderer is the specific
|
|
39
|
+
* failure this extraction exists to prevent, which is why
|
|
40
|
+
* `assertPresetsAreData` in scripts/verify-scaffold-compiles.mjs asserts that a
|
|
41
|
+
* preset request and a components request emit byte-identical surfaces.
|
|
42
|
+
*
|
|
25
43
|
* The handler is called directly in tests (bypassing MCP's zod validation), so it
|
|
26
44
|
* validates `useCase` + `integration` against the registry itself and returns
|
|
27
45
|
* graceful, self-correcting error text when either is unknown.
|
|
@@ -41,7 +59,7 @@ interface PlacementStyle {
|
|
|
41
59
|
/** one-line human description of the layout */
|
|
42
60
|
note: string;
|
|
43
61
|
/** optional extra comment lines describing an alternative layout form */
|
|
44
|
-
altNote?: string;
|
|
62
|
+
altNote?: string[];
|
|
45
63
|
}
|
|
46
64
|
|
|
47
65
|
// The chat element must fill its container. In a `display: flex; flex-direction:
|
|
@@ -50,14 +68,59 @@ interface PlacementStyle {
|
|
|
50
68
|
const FLEX_FILL = 'flex: 1; min-height: 0;';
|
|
51
69
|
const BLOCK_FILL = 'height: 100%; width: 100%;';
|
|
52
70
|
|
|
71
|
+
/**
|
|
72
|
+
* full-page, and it has to be true in a STOCK starter — not just in an empty page.
|
|
73
|
+
*
|
|
74
|
+
* It used to be `height: 100dvh; width: 100%`, which is full-page only if nothing
|
|
75
|
+
* above it interferes, and in the templates consumers actually run something
|
|
76
|
+
* always does. Two frameworks hit it independently:
|
|
77
|
+
*
|
|
78
|
+
* · Vite's `react-ts` template ships `#root { max-width: 1280px; margin: 0 auto;
|
|
79
|
+
* padding: 2rem; text-align: center }`, so the chat was capped, inset by 2rem
|
|
80
|
+
* and had its text centred — inherited straight through the shadow boundary.
|
|
81
|
+
* · The official TanStack Start starter renders a Header (73px) and a Footer
|
|
82
|
+
* (181px) around every route in `__root.tsx`, so a 100dvh sibling put the
|
|
83
|
+
* composer 13px BELOW the fold at 1280x800.
|
|
84
|
+
*
|
|
85
|
+
* `position: fixed; inset: 0` is the one fix that works for both without a
|
|
86
|
+
* per-framework patch: it takes the surface out of flow entirely, so an ancestor's
|
|
87
|
+
* width cap, padding and flex centring stop applying and a sibling header/footer
|
|
88
|
+
* stops consuming height. `text-align: start` is still needed because text-align
|
|
89
|
+
* INHERITS regardless of positioning.
|
|
90
|
+
*
|
|
91
|
+
* `z-index` matters as much as the positioning, and is the same 1000 the other two
|
|
92
|
+
* fixed placements already use. Without it the surface stacks at `auto`, and the
|
|
93
|
+
* TanStack starter's header is `sticky top-0 z-50` — so the chat would sit at the
|
|
94
|
+
* right geometry and still have the top 73px of its thread painted over. Half-
|
|
95
|
+
* covered is the worst outcome available: either the chat owns the viewport or it
|
|
96
|
+
* does not.
|
|
97
|
+
*
|
|
98
|
+
* The trade is stated in the emitted comment rather than hidden: this overlays
|
|
99
|
+
* whatever the starter draws around it, nav included. A consumer who wants the chat
|
|
100
|
+
* inside their layout wants `placement: 'inline'`, which is what that placement is for.
|
|
101
|
+
*/
|
|
102
|
+
const FULL_PAGE: PlacementStyle = {
|
|
103
|
+
style:
|
|
104
|
+
'position: fixed; inset: 0; display: flex; flex-direction: column; ' +
|
|
105
|
+
'text-align: start; z-index: 1000;',
|
|
106
|
+
chatFill: FLEX_FILL,
|
|
107
|
+
note: 'fills the viewport (fixed, inset 0)',
|
|
108
|
+
altNote: [
|
|
109
|
+
'FULL PAGE MEANS FULL PAGE: `position: fixed; inset: 0` is deliberate, not a stray overlay.',
|
|
110
|
+
'`height: 100dvh` is full-page only in an empty document, and stock starters are not empty —',
|
|
111
|
+
"Vite's react-ts template caps #root at 1126px, centres its text and border-boxes it, and the",
|
|
112
|
+
'TanStack Start starter wraps every route in a Header + Footer that pushed the composer 13px',
|
|
113
|
+
'below the fold at 1280x800. Fixed positioning escapes both, `text-align: start` undoes the',
|
|
114
|
+
'inherited centring, and z-index keeps a sticky header from painting over the thread.',
|
|
115
|
+
'This DOES cover the chrome around it (nav included) — that is what full-page means here.',
|
|
116
|
+
'Want the chat to sit INSIDE your own layout instead? Use placement: "inline".',
|
|
117
|
+
],
|
|
118
|
+
};
|
|
119
|
+
|
|
53
120
|
function placementStyle(placement: string): PlacementStyle {
|
|
54
121
|
switch (placement) {
|
|
55
122
|
case 'full-page':
|
|
56
|
-
return
|
|
57
|
-
style: 'height: 100dvh; width: 100%; display: flex; flex-direction: column;',
|
|
58
|
-
chatFill: FLEX_FILL,
|
|
59
|
-
note: 'fills the viewport (100dvh)',
|
|
60
|
-
};
|
|
123
|
+
return FULL_PAGE;
|
|
61
124
|
case 'inline':
|
|
62
125
|
return {
|
|
63
126
|
style: 'width: 100%; max-width: 720px; height: 540px; margin: 0 auto; display: flex; flex-direction: column;',
|
|
@@ -73,9 +136,10 @@ function placementStyle(placement: string): PlacementStyle {
|
|
|
73
136
|
'border-inline-start: 1px solid var(--kai-color-border); display: flex; flex-direction: column; z-index: 1000;',
|
|
74
137
|
chatFill: FLEX_FILL,
|
|
75
138
|
note: 'full-height side panel, docked to the trailing edge (100dvh)',
|
|
76
|
-
altNote:
|
|
139
|
+
altNote: [
|
|
77
140
|
'In-flow alternative (push content instead of overlay): drop `position`/`z-index` and ' +
|
|
78
|
-
|
|
141
|
+
'make this a `flex: 0 0 380px` column inside a `display: flex` row at `height: 100dvh`.',
|
|
142
|
+
],
|
|
79
143
|
};
|
|
80
144
|
case 'docked-widget':
|
|
81
145
|
// The bottom-right floating bubble — rounded, elevated, fixed size.
|
|
@@ -90,101 +154,840 @@ function placementStyle(placement: string): PlacementStyle {
|
|
|
90
154
|
default:
|
|
91
155
|
// Unknown placement falls back to full-page (full height) rather than the bubble,
|
|
92
156
|
// so a future Placement enum member doesn't silently render as a widget.
|
|
93
|
-
return
|
|
94
|
-
style: 'height: 100dvh; width: 100%; display: flex; flex-direction: column;',
|
|
95
|
-
chatFill: FLEX_FILL,
|
|
96
|
-
note: 'fills the viewport (100dvh)',
|
|
97
|
-
};
|
|
157
|
+
return FULL_PAGE;
|
|
98
158
|
}
|
|
99
159
|
}
|
|
100
160
|
|
|
101
|
-
// ── suggestions
|
|
161
|
+
// ── suggestions ───────────────────────────────────────────────────────────────
|
|
102
162
|
|
|
103
163
|
/** Default starter prompts so the suggestions feature always shows. */
|
|
104
164
|
const DEFAULT_SUGGESTIONS = ["What's new?", 'How can you help?'];
|
|
105
165
|
|
|
106
|
-
/** A canned assistant reply the mock integration streams back token-by-token. */
|
|
107
|
-
const MOCK_REPLY =
|
|
108
|
-
"Hi! I'm a local preview — no backend or API key needed. Swap `integration` for a real provider (openrouter, ollama, …) and I'll talk to a real model.";
|
|
109
|
-
|
|
110
166
|
/** Render a string[] as a JS array literal (JSON-quoted — keeps apostrophes readable). */
|
|
111
167
|
function jsArray(items: string[]): string {
|
|
112
168
|
return '[' + items.map((s) => JSON.stringify(s)).join(', ') + ']';
|
|
113
169
|
}
|
|
114
170
|
|
|
171
|
+
// ── streaming: import the adapter, do not re-hand-roll it ─────────────────────
|
|
172
|
+
|
|
115
173
|
/**
|
|
116
|
-
*
|
|
117
|
-
*
|
|
118
|
-
*
|
|
119
|
-
*
|
|
120
|
-
*
|
|
121
|
-
*
|
|
122
|
-
*
|
|
123
|
-
*
|
|
124
|
-
* so
|
|
125
|
-
*
|
|
126
|
-
*
|
|
174
|
+
* How one framework exposes the turn's thread — the array every round of the
|
|
175
|
+
* tool loop re-encodes.
|
|
176
|
+
*
|
|
177
|
+
* THE WHOLE PROBLEM IN ONE INTERFACE. Round 2's request is
|
|
178
|
+
* `toOpenAIMessages(<the thread INCLUDING the assistant turn so far>)`, and the
|
|
179
|
+
* assistant message is appended by `createAssistantStream` through the setter,
|
|
180
|
+
* so the submit handler never holds it. Three of the four surfaces already keep
|
|
181
|
+
* their messages somewhere a closure can read back synchronously
|
|
182
|
+
* (`chat.messages`, `messages.value`, a Svelte `let`), so for those the thread
|
|
183
|
+
* IS that live value and there is nothing to add. React is the odd one:
|
|
184
|
+
* `useState` cannot be read back inside the async turn that is writing it, so
|
|
185
|
+
* the turn declares its own `thread` and `setMessages` becomes the projection of
|
|
186
|
+
* it. See `REACT_THREAD` for why that is a single source of truth and not a
|
|
187
|
+
* mirror.
|
|
127
188
|
*/
|
|
128
|
-
|
|
189
|
+
interface ThreadBinding {
|
|
190
|
+
/** Lines that open the turn: append the user message and commit it. */
|
|
191
|
+
open(ctx: { pad: string; userMessage: string; typed: boolean }): string[];
|
|
192
|
+
/** Expression that reads the CURRENT thread, valid at any point in the turn. */
|
|
193
|
+
live: string;
|
|
194
|
+
/** The `SetMessages` updater handed to `createAssistantStream`. */
|
|
195
|
+
setter: string;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
/**
|
|
199
|
+
* React (and next / tanstack-start, which are React).
|
|
200
|
+
*
|
|
201
|
+
* `thread` is the turn's single source of truth; `setMessages(thread)` is a
|
|
202
|
+
* projection of it for rendering, never read back. That ordering matters: the
|
|
203
|
+
* inverse (holding a copy and folding FROM it while React holds the truth) is
|
|
204
|
+
* the mirror bug this kit already shipped once — `createAssistantStream` used to
|
|
205
|
+
* fold from a local `currentParts` and silently clobbered any edit made through
|
|
206
|
+
* the store. Here nothing folds from the store, so nothing can be clobbered by a
|
|
207
|
+
* stale copy; the one rule, stated in the emitted comment, is that a turn's
|
|
208
|
+
* writes all go through `set`.
|
|
209
|
+
*/
|
|
210
|
+
const REACT_THREAD: ThreadBinding = {
|
|
211
|
+
open: ({ pad, userMessage }) => [
|
|
212
|
+
`${pad}// THE TURN OWNS THE THREAD. Every round of the loop below re-encodes the`,
|
|
213
|
+
`${pad}// whole thread, and React state cannot be read back to get it: setMessages`,
|
|
214
|
+
`${pad}// is async and this closure captured the pre-submit \`messages\`. So \`thread\``,
|
|
215
|
+
`${pad}// is the source of truth for this turn and setMessages just projects it for`,
|
|
216
|
+
`${pad}// rendering. Route any other messages write you make mid-turn through set().`,
|
|
217
|
+
`${pad}let thread: ChatMessage[] = [...messages, ${userMessage}];`,
|
|
218
|
+
`${pad}const set: SetMessages = (fn) => { thread = fn(thread); setMessages(thread); };`,
|
|
219
|
+
`${pad}setMessages(thread);`,
|
|
220
|
+
],
|
|
221
|
+
live: 'thread',
|
|
222
|
+
setter: 'set',
|
|
223
|
+
};
|
|
224
|
+
|
|
225
|
+
/** A framework whose messages live in a variable/property the turn can read back
|
|
226
|
+
* (html's `chat.messages`, Vue's `messages.value`, Svelte's `messages`). The
|
|
227
|
+
* live value IS the thread: no local copy, nothing to keep in sync.
|
|
228
|
+
*
|
|
229
|
+
* `firstRead` differs from `expr` only where the first read can precede any
|
|
230
|
+
* write: an un-upgraded `<kai-chat>` has no `messages` yet, and spreading
|
|
231
|
+
* `undefined` throws. Every later read is of a value this code assigned. */
|
|
232
|
+
function liveThreadBinding(expr: string, setter: string, firstRead = expr): ThreadBinding {
|
|
233
|
+
return {
|
|
234
|
+
open: ({ pad, userMessage }) => [
|
|
235
|
+
`${pad}// ${expr} IS the thread: the stream writes the assistant message back`,
|
|
236
|
+
`${pad}// through it, so every round below re-encodes the live, current value.`,
|
|
237
|
+
`${pad}${expr} = [...${firstRead}, ${userMessage}];`,
|
|
238
|
+
],
|
|
239
|
+
live: expr,
|
|
240
|
+
setter,
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* A framework whose messages sit behind a GETTER plus a separate setter call —
|
|
246
|
+
* an Angular signal (`this.messages()` / `this.messages.set(…)`) or a Solid one
|
|
247
|
+
* (`messages()` / `setMessages(…)`). Same story as `liveThreadBinding`: the read
|
|
248
|
+
* is synchronous, so the live value IS the thread and React's turn-scoped copy
|
|
249
|
+
* (`REACT_THREAD`) is not needed. The only difference is that the write is a
|
|
250
|
+
* call, not an assignment, so the commit is passed in instead of derived.
|
|
251
|
+
*/
|
|
252
|
+
function accessorThreadBinding(read: string, commit: (value: string) => string, setter: string): ThreadBinding {
|
|
253
|
+
return {
|
|
254
|
+
open: ({ pad, userMessage }) => [
|
|
255
|
+
`${pad}// ${read} IS the thread: the stream writes the assistant message back`,
|
|
256
|
+
`${pad}// through the setter, so every round below re-encodes the live, current`,
|
|
257
|
+
`${pad}// value. A signal reads back synchronously, so there is no React-style`,
|
|
258
|
+
`${pad}// stale-closure problem and no turn-local copy to keep in sync.`,
|
|
259
|
+
`${pad}${commit(`[...${read}, ${userMessage}]`)}`,
|
|
260
|
+
],
|
|
261
|
+
live: read,
|
|
262
|
+
setter,
|
|
263
|
+
};
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/**
|
|
267
|
+
* The real-backend submit body. Four lines of adapter, the rest is fetch.
|
|
268
|
+
*
|
|
269
|
+
* This deliberately reverses the inline-everything policy that governs the mock
|
|
270
|
+
* path. Inlining was correct while the kit had nothing to import; it is now the
|
|
271
|
+
* reason a scaffold with kai-tool in its archetype rendered a panel no code path
|
|
272
|
+
* could ever fill, and the hand-rolled reader it replaces was wrong about
|
|
273
|
+
* multi-line SSE frames and codepoints split across a socket boundary.
|
|
274
|
+
*
|
|
275
|
+
* `createAssistantStream` appends the in-flight assistant message itself and
|
|
276
|
+
* folds every delta onto its `parts`, so the scaffold no longer hand-builds an
|
|
277
|
+
* empty assistant message. `readOpenAIStream` parses the SSE properly:
|
|
278
|
+
* keep-alive comments, multi-line frames, codepoints split across a socket
|
|
279
|
+
* boundary, tool calls and reasoning. The inline reader this replaces got the
|
|
280
|
+
* last three wrong and could only ever produce text.
|
|
281
|
+
*
|
|
282
|
+
* `commitSet(expr)` is how each framework writes a whole new messages array, and
|
|
283
|
+
* `setterAdapter` is the `SetMessages` updater createAssistantStream drives.
|
|
284
|
+
* Both are used by the single-round shape only; the tool-loop shape takes its
|
|
285
|
+
* thread and its setter from `thread` (see `ThreadBinding`).
|
|
286
|
+
*/
|
|
287
|
+
function realStreamBody(opts: {
|
|
129
288
|
pad: string;
|
|
130
289
|
/** read the current messages array (for building `history`) */
|
|
131
290
|
read: string;
|
|
132
|
-
/** commit
|
|
133
|
-
|
|
134
|
-
/**
|
|
135
|
-
|
|
291
|
+
/** commit a whole new messages array */
|
|
292
|
+
commitSet: (expr: string) => string;
|
|
293
|
+
/** the `SetMessages` functional-updater expression handed to createAssistantStream */
|
|
294
|
+
setterAdapter: string;
|
|
136
295
|
/** set loading true/false */
|
|
137
296
|
setLoading: (v: 'true' | 'false') => string;
|
|
297
|
+
/** the JSON.stringify argument for the POST body, given the thread expression */
|
|
298
|
+
bodyPayload: (thread: string) => string;
|
|
299
|
+
/** emit `as const` + the ChatMessage[] annotation (strict-TS frameworks) */
|
|
300
|
+
strictRoles?: boolean;
|
|
301
|
+
/** archetype renders kai-tool → emit the LIVE multi-round loop */
|
|
302
|
+
toolLoop: boolean;
|
|
303
|
+
/** the scaffold declares a card registry → the loop gets its cardFromToolCall arm */
|
|
304
|
+
cards?: boolean;
|
|
305
|
+
/** how this framework exposes the turn's thread (tool-loop shape only) */
|
|
306
|
+
thread: ThreadBinding;
|
|
307
|
+
/** where the submitted text comes from — every kai-* target reads it off the
|
|
308
|
+
* `kai-submit` CustomEvent; `solid` renders the SolidJS `PromptInput`, which
|
|
309
|
+
* has no such event, so its submitted text is the controlled input signal. */
|
|
310
|
+
valueSource?: string;
|
|
311
|
+
/** lines emitted right after the value is read and guarded */
|
|
312
|
+
afterValue?: string[];
|
|
138
313
|
/**
|
|
139
|
-
*
|
|
140
|
-
*
|
|
141
|
-
*
|
|
314
|
+
* The `mock` integration. Swaps ONLY the source of the stream — the canned
|
|
315
|
+
* responder instead of `fetch('/api/chat')` — and leaves every other line
|
|
316
|
+
* identical. That identity is the point: see `mockRequest`.
|
|
142
317
|
*/
|
|
143
|
-
|
|
318
|
+
mock?: boolean;
|
|
144
319
|
}): string {
|
|
145
|
-
const {
|
|
320
|
+
const {
|
|
321
|
+
pad, read, commitSet, setterAdapter, setLoading, bodyPayload, strictRoles = false, toolLoop, thread,
|
|
322
|
+
cards = false, valueSource = 'e.detail.value', afterValue = [], mock = false,
|
|
323
|
+
} = opts;
|
|
146
324
|
const asConst = strictRoles ? ' as const' : '';
|
|
147
|
-
|
|
325
|
+
// Under strict TS an un-annotated array literal widens the part's `type` to
|
|
326
|
+
// `string`, so the later commit fails TS2322. Plain-JS contexts (html) have no
|
|
327
|
+
// type to annotate with.
|
|
328
|
+
const historyType = strictRoles ? ': ChatMessage[]' : '';
|
|
329
|
+
const userMessage = `{ id: crypto.randomUUID(), role: 'user'${asConst}, parts: [{ type: 'text', text: value }] }`;
|
|
330
|
+
|
|
331
|
+
const open = toolLoop
|
|
332
|
+
? thread.open({ pad, userMessage, typed: strictRoles })
|
|
333
|
+
: [
|
|
334
|
+
`${pad}const history${historyType} = [...${read}, ${userMessage}];`,
|
|
335
|
+
`${pad}${commitSet('history')}`,
|
|
336
|
+
];
|
|
337
|
+
const threadExpr = toolLoop ? thread.live : 'history';
|
|
338
|
+
const setter = toolLoop ? thread.setter : setterAdapter;
|
|
339
|
+
|
|
340
|
+
/**
|
|
341
|
+
* The mock's request. Note what it is NOT: it is not a different streaming
|
|
342
|
+
* strategy, it is the same two lines with a different source expression.
|
|
343
|
+
*
|
|
344
|
+
* `mockResponse(value)` yields SSE frames that `readOpenAIStream` parses
|
|
345
|
+
* exactly as it parses a provider's, so the no-backend preview exercises the
|
|
346
|
+
* kit's real reader rather than a hand-rolled fold — and swapping to a real
|
|
347
|
+
* backend is replacing this one expression with the `fetch` below.
|
|
348
|
+
*
|
|
349
|
+
* It is also, deliberately, impossible to mistake for a real response: the
|
|
350
|
+
* stream opens with a `: kai-mock` SSE comment, every frame carries a
|
|
351
|
+
* `_kai_mock` marker, the model reports as `kai-mock` and the turn reports
|
|
352
|
+
* zero tokens. See `createMockResponder` in @kitn.ai/ui/state.
|
|
353
|
+
*/
|
|
354
|
+
const mockRequest = (indent: string): string[] => [
|
|
355
|
+
`${indent}// NO BACKEND AND NO PROVIDER. mockResponse() returns canned SSE frames that`,
|
|
356
|
+
`${indent}// are read by the SAME parser a real model's response goes through, so this`,
|
|
357
|
+
`${indent}// preview exercises the real path. Every frame is marked as a mock (a`,
|
|
358
|
+
`${indent}// ': kai-mock' banner, a _kai_mock field, model 'kai-mock', zero usage), so`,
|
|
359
|
+
`${indent}// nothing here can be mistaken for a real turn.`,
|
|
360
|
+
`${indent}//`,
|
|
361
|
+
`${indent}// TO GO LIVE, only this one line changes: \`res\` becomes the POST to your`,
|
|
362
|
+
`${indent}// route, with toOpenAIMessages(${threadExpr}) as the body. Rather than copy`,
|
|
363
|
+
`${indent}// that request into a comment here — where it would drift from the real`,
|
|
364
|
+
`${indent}// one — scaffold again with a provider (integration: 'openrouter', 'ollama',`,
|
|
365
|
+
`${indent}// …) and the emitted code is the exact replacement, backend route included.`,
|
|
366
|
+
`${indent}// Everything below this line is already the real path and stays as it is.`,
|
|
367
|
+
`${indent}const res = mockResponse(value);`,
|
|
368
|
+
`${indent}const turn = await readOpenAIStream(res, stream);`,
|
|
369
|
+
];
|
|
370
|
+
|
|
371
|
+
const realRequest = (indent: string): string[] => [
|
|
372
|
+
`${indent}const res = await fetch('/api/chat', {`,
|
|
373
|
+
`${indent} method: 'POST',`,
|
|
374
|
+
`${indent} headers: { 'Content-Type': 'application/json' },`,
|
|
375
|
+
`${indent} body: JSON.stringify(${bodyPayload(threadExpr)}),`,
|
|
376
|
+
`${indent}});`,
|
|
377
|
+
`${indent}// The finished turn: text, reasoning, tool calls, stop reason, usage. An`,
|
|
378
|
+
`${indent}// error FRAME inside a 200 stream lands on turn.error, and whatever`,
|
|
379
|
+
`${indent}// streamed before it is already on the message. A non-ok RESPONSE throws`,
|
|
380
|
+
`${indent}// instead, which is the catch below.`,
|
|
381
|
+
`${indent}const turn = await readOpenAIStream(res, stream);`,
|
|
382
|
+
];
|
|
383
|
+
|
|
384
|
+
const request = mock ? mockRequest : realRequest;
|
|
385
|
+
|
|
148
386
|
return [
|
|
149
|
-
`${pad}const value =
|
|
387
|
+
`${pad}const value = ${valueSource}.trim();`,
|
|
150
388
|
`${pad}if (!value) return;`,
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
`${pad}${commitInitial(`[...history, { id: assistantId, role: 'assistant'${asConst}, content: '' }]`)}`,
|
|
389
|
+
...afterValue.map((l) => `${pad}${l}`),
|
|
390
|
+
...open,
|
|
154
391
|
`${pad}${setLoading('true')}`,
|
|
155
|
-
`${pad}//
|
|
156
|
-
`${pad}
|
|
157
|
-
`${pad}
|
|
158
|
-
`${pad}
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
392
|
+
`${pad}// createAssistantStream appends the in-flight assistant message and folds`,
|
|
393
|
+
`${pad}// every delta onto its parts. readOpenAIStream parses the SSE: keep-alive`,
|
|
394
|
+
`${pad}// comments, multi-line frames, split codepoints, tool calls, reasoning.`,
|
|
395
|
+
`${pad}const stream = createAssistantStream(${setter});`,
|
|
396
|
+
...(toolLoop ? toolLoopBody({ pad, request, threadExpr, cards }) : [
|
|
397
|
+
`${pad}try {`,
|
|
398
|
+
...request(`${pad} `),
|
|
399
|
+
`${pad} if (turn.error) console.error('Model error:', turn.error.message);`,
|
|
400
|
+
]),
|
|
401
|
+
`${pad}} catch (err) {`,
|
|
402
|
+
`${pad} // Without this a bad key is a permanently blank assistant bubble plus an`,
|
|
403
|
+
`${pad} // unhandled rejection. abort() settles the message and flips any tool`,
|
|
404
|
+
`${pad} // panel still waiting on a result to output-error, so nothing spins`,
|
|
405
|
+
`${pad} // forever; text that already streamed stays put.`,
|
|
406
|
+
`${pad} stream.abort(err instanceof Error ? err.message : 'Request failed');`,
|
|
407
|
+
`${pad} console.error(err); // swap in your own error surface (a toast, a banner)`,
|
|
408
|
+
`${pad}} finally {`,
|
|
409
|
+
`${pad} // done() SETTLES the message: every sink call after it is dropped, which`,
|
|
410
|
+
`${pad} // is why the whole loop runs above it and not after.`,
|
|
411
|
+
`${pad} stream.done();`,
|
|
412
|
+
`${pad} ${setLoading('false')}`,
|
|
164
413
|
`${pad}}`,
|
|
165
|
-
`${pad}${setLoading('false')}`,
|
|
166
414
|
].join('\n');
|
|
167
415
|
}
|
|
168
416
|
|
|
417
|
+
/**
|
|
418
|
+
* The multi-round tool loop, LIVE.
|
|
419
|
+
*
|
|
420
|
+
* It used to be emitted commented out, on the reasoning that a loop calling
|
|
421
|
+
* tools that do not exist yet would fail on the first run. That reasoning was
|
|
422
|
+
* wrong in the way that matters: the commented block named an undefined
|
|
423
|
+
* `runYourTool`, and its second round was prose ("then POST again with
|
|
424
|
+
* toOpenAIMessages() over the updated thread") describing a value the consumer
|
|
425
|
+
* had no way to obtain — `history` never contains the assistant message, the
|
|
426
|
+
* `messages` closure is stale by construction, and `AssistantStream` is
|
|
427
|
+
* write-only. So the archetype's headline capability could not be completed by
|
|
428
|
+
* uncommenting, or by any amount of local editing.
|
|
429
|
+
*
|
|
430
|
+
* Live, with a `runTool` stub that answers the one tool the scaffold declares,
|
|
431
|
+
* it runs end to end on the first submit: the panel reaches `output-available`
|
|
432
|
+
* and the model's answer streams into the same message, after the tool part, as
|
|
433
|
+
* a new text part.
|
|
434
|
+
*
|
|
435
|
+
* Both rounds drive the SAME `AssistantStream`, so the whole exchange folds into
|
|
436
|
+
* one assistant message with its parts in stream order — which is what
|
|
437
|
+
* `toOpenAIMessages` splits back into `assistant(tool_calls) → tool → assistant`
|
|
438
|
+
* on the way out.
|
|
439
|
+
*/
|
|
440
|
+
function toolLoopBody(opts: {
|
|
441
|
+
pad: string;
|
|
442
|
+
request: (indent: string) => string[];
|
|
443
|
+
threadExpr: string;
|
|
444
|
+
/** the scaffold declares a card registry, so a `kai_*` call is a card, not a tool */
|
|
445
|
+
cards?: boolean;
|
|
446
|
+
}): string[] {
|
|
447
|
+
const { pad, request, threadExpr, cards = false } = opts;
|
|
448
|
+
// THE ONE LINE THAT CLOSES THE LOOP.
|
|
449
|
+
//
|
|
450
|
+
// A `kai_confirm` call is not a tool to run: `cardTools` handed the model the
|
|
451
|
+
// card's own data schema, so the arguments ARE the envelope's `data` and the
|
|
452
|
+
// mapping is identity. `cardFromToolCall` returns null for every other name, so
|
|
453
|
+
// the app's own tools fall through untouched and this costs nothing when the
|
|
454
|
+
// model never asks for a card.
|
|
455
|
+
//
|
|
456
|
+
// The provider's `tool_call_id` becomes `CardEnvelope.id` verbatim, which is what
|
|
457
|
+
// buys free revision: a model correcting a card re-sends the same call id and
|
|
458
|
+
// `addCard` upserts in place instead of stacking a second copy.
|
|
459
|
+
const cardBranch = cards
|
|
460
|
+
? [
|
|
461
|
+
`${pad} // A kai_* call is a CARD, not a tool to run: cardTools handed the model`,
|
|
462
|
+
`${pad} // the card's own schema, so the arguments ARE the envelope's data.`,
|
|
463
|
+
`${pad} // cardFromToolCall returns null for anything else, which falls through to`,
|
|
464
|
+
`${pad} // your own tools below.`,
|
|
465
|
+
`${pad} const card = cardFromToolCall(call.name, call.input ?? {}, { id: call.id });`,
|
|
466
|
+
`${pad} if (card) {`,
|
|
467
|
+
`${pad} // The id IS the tool call id, so a model that revises the card re-sends`,
|
|
468
|
+
`${pad} // the same id and this replaces it in place instead of drawing a second.`,
|
|
469
|
+
`${pad} stream.addCard(card);`,
|
|
470
|
+
`${pad} // Answer the call now: the card is on screen and the model must not sit`,
|
|
471
|
+
`${pad} // waiting on a result. The user's click comes back through the card's own`,
|
|
472
|
+
`${pad} // kai-card-event, not through this round.`,
|
|
473
|
+
`${pad} applyToolOutput(stream, call.id, { status: 'awaiting_user' });`,
|
|
474
|
+
`${pad} continue;`,
|
|
475
|
+
`${pad} }`,
|
|
476
|
+
]
|
|
477
|
+
: [];
|
|
478
|
+
return [
|
|
479
|
+
`${pad}// Cap the rounds: a runaway model is a runaway bill.`,
|
|
480
|
+
`${pad}const MAX_TOOL_ROUNDS = 4;`,
|
|
481
|
+
`${pad}try {`,
|
|
482
|
+
`${pad} for (let round = 0; round < MAX_TOOL_ROUNDS; round++) {`,
|
|
483
|
+
...request(`${pad} `),
|
|
484
|
+
`${pad} if (turn.error) { console.error('Model error:', turn.error.message); break; }`,
|
|
485
|
+
`${pad} // The kit never RUNS a tool. Calls the provider executed itself already`,
|
|
486
|
+
`${pad} // carry their output, and a malformed one has nothing to run.`,
|
|
487
|
+
`${pad} const pending = turn.toolCalls.filter((c) => !c.error && !c.providerExecuted);`,
|
|
488
|
+
`${pad} if (pending.length === 0) break; // the model answered — the turn is done`,
|
|
489
|
+
`${pad} for (const call of pending) {`,
|
|
490
|
+
...cardBranch,
|
|
491
|
+
`${pad} try {`,
|
|
492
|
+
`${pad} applyToolOutput(stream, call.id, await runTool(call.name, call.input ?? {}));`,
|
|
493
|
+
`${pad} } catch (err) {`,
|
|
494
|
+
`${pad} // The panel must not spin forever because your tool threw; the model`,
|
|
495
|
+
`${pad} // is told about the failure and can react to it next round.`,
|
|
496
|
+
`${pad} applyToolFailure(stream, call.id, err instanceof Error ? err.message : 'Tool failed');`,
|
|
497
|
+
`${pad} }`,
|
|
498
|
+
`${pad} }`,
|
|
499
|
+
`${pad} // Next round re-encodes ${threadExpr}, which now carries this round's calls`,
|
|
500
|
+
`${pad} // AND their results: toOpenAIMessages splits the turn at the tool boundary`,
|
|
501
|
+
`${pad} // into assistant(tool_calls) -> tool(result) -> assistant(answer).`,
|
|
502
|
+
`${pad} }`,
|
|
503
|
+
];
|
|
504
|
+
}
|
|
505
|
+
|
|
506
|
+
/**
|
|
507
|
+
* The tool runner the loop calls, emitted as a working stub.
|
|
508
|
+
*
|
|
509
|
+
* The kit never calls a consumer's function — this is the seam where the host
|
|
510
|
+
* does. It answers the `search` tool the scaffold declares in `tools` so the
|
|
511
|
+
* loop completes a round on the first run against a real model, and says so
|
|
512
|
+
* loudly enough that nobody ships the canned answer.
|
|
513
|
+
*
|
|
514
|
+
* No template literals: the emitted code is itself inside one.
|
|
515
|
+
*/
|
|
516
|
+
function toolRunnerLines(pad: string, typed: boolean): string[] {
|
|
517
|
+
const sig = typed
|
|
518
|
+
? `async function runTool(name: string, input: Record<string, unknown>): Promise<Record<string, unknown>> {`
|
|
519
|
+
: `async function runTool(name, input) {`;
|
|
520
|
+
return [
|
|
521
|
+
`${pad}// YOUR tools run here. The kit never calls one: the model asks, you execute,`,
|
|
522
|
+
`${pad}// and applyToolOutput reports the result back into the panel and into the`,
|
|
523
|
+
`${pad}// next round's request. Whatever you return is JSON-encoded as the result.`,
|
|
524
|
+
`${pad}${sig}`,
|
|
525
|
+
`${pad} if (name === 'search') {`,
|
|
526
|
+
`${pad} // STUB — replace with a real search call. Bracket access, not \`input.query\`:`,
|
|
527
|
+
`${pad} // \`input\` is an index signature, and Angular's stock tsconfig turns on`,
|
|
528
|
+
`${pad} // noPropertyAccessFromIndexSignature, which rejects the dotted form (TS4111).`,
|
|
529
|
+
`${pad} return { results: ['No search backend wired up yet. Query: ' + String(input['query'] ?? '')] };`,
|
|
530
|
+
`${pad} }`,
|
|
531
|
+
`${pad} return { error: 'Unknown tool: ' + name };`,
|
|
532
|
+
`${pad}}`,
|
|
533
|
+
];
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/** True when the surface renders a tool panel, so the scaffold needs the loop. */
|
|
537
|
+
function hasToolPanel(components: readonly string[]): boolean {
|
|
538
|
+
return components.includes('kai-tool');
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
/**
|
|
542
|
+
* True when the scaffold emits the generative-UI card round trip: a
|
|
543
|
+
* `createCardRegistry` declaration, `cardTools()` in the tools array, and
|
|
544
|
+
* `cardFromToolCall()` in the tool loop.
|
|
545
|
+
*
|
|
546
|
+
* CARDS FOLD INTO `kai-tool`. THERE IS NO SEPARATE CARD COMPONENT, AND THAT IS A
|
|
547
|
+
* CHOICE. A card is a tool call the model makes and the app draws instead of
|
|
548
|
+
* executing, so "the model reaches for a tool" is the same capability `kai-tool`
|
|
549
|
+
* already stands for, and a distinct card capability would add another surface's
|
|
550
|
+
* worth of front-end cells to `verify:scaffold` to say it twice.
|
|
551
|
+
*
|
|
552
|
+
* Written as its own predicate rather than being spelled `hasToolPanel` at each
|
|
553
|
+
* call site even though it returns exactly that today. The fold is a decision with
|
|
554
|
+
* ONE place to change: point this at a different component and the registry, the
|
|
555
|
+
* tools and the loop line move together. Inlining `hasToolPanel` would scatter the
|
|
556
|
+
* decision over eight renderers and make moving it a search-and-replace.
|
|
557
|
+
*/
|
|
558
|
+
function bearsCards(components: readonly string[]): boolean {
|
|
559
|
+
return hasToolPanel(components);
|
|
560
|
+
}
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* Which provider envelope `cardTools()` should project into, for the wire this
|
|
564
|
+
* integration's route really speaks.
|
|
565
|
+
*
|
|
566
|
+
* `cardTools` takes `provider` as a REQUIRED argument because the three shapes are
|
|
567
|
+
* genuinely different documents: OpenAI nests the schema under
|
|
568
|
+
* `function.parameters`, Anthropic puts it at `input_schema`, and `jsonschema`
|
|
569
|
+
* returns a bare `{name, description, schema}` that `ai@7`'s `jsonSchema()` takes
|
|
570
|
+
* directly. Emitting the wrong one is not a style problem — the provider 400s, or
|
|
571
|
+
* (worse) ignores the tool and the card silently never arrives.
|
|
572
|
+
*
|
|
573
|
+
* READ OFF THE INTEGRATION, not inferred. This used to be a table keyed on
|
|
574
|
+
* `streamFormat`, and that table was wrong in a way only `anthropic` made
|
|
575
|
+
* visible: `streamFormat` describes the RESPONSE stream, the tool envelope is a
|
|
576
|
+
* REQUEST concern, and the two only looked correlated while every integration
|
|
577
|
+
* that forwarded tools happened to be `openai-sse`. `anthropic` is
|
|
578
|
+
* `streamFormat: 'native'`, the table mapped `native` to `null`, and a null here
|
|
579
|
+
* means a scaffold that hands the model a tools array with no card in it.
|
|
580
|
+
*
|
|
581
|
+
* Nor is the fix "key on the provider instead". Anthropic's emitted route
|
|
582
|
+
* CONVERTS the array itself — `toAnthropicTools` reads `raw.function.name` and
|
|
583
|
+
* `raw.function.parameters` — so it wants the OPENAI envelope even though it
|
|
584
|
+
* POSTs to api.anthropic.com. Only the route knows its own request contract, so
|
|
585
|
+
* the integration declares it (`clientToolFormat`), exactly as it already
|
|
586
|
+
* declares `forwardsFromClient`.
|
|
587
|
+
*
|
|
588
|
+
* `null` here means the integration does not forward a tools array at all, which
|
|
589
|
+
* is the honest state for langgraph/mastra/pi/vercel-ai-sdk (their routes own the
|
|
590
|
+
* tool list server-side). It can no longer mean "forwards tools, envelope
|
|
591
|
+
* unknown": `IntegrationSchema` rejects that combination at the catalog boundary,
|
|
592
|
+
* `assertCardToolFormat` below refuses to emit it, and `scaffold.test.ts` +
|
|
593
|
+
* `cardRoundTripCheck` both assert it against the real registry.
|
|
594
|
+
*/
|
|
595
|
+
function cardToolProviderFor(integration: Integration): 'openai' | 'anthropic' | 'jsonschema' | null {
|
|
596
|
+
return integration.clientToolFormat ?? null;
|
|
597
|
+
}
|
|
598
|
+
|
|
599
|
+
/**
|
|
600
|
+
* The emit-time hard stop.
|
|
601
|
+
*
|
|
602
|
+
* The schema refinement catches a malformed catalog entry, but `Integration`
|
|
603
|
+
* values also arrive from tests and from callers that build one by hand, so the
|
|
604
|
+
* emit path refuses rather than trusting that. Throwing here is the point: the
|
|
605
|
+
* failure this guards is SILENT by nature — a tools array with no card tool in it
|
|
606
|
+
* produces a model that is never told a card exists, so it never emits one, and
|
|
607
|
+
* nothing anywhere says why. A thrown error naming the integration is strictly
|
|
608
|
+
* better than a scaffold that looks fine and renders nothing.
|
|
609
|
+
*/
|
|
610
|
+
function assertCardToolFormat(integration: Integration): 'openai' | 'anthropic' | 'jsonschema' {
|
|
611
|
+
const provider = cardToolProviderFor(integration);
|
|
612
|
+
if (provider === null) {
|
|
613
|
+
throw new Error(
|
|
614
|
+
`Integration '${integration.id}' forwards a 'tools' array but declares no clientToolFormat, so the ` +
|
|
615
|
+
`scaffold would offer the model no card tool at all. Set clientToolFormat on the integration to the ` +
|
|
616
|
+
`envelope its route expects: 'openai' | 'anthropic' | 'jsonschema'.`,
|
|
617
|
+
);
|
|
618
|
+
}
|
|
619
|
+
return provider;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* What the scaffolder INTENDS to emit for one (components, integration) pair, so a
|
|
624
|
+
* guard can check the eight renderers against the decision instead of restating it.
|
|
625
|
+
*
|
|
626
|
+
* Exported for `cardRoundTripCheck` in scripts/verify-scaffold-compiles.mjs and
|
|
627
|
+
* for scaffold.test.ts. It reads the same three predicates `renderSurface` reads
|
|
628
|
+
* and nothing else, which is the point: the failure it exists to catch is one of
|
|
629
|
+
* the eight framework renderers not following the decision, and the check would be
|
|
630
|
+
* worthless if the guard hard-coded "the agentic archetype, 8 integrations".
|
|
631
|
+
*
|
|
632
|
+
* KEYED ON A COMPONENTS LIST, not an archetype id, for the same reason
|
|
633
|
+
* `renderSurface` is. A guard that took an archetype id could only ever check the
|
|
634
|
+
* six presets, so the surfaces the feature multi-select makes reachable — the ones
|
|
635
|
+
* with no preset — would emit cards with nothing asserting they did. The archetype
|
|
636
|
+
* axis was also the narrower one: `getArchetype` returns `undefined` for anything
|
|
637
|
+
* not in the catalog, so the guard could not have been pointed at a new surface
|
|
638
|
+
* without first shipping a catalog entry for it.
|
|
639
|
+
*
|
|
640
|
+
* It cannot catch the emitter and the plan being wrong TOGETHER, so it is not the
|
|
641
|
+
* only check: `cardRoundTripCheck` also asserts, without consulting this function,
|
|
642
|
+
* that a scaffold declaring a `tools` array always calls `cardTools` — a tools
|
|
643
|
+
* array offered to a model with no card in it is the silent hole.
|
|
644
|
+
*/
|
|
645
|
+
export function cardEmitPlan(
|
|
646
|
+
components: readonly string[],
|
|
647
|
+
integrationId: string,
|
|
648
|
+
): { cards: boolean; tools: boolean; provider: 'openai' | 'anthropic' | 'jsonschema' | null } | null {
|
|
649
|
+
const integration = getIntegration(integrationId);
|
|
650
|
+
if (!integration || components.length === 0) return null;
|
|
651
|
+
const isMock = integration.id === 'mock';
|
|
652
|
+
const cards = !isMock && bearsCards(components);
|
|
653
|
+
const tools = !isMock && emitsToolSchemas(components, integration);
|
|
654
|
+
return { cards, tools, provider: cards && tools ? cardToolProviderFor(integration) : null };
|
|
655
|
+
}
|
|
656
|
+
|
|
657
|
+
/**
|
|
658
|
+
* The import lines a real-backend scaffold needs on top of the ones it already
|
|
659
|
+
* emits.
|
|
660
|
+
*
|
|
661
|
+
* Every name here MUST be referenced by live emitted code: every starter in this
|
|
662
|
+
* repo (and create-vite's own TypeScript template) sets `noUnusedLocals`, so one
|
|
663
|
+
* unreferenced name fails `npm run build` with TS6133 in a stock app.
|
|
664
|
+
* `applyToolOutput`/`applyToolFailure` used to be excluded for exactly that
|
|
665
|
+
* reason — the tool loop that called them was commented out. The loop is live
|
|
666
|
+
* now, so they are imported, and only when it is emitted.
|
|
667
|
+
*
|
|
668
|
+
* `type SetMessages` is React-only for the same reason: it annotates the `set`
|
|
669
|
+
* adapter in `REACT_THREAD`, which no other framework needs.
|
|
670
|
+
*
|
|
671
|
+
* `typed` pulls in the kit's own `ChatMessage` for the strict-TS frameworks; the
|
|
672
|
+
* plain-JS html target must not emit a type import, and `solid` takes the same
|
|
673
|
+
* type from `@kitn.ai/ui` alongside its components.
|
|
674
|
+
*
|
|
675
|
+
* The `mock` integration imports from here too. It used to import nothing at all
|
|
676
|
+
* and hand-declare a narrow local `ChatMessage` instead — a subset with no
|
|
677
|
+
* `raw`/`rawInput` and no `source`/`file` part variants, which a message the kit
|
|
678
|
+
* itself produced did not satisfy. Now that the mock streams through
|
|
679
|
+
* `readOpenAIStream` like everything else, it takes the real type.
|
|
680
|
+
*/
|
|
681
|
+
function wireImportLines(opts: {
|
|
682
|
+
pad?: string;
|
|
683
|
+
typed: boolean;
|
|
684
|
+
/** the live tool loop is emitted → it calls applyToolOutput/applyToolFailure */
|
|
685
|
+
toolLoop?: boolean;
|
|
686
|
+
/** the framework's thread binding declares `const set: SetMessages` */
|
|
687
|
+
setMessagesType?: boolean;
|
|
688
|
+
/** the card registry is emitted → createCardRegistry + cardFromToolCall */
|
|
689
|
+
cards?: boolean;
|
|
690
|
+
/** the tools array calls cardTools() as well */
|
|
691
|
+
cardTools?: boolean;
|
|
692
|
+
/** the `mock` integration → import the shared responder, not a fetch encoder */
|
|
693
|
+
mock?: boolean;
|
|
694
|
+
}): string[] {
|
|
695
|
+
const { pad = '', typed, toolLoop = false, setMessagesType = false, cards = false, cardTools: emitsCardTools = false, mock = false } = opts;
|
|
696
|
+
const stateNames = [
|
|
697
|
+
'createAssistantStream',
|
|
698
|
+
// The mock's canned reply comes from the kit, not from a copy pasted into
|
|
699
|
+
// this file. One implementation, shared with create-kai and the starters.
|
|
700
|
+
...(mock ? ['createMockResponder'] : []),
|
|
701
|
+
...(typed ? ['type ChatMessage'] : []),
|
|
702
|
+
...(typed && setMessagesType ? ['type SetMessages'] : []),
|
|
703
|
+
].join(', ');
|
|
704
|
+
// `noUnusedLocals` is enforced over the emitted scaffolds (verify:scaffold), so
|
|
705
|
+
// the mock must NOT name toOpenAIMessages: it has no request body to encode.
|
|
706
|
+
const wireNames = [
|
|
707
|
+
'readOpenAIStream',
|
|
708
|
+
...(mock ? [] : ['toOpenAIMessages']),
|
|
709
|
+
...(toolLoop ? ['applyToolOutput', 'applyToolFailure'] : []),
|
|
710
|
+
].join(', ');
|
|
711
|
+
// Same noUnusedLocals rule as above, one entry finer: `cardTools` is named only
|
|
712
|
+
// when the integration's route actually forwards a tools array, because on the
|
|
713
|
+
// ones that build their tools server-side the registry is still wired to the
|
|
714
|
+
// client (cardTypes/cardSchemas) while nothing here calls cardTools.
|
|
715
|
+
const schemaNames = [
|
|
716
|
+
'createCardRegistry',
|
|
717
|
+
...(emitsCardTools ? ['cardTools'] : []),
|
|
718
|
+
...(toolLoop ? ['cardFromToolCall'] : []),
|
|
719
|
+
].join(', ');
|
|
720
|
+
return [
|
|
721
|
+
`${pad}import { ${stateNames} } from '@kitn.ai/ui/state';`,
|
|
722
|
+
`${pad}import { ${wireNames} } from '@kitn.ai/ui/wire';`,
|
|
723
|
+
// Server-safe: no DOM, no Solid runtime. The same import works in the route
|
|
724
|
+
// when the registry moves to its own cards.ts.
|
|
725
|
+
...(cards ? [`${pad}import { ${schemaNames} } from '@kitn.ai/ui/schemas';`] : []),
|
|
726
|
+
];
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* The mock responder, declared at MODULE scope.
|
|
731
|
+
*
|
|
732
|
+
* Module scope rather than inside the submit handler because the responder owns
|
|
733
|
+
* the cursor into its canned replies: rebuilt per turn it would answer with the
|
|
734
|
+
* first reply forever, and the seeded conversation would stop making sense on
|
|
735
|
+
* the second message.
|
|
736
|
+
*/
|
|
737
|
+
function mockResponderInit(pad = ''): string[] {
|
|
738
|
+
return [
|
|
739
|
+
`${pad}// The kit's own mock responder — no backend, no key, no network. It streams`,
|
|
740
|
+
`${pad}// canned SSE frames through the same reader a real provider's response uses,`,
|
|
741
|
+
`${pad}// and marks every one of them as a mock. Shared with create-kai and the`,
|
|
742
|
+
`${pad}// starters, so there is one implementation of this and not seven.`,
|
|
743
|
+
`${pad}const mockResponse = createMockResponder();`,
|
|
744
|
+
];
|
|
745
|
+
}
|
|
746
|
+
|
|
747
|
+
/** The POST body for a real backend. `toOpenAIMessages` keeps tool calls and
|
|
748
|
+
* tool results on the way back, which is what makes a second round possible;
|
|
749
|
+
* `tools` is what makes a FIRST tool call possible at all. Each field appears
|
|
750
|
+
* only when the emitted code declares the const it names.
|
|
751
|
+
*
|
|
752
|
+
* `thread` is the expression holding the messages to encode — a const built
|
|
753
|
+
* once for a single-round scaffold, the LIVE thread for a tool loop that
|
|
754
|
+
* re-encodes it every round. */
|
|
755
|
+
function realBodyPayload(opts: { defaultModel?: string; tools: boolean }): (thread: string) => string {
|
|
756
|
+
return (thread: string) => {
|
|
757
|
+
const fields = [
|
|
758
|
+
...(opts.defaultModel ? ['model'] : []),
|
|
759
|
+
`messages: toOpenAIMessages(${thread})`,
|
|
760
|
+
...(opts.tools ? ['tools'] : []),
|
|
761
|
+
];
|
|
762
|
+
return `{ ${fields.join(', ')} }`;
|
|
763
|
+
};
|
|
764
|
+
}
|
|
765
|
+
|
|
169
766
|
// ── SCAF-8: per-integration default model ids ─────────────────────────────────
|
|
170
767
|
|
|
171
768
|
/**
|
|
172
|
-
*
|
|
173
|
-
*
|
|
174
|
-
*
|
|
769
|
+
* Default model id per integration whose route forwards one.
|
|
770
|
+
*
|
|
771
|
+
* THE ID IS HOST-SPECIFIC, and there is no such thing as a safe generic one.
|
|
772
|
+
* This used to fall through to `'openai/gpt-4o-mini'` for anything unlisted, on
|
|
773
|
+
* the reasoning that "a route that forwards the client's model is by definition
|
|
774
|
+
* pointed at an OpenAI-compatible endpoint". That was false twice over the
|
|
775
|
+
* moment a first-party provider landed: `openai/gpt-4o-mini` is an OPENROUTER
|
|
776
|
+
* slug — api.openai.com 404s the prefixed form, and api.anthropic.com rejects it
|
|
777
|
+
* outright — so a scaffold generated for the provider it names could not run
|
|
778
|
+
* against it.
|
|
779
|
+
*
|
|
780
|
+
* tsc cannot see any of this; every one of those strings compiles. The guard is
|
|
781
|
+
* `scaffold.test.ts` → "the emitted model id is valid for the host its route
|
|
782
|
+
* POSTs to", which reads the id out of the EMITTED scaffold and the host out of
|
|
783
|
+
* the route source, so a new integration cannot reintroduce a wrong one.
|
|
784
|
+
*/
|
|
785
|
+
const CLIENT_MODEL_IDS: Record<string, string> = {
|
|
786
|
+
// Vendor-prefixed `vendor/model`: OpenRouter's own id space, and the ONLY one
|
|
787
|
+
// of the three where the prefix belongs.
|
|
788
|
+
openrouter: 'openai/gpt-4o-mini',
|
|
789
|
+
// No vendor prefix. This is what the route already pinned, so moving the knob
|
|
790
|
+
// to the client changes the wire not at all.
|
|
791
|
+
openai: 'gpt-4o-mini',
|
|
792
|
+
// Anthropic's id space. Matches what the route pinned; 'claude-sonnet-5' and
|
|
793
|
+
// 'claude-haiku-4-5' are the cheaper swaps (see this integration's runNote).
|
|
794
|
+
anthropic: 'claude-opus-5',
|
|
795
|
+
};
|
|
796
|
+
|
|
797
|
+
/**
|
|
798
|
+
* The default model id for an integration whose ROUTE reads the client's `model`
|
|
799
|
+
* field, and undefined for every other one.
|
|
800
|
+
*
|
|
801
|
+
* This used to be a substring test (`routeSrc.includes('model')`), which is true
|
|
802
|
+
* of any template that so much as writes `model: 'llama3.2'`. That emitted an
|
|
803
|
+
* editable `const model` into ollama, langgraph, vercel-ai-sdk and cloudflare
|
|
804
|
+
* scaffolds whose routes pin their own model and never read the field, so
|
|
805
|
+
* changing it did nothing, and cloudflare's default was not even a valid Workers
|
|
806
|
+
* AI id. `forwardsFromClient` states the fact instead of guessing at it.
|
|
175
807
|
*/
|
|
176
808
|
function defaultModelFor(integration: Integration): string | undefined {
|
|
177
|
-
|
|
178
|
-
const
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
809
|
+
if (!integration.forwardsFromClient.includes('model')) return undefined;
|
|
810
|
+
const id = CLIENT_MODEL_IDS[integration.id];
|
|
811
|
+
// No fallback, deliberately — the old `?? 'openai/gpt-4o-mini'` is what let a
|
|
812
|
+
// first-party provider inherit an OpenRouter slug and emit a scaffold that
|
|
813
|
+
// 404s on its own host. A model id is a per-host fact; an integration that
|
|
814
|
+
// forwards one has to say which.
|
|
815
|
+
if (id === undefined) {
|
|
816
|
+
throw new Error(
|
|
817
|
+
`Integration '${integration.id}' forwards the client's 'model' but has no CLIENT_MODEL_IDS entry, so the ` +
|
|
818
|
+
`scaffold would emit a model id that is not valid for the host its route POSTs to. Add one in ` +
|
|
819
|
+
`mcp/tools/scaffold.ts.`,
|
|
820
|
+
);
|
|
821
|
+
}
|
|
822
|
+
return id;
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
/**
|
|
826
|
+
* True when the scaffold should declare tool schemas and put them in the body.
|
|
827
|
+
*
|
|
828
|
+
* Both halves matter. A tool panel with no tools array in the request is a panel
|
|
829
|
+
* no code path can populate: the model never emits a tool call, so kai-tool
|
|
830
|
+
* renders nothing forever. And a tools array the route drops on the floor is the
|
|
831
|
+
* same dead-const defect as the model one: langgraph builds its tools into the
|
|
832
|
+
* agent, Mastra and Pi into the harness, and none of them read the field.
|
|
833
|
+
*
|
|
834
|
+
* The archetype half is an OR, not just `hasToolPanel`, because there are now two
|
|
835
|
+
* reasons a scaffold needs a tools array: a tool panel with nothing to call, and a
|
|
836
|
+
* card the model can never be asked for. Both operands are true for `agentic`
|
|
837
|
+
* today (see `bearsCards`), so the `||` changes no output — it is here so that
|
|
838
|
+
* moving cards to an archetype without `kai-tool` keeps emitting their tools
|
|
839
|
+
* instead of silently dropping them.
|
|
840
|
+
*/
|
|
841
|
+
function emitsToolSchemas(components: readonly string[], integration: Integration): boolean {
|
|
842
|
+
const needsToolsArray = hasToolPanel(components) || bearsCards(components);
|
|
843
|
+
return needsToolsArray && integration.forwardsFromClient.includes('tools');
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
/**
|
|
847
|
+
* The tool schemas, emitted beside the model const. OpenAI function-calling
|
|
848
|
+
* form, which is what every passthrough route forwards.
|
|
849
|
+
*
|
|
850
|
+
* `search` on purpose: it is the tool `SAMPLE_AGENTIC_MESSAGE` already shows in
|
|
851
|
+
* the seeded thread, so the sample panel and the live one describe one tool.
|
|
852
|
+
*
|
|
853
|
+
* The CARD tools are appended by calling `cardTools(cards, { provider })`, never
|
|
854
|
+
* by writing a card's shape out here. That is the whole point of the emit
|
|
855
|
+
* contract: a `confirm` card's schema already exists, the kit already validates
|
|
856
|
+
* arriving cards against it, and a scaffolder that restated it would be the sixth
|
|
857
|
+
* copy of one shape — the copy that drifts, in the file a developer is least
|
|
858
|
+
* likely to re-read.
|
|
859
|
+
*/
|
|
860
|
+
function toolSchemaLines(
|
|
861
|
+
pad: string,
|
|
862
|
+
cardProvider: 'openai' | 'anthropic' | 'jsonschema' | null = null,
|
|
863
|
+
): string[] {
|
|
864
|
+
// The `provider` note differs per shape, because the three shapes are genuinely
|
|
865
|
+
// different documents and a developer swapping backends needs to know which line
|
|
866
|
+
// to change.
|
|
867
|
+
const PROVIDER_NOTE: Record<'openai' | 'anthropic' | 'jsonschema', string[]> = {
|
|
868
|
+
openai: [
|
|
869
|
+
`// \`provider\` is required, not cosmetic: this route POSTs to an OpenAI-compatible`,
|
|
870
|
+
`// endpoint, which wants { type: 'function', function: { parameters } }. Anthropic`,
|
|
871
|
+
`// wants the schema at \`input_schema\` instead — one word here, not a rewrite.`,
|
|
872
|
+
],
|
|
873
|
+
anthropic: [
|
|
874
|
+
`// \`provider\` is required, not cosmetic: Anthropic's /v1/messages wants the schema`,
|
|
875
|
+
`// at \`input_schema\`, where an OpenAI-compatible endpoint wants it nested under`,
|
|
876
|
+
`// \`function.parameters\`. One word here, not a rewrite.`,
|
|
877
|
+
],
|
|
878
|
+
jsonschema: [
|
|
879
|
+
`// 'jsonschema' returns a bare { name, description, schema }, which is the form the`,
|
|
880
|
+
`// AI SDK takes directly: tool({ description, inputSchema: jsonSchema(def.schema) }).`,
|
|
881
|
+
`// No provider envelope, so our schema is not an awkward second source of truth.`,
|
|
882
|
+
],
|
|
186
883
|
};
|
|
187
|
-
|
|
884
|
+
const cardLines =
|
|
885
|
+
cardProvider === null
|
|
886
|
+
? []
|
|
887
|
+
: [
|
|
888
|
+
`${pad} // Every card type \`cards\` declares, as a tool definition GENERATED from the`,
|
|
889
|
+
`${pad} // card's own JSON Schema — the same schema the kit validates arriving cards`,
|
|
890
|
+
`${pad} // against, so the tool the model sees and the card that renders cannot drift.`,
|
|
891
|
+
...PROVIDER_NOTE[cardProvider].map((l) => `${pad} ${l}`),
|
|
892
|
+
`${pad} ...cardTools(cards, { provider: '${cardProvider}' }),`,
|
|
893
|
+
];
|
|
894
|
+
return [
|
|
895
|
+
`${pad}// The tools the model may call. The request body carries this array; without`,
|
|
896
|
+
`${pad}// it the model never emits a tool call and the kai-tool panel stays empty,`,
|
|
897
|
+
`${pad}// which is the whole reason it is here. Replace with your own. The kit never`,
|
|
898
|
+
`${pad}// RUNS a tool: see the loop in onSubmit for who does.`,
|
|
899
|
+
`${pad}const tools = [`,
|
|
900
|
+
`${pad} {`,
|
|
901
|
+
`${pad} type: 'function',`,
|
|
902
|
+
`${pad} function: {`,
|
|
903
|
+
`${pad} name: 'search',`,
|
|
904
|
+
`${pad} description: 'Search the web for up-to-date information.',`,
|
|
905
|
+
`${pad} parameters: {`,
|
|
906
|
+
`${pad} type: 'object',`,
|
|
907
|
+
`${pad} properties: { query: { type: 'string', description: 'What to search for.' } },`,
|
|
908
|
+
`${pad} required: ['query'],`,
|
|
909
|
+
`${pad} },`,
|
|
910
|
+
`${pad} },`,
|
|
911
|
+
`${pad} },`,
|
|
912
|
+
...cardLines,
|
|
913
|
+
`${pad}];`,
|
|
914
|
+
];
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
/**
|
|
918
|
+
* The card registry: the ONE place this app writes down which cards it renders.
|
|
919
|
+
*
|
|
920
|
+
* Emitted at MODULE scope in every framework, deliberately. Three separate things
|
|
921
|
+
* read it — the tools array, the tool loop's `cardFromToolCall` fall-through, and
|
|
922
|
+
* the client's `cardTypes`/`cardSchemas` wiring — and in the Solid target one of
|
|
923
|
+
* them (`renderPart`) is a top-level function that cannot see a component-local
|
|
924
|
+
* const. In a real app this block is `cards.ts`, imported by the client AND by the
|
|
925
|
+
* route; the scaffold's tool loop runs in the browser, so one module holds both
|
|
926
|
+
* ends here and the comment says where it goes when they separate.
|
|
927
|
+
*
|
|
928
|
+
* `use` is a real narrowing, not decoration: it is what the model is OFFERED, so
|
|
929
|
+
* two entries is two tool definitions per request instead of seven. It does not
|
|
930
|
+
* narrow what RENDERS — `mergeCardTags` unions all seven built-ins in regardless,
|
|
931
|
+
* so a `tasks` envelope arriving from somewhere else still draws.
|
|
932
|
+
*/
|
|
933
|
+
function cardRegistryLines(pad = ''): string[] {
|
|
934
|
+
return [
|
|
935
|
+
`${pad}// The card types this app renders, declared ONCE for both ends of the round`,
|
|
936
|
+
`${pad}// trip: \`cardTools(cards, …)\` turns them into the tool definitions the model`,
|
|
937
|
+
`${pad}// is offered, and cardTypes/cardSchemas below tell <kai-chat> what draws an`,
|
|
938
|
+
`${pad}// arriving card and what a valid one looks like. Move this to its own cards.ts`,
|
|
939
|
+
`${pad}// the moment your BACKEND needs it too — it imports nothing from the DOM.`,
|
|
940
|
+
`${pad}const cards = createCardRegistry({`,
|
|
941
|
+
`${pad} // The built-ins the model is OFFERED. Omit \`use\` for all seven; every entry`,
|
|
942
|
+
`${pad} // is one more tool definition in every request. This does NOT narrow what`,
|
|
943
|
+
`${pad} // RENDERS: all seven built-in cards still draw if one turns up.`,
|
|
944
|
+
`${pad} use: ['confirm', 'choice'],`,
|
|
945
|
+
`${pad} // YOUR card types go here, and this is the half that makes the generative UI`,
|
|
946
|
+
`${pad} // yours. \`schema\` is both what the model is told and what arriving data is`,
|
|
947
|
+
`${pad} // checked against; \`tag\` is your own custom element.`,
|
|
948
|
+
`${pad} // custom: {`,
|
|
949
|
+
`${pad} // 'pricing-table': {`,
|
|
950
|
+
`${pad} // schema: pricingSchema, // import pricingSchema from './pricing.schema.json'`,
|
|
951
|
+
`${pad} // tag: 'my-pricing-table',`,
|
|
952
|
+
`${pad} // description: 'Show a plan comparison the user can pick from.',`,
|
|
953
|
+
`${pad} // },`,
|
|
954
|
+
`${pad} // },`,
|
|
955
|
+
`${pad}});`,
|
|
956
|
+
];
|
|
957
|
+
}
|
|
958
|
+
|
|
959
|
+
/**
|
|
960
|
+
* The two card properties, as the JS PROPERTIES they have to be.
|
|
961
|
+
*
|
|
962
|
+
* `cards.tags` and `cards.validationSchemas` carry the CUSTOM types only, because
|
|
963
|
+
* the kit merges its own seven in itself — so with no `custom` block above they are
|
|
964
|
+
* both `{}` and these two lines currently do nothing. They are emitted anyway, and
|
|
965
|
+
* that is the point of the emit contract rather than an oversight: filling in
|
|
966
|
+
* `custom` is then a one-line change instead of an archaeology exercise through the
|
|
967
|
+
* docs, and the two names are the ones a developer would otherwise have to
|
|
968
|
+
* discover. `verify:scaffold` compiles the assignment in all eight frameworks, so
|
|
969
|
+
* the shapes are proven compatible rather than asserted to be.
|
|
970
|
+
*
|
|
971
|
+
* Never an attribute. `cardTypes`/`cardSchemas` are objects, and an object set as
|
|
972
|
+
* an HTML attribute stringifies to "[object Object]" and silently registers
|
|
973
|
+
* nothing.
|
|
974
|
+
*/
|
|
975
|
+
const CARD_PROP_COMMENT = [
|
|
976
|
+
`// Cards, as JS PROPERTIES (objects can never be HTML attributes). \`tags\` says`,
|
|
977
|
+
`// what DRAWS your own card type; \`cardSchemas\` says what a VALID one looks like,`,
|
|
978
|
+
`// so a model that gets the shape wrong shows a named diagnostic instead of empty`,
|
|
979
|
+
`// chrome. Both are {} until the \`custom\` block above is filled in; the built-in`,
|
|
980
|
+
`// seven draw and validate without them.`,
|
|
981
|
+
];
|
|
982
|
+
|
|
983
|
+
// `//` comment lines as a JSX comment block, for the three JSX targets.
|
|
984
|
+
//
|
|
985
|
+
// A JSX comment (a braces pair wrapping a block comment) is legal only at ELEMENT
|
|
986
|
+
// position; between attributes it is a parse error. So these go above the tag
|
|
987
|
+
// rather than among its props. Written as line comments here because the thing
|
|
988
|
+
// being described cannot be spelled inside a block comment.
|
|
989
|
+
function jsxComment(lines: readonly string[], pad: string): string[] {
|
|
990
|
+
return lines.map((l) => `${pad}{/* ${l.replace(/^\/\/ ?/, '')} */}`);
|
|
188
991
|
}
|
|
189
992
|
|
|
190
993
|
// ── SCAF-9: message-embedded companion logic ──────────────────────────────────
|
|
@@ -206,41 +1009,123 @@ const MESSAGE_EMBEDDED_TAGS = new Set(['kai-tool', 'kai-reasoning']);
|
|
|
206
1009
|
*/
|
|
207
1010
|
const WORKSPACE_STRUCTURAL_TAGS = new Set(['kai-resizable', 'kai-artifact']);
|
|
208
1011
|
|
|
209
|
-
/** True when the
|
|
210
|
-
function isWorkspace(
|
|
211
|
-
return
|
|
1012
|
+
/** True when the surface is the resizable split workspace (chat + artifact). */
|
|
1013
|
+
function isWorkspace(components: readonly string[]): boolean {
|
|
1014
|
+
return components.includes('kai-resizable') && components.includes('kai-artifact');
|
|
212
1015
|
}
|
|
213
1016
|
|
|
214
1017
|
/**
|
|
215
|
-
* A sample assistant message
|
|
216
|
-
*
|
|
1018
|
+
* A sample assistant message showing embedded tool + reasoning.
|
|
1019
|
+
*
|
|
1020
|
+
* IT IS NOT SEEDED INTO A REAL SCAFFOLD'S THREAD, and used not to be optional.
|
|
1021
|
+
* A fabricated assistant turn in the initial state is conversation history the
|
|
1022
|
+
* user never had, and it does three things:
|
|
1023
|
+
*
|
|
1024
|
+
* 1. it is SENT TO THE MODEL on turn one, as `assistant(tool_calls tc_001)` +
|
|
1025
|
+
* `tool(result)`, so the very first request claims the model made a call it
|
|
1026
|
+
* never made and that a tool it may not have answered;
|
|
1027
|
+
* 2. it THROWS in `toAnthropicMessages` — a reasoning part with no verbatim
|
|
1028
|
+
* `raw` cannot be echoed back as a thinking block, which is a
|
|
1029
|
+
* `WireEncodeError` before the request is even built;
|
|
1030
|
+
* 3. it kills the thread's empty state, so the `suggestions` the caller passed
|
|
1031
|
+
* to `scaffold` never render at all.
|
|
1032
|
+
*
|
|
1033
|
+
* The live tool loop fills the panel for real on the first submit, so nothing is
|
|
1034
|
+
* lost by starting empty. The `mock` preview has no provider to lie to and no
|
|
1035
|
+
* encoder to throw, so it gets this as a COMMENTED fixture (see
|
|
1036
|
+
* `sampleSeedComment`): the one place uncommenting it is safe.
|
|
217
1037
|
*/
|
|
218
1038
|
const SAMPLE_AGENTIC_MESSAGE = {
|
|
219
1039
|
id: 'sample-assistant',
|
|
220
1040
|
role: 'assistant' as const,
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
tools: [
|
|
1041
|
+
parts: [
|
|
1042
|
+
{ type: 'reasoning' as const, text: 'I should call the search tool to get up-to-date data.' },
|
|
224
1043
|
{
|
|
225
|
-
type: '
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
1044
|
+
type: 'tool' as const,
|
|
1045
|
+
tool: {
|
|
1046
|
+
type: 'search',
|
|
1047
|
+
state: 'output-available' as const,
|
|
1048
|
+
input: { query: 'current pricing' },
|
|
1049
|
+
output: { results: ['Result A', 'Result B'] },
|
|
1050
|
+
toolCallId: 'tc_001',
|
|
1051
|
+
},
|
|
230
1052
|
},
|
|
1053
|
+
{ type: 'text' as const, text: 'Searched the web for current pricing.' },
|
|
231
1054
|
],
|
|
232
1055
|
};
|
|
233
1056
|
|
|
1057
|
+
/**
|
|
1058
|
+
* What the agentic archetype emits where the seed used to be.
|
|
1059
|
+
*
|
|
1060
|
+
* `mock` gets the fixture commented out beside an empty thread; a real backend
|
|
1061
|
+
* gets the explanation only, because there the fixture is unsafe at any level of
|
|
1062
|
+
* commenting-out (see `SAMPLE_AGENTIC_MESSAGE`).
|
|
1063
|
+
*
|
|
1064
|
+
* `decl` is the framework's own line(s) for the fixture, so what an editor's
|
|
1065
|
+
* uncomment produces is complete: the declaration AND whatever hands it to the
|
|
1066
|
+
* thread. A commented block that leaves an unused `sampleMessages` behind fails
|
|
1067
|
+
* `noUnusedLocals` the moment someone takes it up on the offer.
|
|
1068
|
+
*/
|
|
1069
|
+
function sampleSeedComment(
|
|
1070
|
+
isMock: boolean,
|
|
1071
|
+
pad: string,
|
|
1072
|
+
decl: (literal: string) => string[],
|
|
1073
|
+
): string[] {
|
|
1074
|
+
const shared = [
|
|
1075
|
+
`${pad}// Tool calls and reasoning render INSIDE the thread, as parts on the`,
|
|
1076
|
+
`${pad}// assistant message — the stream in onSubmit builds them as the model works.`,
|
|
1077
|
+
`${pad}// The thread starts EMPTY so the suggestions show and turn one carries no`,
|
|
1078
|
+
`${pad}// conversation the user never had.`,
|
|
1079
|
+
];
|
|
1080
|
+
if (!isMock) return shared;
|
|
1081
|
+
return [
|
|
1082
|
+
...shared,
|
|
1083
|
+
`${pad}// This local preview has no provider to send it to, so here — and only`,
|
|
1084
|
+
`${pad}// here — you can uncomment a fixture to see a filled panel with no backend`,
|
|
1085
|
+
`${pad}// (replace the empty initializer below with it):`,
|
|
1086
|
+
...decl(JSON.stringify(SAMPLE_AGENTIC_MESSAGE)).map((line) => `${pad}// ${line}`),
|
|
1087
|
+
];
|
|
1088
|
+
}
|
|
1089
|
+
|
|
234
1090
|
// ── front-end rendering ───────────────────────────────────────────────────────
|
|
235
1091
|
|
|
236
1092
|
interface RenderCtx {
|
|
237
1093
|
p: PlacementStyle;
|
|
238
1094
|
emptyHint: string;
|
|
239
1095
|
suggestions: string[];
|
|
1096
|
+
/**
|
|
1097
|
+
* The human label in the emitted banner comment, DERIVED from the components
|
|
1098
|
+
* (see `surfaceLabel`) rather than passed in from a preset's `title`.
|
|
1099
|
+
*
|
|
1100
|
+
* That is deliberate and it is what makes archetypes data. A renderer that took
|
|
1101
|
+
* a caller-supplied title would emit different bytes for the same components
|
|
1102
|
+
* depending on who asked, so `renderSurface(components)` and
|
|
1103
|
+
* `scaffold({ useCase })` could not be compared for equality — and that equality
|
|
1104
|
+
* is the single check standing between this file and a second renderer growing
|
|
1105
|
+
* back (see `assertPresetsAreData` in scripts/verify-scaffold-compiles.mjs).
|
|
1106
|
+
* The preset's title is still printed, in the provenance header `compose`
|
|
1107
|
+
* writes ABOVE the surface, where it describes where the request came from
|
|
1108
|
+
* instead of what was rendered.
|
|
1109
|
+
*/
|
|
1110
|
+
label: string;
|
|
240
1111
|
/** mock = stream the reply client-side (no fetch, no backend, no key) */
|
|
241
1112
|
isMock: boolean;
|
|
242
1113
|
/** SCAF-8: non-undefined when the integration forwards a model param */
|
|
243
1114
|
defaultModel?: string;
|
|
1115
|
+
/** the surface renders kai-tool AND the route forwards a tools array, so the
|
|
1116
|
+
* scaffold declares the schemas that make a tool call possible */
|
|
1117
|
+
emitTools: boolean;
|
|
1118
|
+
/** the surface renders kai-tool and there is a backend to call, so the live
|
|
1119
|
+
* multi-round loop (and the `runTool` stub it calls) is emitted */
|
|
1120
|
+
emitToolLoop: boolean;
|
|
1121
|
+
/** the surface bears cards and there is a model to ask for one, so the
|
|
1122
|
+
* registry + the loop's cardFromToolCall arm + the cardTypes/cardSchemas
|
|
1123
|
+
* wiring are emitted */
|
|
1124
|
+
emitCards: boolean;
|
|
1125
|
+
/** which provider envelope `cardTools()` projects into, or null when this
|
|
1126
|
+
* integration's route does not forward a tools array we can shape (see
|
|
1127
|
+
* `cardToolProviderFor`). Non-null implies `emitCards`. */
|
|
1128
|
+
cardProvider: 'openai' | 'anthropic' | 'jsonschema' | null;
|
|
244
1129
|
}
|
|
245
1130
|
|
|
246
1131
|
/** The kai-* tags for the archetype, in order, as opening/closing markup.
|
|
@@ -252,10 +1137,25 @@ interface RenderCtx {
|
|
|
252
1137
|
* SCAF-14: workspace structural types (kai-resizable, kai-artifact) are emitted
|
|
253
1138
|
* as a properly composed split layout — chat in one pane, artifact in the other.
|
|
254
1139
|
*/
|
|
255
|
-
function componentTags(
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
1140
|
+
function componentTags(components: readonly string[], chatFill: string): string {
|
|
1141
|
+
const companionTags = components.filter(
|
|
1142
|
+
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
1143
|
+
);
|
|
1144
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1145
|
+
const hasStandaloneCompanions = companionTags.length > 0;
|
|
1146
|
+
|
|
1147
|
+
const lines: string[] = [];
|
|
1148
|
+
// SCAF-14: workspace is a structural/layout surface — emit a runnable split.
|
|
1149
|
+
//
|
|
1150
|
+
// This used to `return` here, which dropped every standalone companion on the
|
|
1151
|
+
// floor: a surface with kai-sources AND the workspace pair rendered the split
|
|
1152
|
+
// and nothing else. No archetype could reach that combination (`workspace` is
|
|
1153
|
+
// the only preset with the pair and it carries no companions), so the bug was
|
|
1154
|
+
// unreachable until the surface axis became a components list. Building the
|
|
1155
|
+
// chat block into `lines` instead means the companion loop below runs either
|
|
1156
|
+
// way, which is the property that was missing rather than a special case.
|
|
1157
|
+
if (isWorkspace(components)) {
|
|
1158
|
+
lines.push(
|
|
259
1159
|
` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
|
|
260
1160
|
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
|
|
261
1161
|
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
|
|
@@ -267,22 +1167,16 @@ function componentTags(archetype: Archetype, chatFill: string): string {
|
|
|
267
1167
|
` <kai-artifact id="artifact" src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
|
|
268
1168
|
` </kai-resizable-item>`,
|
|
269
1169
|
` </kai-resizable>`,
|
|
270
|
-
|
|
1170
|
+
);
|
|
1171
|
+
} else {
|
|
1172
|
+
lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
|
|
271
1173
|
}
|
|
272
1174
|
|
|
273
|
-
const companionTags = archetype.components.filter(
|
|
274
|
-
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
275
|
-
);
|
|
276
|
-
const hasEmbedded = archetype.components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
277
|
-
const hasStandaloneCompanions = companionTags.length > 0;
|
|
278
|
-
|
|
279
|
-
const lines: string[] = [];
|
|
280
|
-
lines.push(` <kai-chat id="chat" suggestion-mode="submit" style="${chatFill}"></kai-chat>`);
|
|
281
|
-
|
|
282
1175
|
if (hasEmbedded) {
|
|
283
1176
|
lines.push(
|
|
284
|
-
` <!-- kai-tool / kai-reasoning render INSIDE the thread, not as siblings
|
|
285
|
-
`
|
|
1177
|
+
` <!-- kai-tool / kai-reasoning render INSIDE the thread, not as siblings: they are`,
|
|
1178
|
+
` parts on a message — parts: [{ type: 'reasoning', … }, { type: 'tool', tool: {…} }, …]`,
|
|
1179
|
+
` — and the stream in the script below builds them as the model works. -->`,
|
|
286
1180
|
);
|
|
287
1181
|
}
|
|
288
1182
|
|
|
@@ -305,159 +1199,215 @@ function componentTags(archetype: Archetype, chatFill: string): string {
|
|
|
305
1199
|
return lines.join('\n');
|
|
306
1200
|
}
|
|
307
1201
|
|
|
308
|
-
/**
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
1202
|
+
/**
|
|
1203
|
+
* The `html` target's logic, as a REAL `src/main.ts` module.
|
|
1204
|
+
*
|
|
1205
|
+
* SCAF-19 used to inline this as plain JS inside `<script type="module">` in
|
|
1206
|
+
* index.html, on the reasoning that the wiring sets untyped properties on a raw
|
|
1207
|
+
* `customElements` reference and would need a hand-cast per property. Being
|
|
1208
|
+
* invisible to tsc was described as the benefit.
|
|
1209
|
+
*
|
|
1210
|
+
* It was the defect. The canonical getting-started path
|
|
1211
|
+
* (`npm create vite -- --template vanilla-ts`) builds with `tsc && vite build`
|
|
1212
|
+
* and its tsconfig is `"include": ["src"]`, so the consumer's own build
|
|
1213
|
+
* type-checked NONE of the scaffold's logic. Proven rather than argued: an
|
|
1214
|
+
* injected call to a function that does not exist anywhere left `npm run build`
|
|
1215
|
+
* exiting 0 in a stock app.
|
|
1216
|
+
*
|
|
1217
|
+
* The hand-cast worry does not survive contact either — the kit SHIPS the element
|
|
1218
|
+
* interfaces, so one `as KaiChatElement` at the lookup types every property that
|
|
1219
|
+
* follows, which is what the svelte and angular targets already do. And the
|
|
1220
|
+
* message type comes from the element itself
|
|
1221
|
+
* (`KaiChatElement['messages'][number]`) instead of the hand-written local subset
|
|
1222
|
+
* the other mock targets declare: derived from the property it is assigned to, it
|
|
1223
|
+
* cannot drift out of step with it.
|
|
1224
|
+
*
|
|
1225
|
+
* Moving the logic into `src/` also retires the TS18003 workaround the old note
|
|
1226
|
+
* carried. That error existed only because deleting the template's `src/main.ts`
|
|
1227
|
+
* left `src/` with no `.ts` files at all; this scaffold now IS `src/main.ts`.
|
|
1228
|
+
*/
|
|
1229
|
+
function htmlModule(ctx: RenderCtx, components: readonly string[]): string {
|
|
1230
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1231
|
+
const hasSources = components.includes('kai-sources');
|
|
312
1232
|
|
|
313
|
-
// SCAF-9:
|
|
1233
|
+
// SCAF-9: the agentic archetype explains where tool + reasoning parts come
|
|
1234
|
+
// from. It no longer SEEDS a fabricated turn — see `SAMPLE_AGENTIC_MESSAGE`.
|
|
314
1235
|
const seedLines = hasEmbedded
|
|
1236
|
+
? [...sampleSeedComment(ctx.isMock, ` `, (literal) => [`chat.messages = [${literal}];`]), ``]
|
|
1237
|
+
: [];
|
|
1238
|
+
|
|
1239
|
+
const sourcesSetupLines = hasSources
|
|
315
1240
|
? [
|
|
316
|
-
`
|
|
317
|
-
`
|
|
318
|
-
`
|
|
1241
|
+
` const sourcesEl = document.getElementById('sources') as KaiSourcesElement;`,
|
|
1242
|
+
` // Replace with your real source data (set as a JS property — it's an array).`,
|
|
1243
|
+
` const sampleSources = [`,
|
|
1244
|
+
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
|
|
1245
|
+
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
|
|
1246
|
+
` ];`,
|
|
1247
|
+
` sourcesEl.sources = sampleSources;`,
|
|
319
1248
|
``,
|
|
320
1249
|
]
|
|
321
1250
|
: [];
|
|
322
1251
|
|
|
323
|
-
|
|
1252
|
+
// Module scope, like vue/angular: the handler below closes over all three, and
|
|
1253
|
+
// `runTool` is a function declaration rather than something wedged into init().
|
|
1254
|
+
const modelLines = ctx.defaultModel
|
|
324
1255
|
? [
|
|
325
|
-
|
|
326
|
-
`
|
|
327
|
-
` const sampleSources = [`,
|
|
328
|
-
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
|
|
329
|
-
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
|
|
330
|
-
` ];`,
|
|
331
|
-
` sourcesEl.sources = sampleSources;`,
|
|
1256
|
+
`// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
|
|
1257
|
+
`const model = '${ctx.defaultModel}';`,
|
|
332
1258
|
``,
|
|
333
1259
|
]
|
|
334
1260
|
: [];
|
|
1261
|
+
const cardsLines = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
|
|
1262
|
+
const toolsLines = ctx.emitTools ? [...toolSchemaLines('', ctx.cardProvider), ``] : [];
|
|
1263
|
+
const runnerLines = ctx.emitToolLoop ? [...toolRunnerLines('', true), ``] : [];
|
|
1264
|
+
const cardPropLines = ctx.emitCards
|
|
1265
|
+
? [
|
|
1266
|
+
...CARD_PROP_COMMENT.map((l) => ` ${l}`),
|
|
1267
|
+
` chat.cardTypes = cards.tags;`,
|
|
1268
|
+
` chat.cardSchemas = cards.validationSchemas;`,
|
|
1269
|
+
``,
|
|
1270
|
+
]
|
|
1271
|
+
: [];
|
|
1272
|
+
|
|
1273
|
+
// KaiSourcesElement only when a kai-sources companion is really declared: a
|
|
1274
|
+
// stock vanilla-ts tsconfig sets noUnusedLocals, so an always-on import is a
|
|
1275
|
+
// build error on every other archetype.
|
|
1276
|
+
const elementTypes = hasSources ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
|
|
1277
|
+
|
|
1278
|
+
/**
|
|
1279
|
+
* Same rule, applied to the kit's own `ChatMessage`.
|
|
1280
|
+
*
|
|
1281
|
+
* Unlike vue and svelte — which declare `ref<ChatMessage[]>` / `let messages:
|
|
1282
|
+
* ChatMessage[]` and therefore always reference it — this target keeps the
|
|
1283
|
+
* thread on the element. So the name is only used by the SINGLE-ROUND shape's
|
|
1284
|
+
* `const history: ChatMessage[]`; the tool-loop shape's thread IS
|
|
1285
|
+
* `chat.messages`, already typed by `KaiChatElement`, and importing the type
|
|
1286
|
+
* there is a TS6133 that fails `npm run build` in a stock app.
|
|
1287
|
+
*/
|
|
1288
|
+
const annotatesChatMessage = !ctx.emitToolLoop;
|
|
335
1289
|
|
|
336
1290
|
const head = [
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
`
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
`
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
1291
|
+
`// src/main.ts — the page's logic, in a module YOUR build type-checks.`,
|
|
1292
|
+
`//`,
|
|
1293
|
+
`// It lives here rather than inline in index.html on purpose: the canonical`,
|
|
1294
|
+
`// getting-started path (\`npm create vite -- --template vanilla-ts\`) builds with`,
|
|
1295
|
+
`// \`tsc && vite build\` and scopes its tsconfig to "include": ["src"], so an`,
|
|
1296
|
+
`// inline <script> is checked by nothing at all. Delete the template's own`,
|
|
1297
|
+
`// src/main.ts and save this in its place; index.html already points at it.`,
|
|
1298
|
+
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
1299
|
+
`// The kit ships the element interfaces, so one cast at the lookup below types`,
|
|
1300
|
+
`// every property assignment that follows.`,
|
|
1301
|
+
`import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
|
|
1302
|
+
...wireImportLines({
|
|
1303
|
+
typed: annotatesChatMessage,
|
|
1304
|
+
toolLoop: ctx.emitToolLoop,
|
|
1305
|
+
cards: ctx.emitCards,
|
|
1306
|
+
cardTools: ctx.cardProvider !== null,
|
|
1307
|
+
mock: ctx.isMock,
|
|
1308
|
+
}),
|
|
1309
|
+
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
1310
|
+
``,
|
|
1311
|
+
...(ctx.isMock ? [...mockResponderInit(), ``] : []),
|
|
1312
|
+
...modelLines,
|
|
1313
|
+
...cardsLines,
|
|
1314
|
+
...toolsLines,
|
|
1315
|
+
...runnerLines,
|
|
1316
|
+
`async function init() {`,
|
|
1317
|
+
` const chat = document.getElementById('chat') as KaiChatElement;`,
|
|
1318
|
+
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
|
|
1319
|
+
` // element may not be upgraded yet. Wait for the upgrade before setting any`,
|
|
1320
|
+
` // array/object property — values set pre-upgrade are dropped on upgrade.`,
|
|
1321
|
+
` await customElements.whenDefined('kai-chat');`,
|
|
1322
|
+
` // suggestions is a JS PROPERTY (arrays can't be HTML attributes)`,
|
|
1323
|
+
` chat.suggestions = ${jsArray(ctx.suggestions)};`,
|
|
1324
|
+
` chat.suggestionMode = 'submit';`,
|
|
352
1325
|
``,
|
|
353
|
-
...
|
|
354
|
-
...
|
|
1326
|
+
...cardPropLines,
|
|
1327
|
+
...seedLines,
|
|
1328
|
+
...sourcesSetupLines,
|
|
355
1329
|
];
|
|
356
1330
|
|
|
357
|
-
//
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
`
|
|
362
|
-
`
|
|
363
|
-
|
|
364
|
-
|
|
1331
|
+
// `Event`, not `CustomEvent`: addEventListener with a custom event name hands
|
|
1332
|
+
// the listener a plain Event, so the narrowing happens in the body — the same
|
|
1333
|
+
// shape renderAngular emits, for the same reason.
|
|
1334
|
+
const listenerOpen = [
|
|
1335
|
+
` chat.addEventListener('kai-submit', async (event: Event) => {`,
|
|
1336
|
+
` const e = event as CustomEvent<{ value: string }>;`,
|
|
1337
|
+
];
|
|
1338
|
+
const footer = [
|
|
1339
|
+
` });`,
|
|
1340
|
+
`}`,
|
|
1341
|
+
``,
|
|
1342
|
+
`// A <script type="module"> is deferred, so the DOM is already parsed here.`,
|
|
1343
|
+
`void init();`,
|
|
365
1344
|
];
|
|
366
|
-
|
|
367
|
-
if (ctx.isMock) {
|
|
368
|
-
const body = mockStreamBody({
|
|
369
|
-
pad: ' ',
|
|
370
|
-
read: 'chat.messages',
|
|
371
|
-
commitInitial: (expr) => `chat.messages = ${expr};`,
|
|
372
|
-
// chat.messages is live (no React snapshot) — map over it directly
|
|
373
|
-
commitMap: (mapBody) => `chat.messages = chat.messages.map((m) => ${mapBody});`,
|
|
374
|
-
setLoading: (v) => `chat.loading = ${v};`,
|
|
375
|
-
});
|
|
376
|
-
return [
|
|
377
|
-
...head,
|
|
378
|
-
` // No backend: stream a canned reply client-side (no fetch, no API key).`,
|
|
379
|
-
` chat.addEventListener('kai-submit', async (e) => {`,
|
|
380
|
-
body,
|
|
381
|
-
` });`,
|
|
382
|
-
...domReadyFooter,
|
|
383
|
-
` </script>`,
|
|
384
|
-
].join('\n');
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// SCAF-8: include model in the POST body when the integration forwards it.
|
|
388
|
-
const modelLines = ctx.defaultModel
|
|
389
|
-
? [
|
|
390
|
-
` // SCAF-8: change this model id to any provider/model string you want to use.`,
|
|
391
|
-
` const model = '${ctx.defaultModel}';`,
|
|
392
|
-
``,
|
|
393
|
-
]
|
|
394
|
-
: [];
|
|
395
|
-
const bodyPayload = ctx.defaultModel
|
|
396
|
-
? `{ model, messages: history.map((m) => ({ role: m.role, content: m.content })) }`
|
|
397
|
-
: `{ messages: history.map((m) => ({ role: m.role, content: m.content })) }`;
|
|
398
1345
|
|
|
399
1346
|
return [
|
|
400
1347
|
...head,
|
|
401
|
-
`
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
` const decoder = new TextDecoder();`,
|
|
422
|
-
` let buffer = '', answer = '';`,
|
|
423
|
-
` while (true) {`,
|
|
424
|
-
` const { value: chunk, done } = await reader.read();`,
|
|
425
|
-
` if (done) break;`,
|
|
426
|
-
` buffer += decoder.decode(chunk, { stream: true });`,
|
|
427
|
-
` const lines = buffer.split('\\n');`,
|
|
428
|
-
` buffer = lines.pop();`,
|
|
429
|
-
` for (const line of lines) {`,
|
|
430
|
-
` const s = line.trim();`,
|
|
431
|
-
` if (!s.startsWith('data:')) continue;`,
|
|
432
|
-
` const payload = s.slice(5).trim();`,
|
|
433
|
-
` if (payload === '[DONE]') continue;`,
|
|
434
|
-
` try {`,
|
|
435
|
-
` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
|
|
436
|
-
` if (!delta) continue;`,
|
|
437
|
-
` answer += delta;`,
|
|
438
|
-
` chat.messages = chat.messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
|
|
439
|
-
` } catch { /* skip keep-alive lines */ }`,
|
|
440
|
-
` }`,
|
|
441
|
-
` }`,
|
|
442
|
-
` chat.loading = false;`,
|
|
443
|
-
` });`,
|
|
444
|
-
...domReadyFooter,
|
|
445
|
-
` </script>`,
|
|
1348
|
+
` // messages is a JS PROPERTY (objects can't be HTML attributes)`,
|
|
1349
|
+
...listenerOpen,
|
|
1350
|
+
realStreamBody({
|
|
1351
|
+
pad: ' ',
|
|
1352
|
+
read: 'chat.messages',
|
|
1353
|
+
commitSet: (expr) => `chat.messages = ${expr};`,
|
|
1354
|
+
setterAdapter: '(fn) => { chat.messages = fn(chat.messages); }',
|
|
1355
|
+
setLoading: (v) => `chat.loading = ${v};`,
|
|
1356
|
+
bodyPayload: realBodyPayload({ defaultModel: ctx.defaultModel, tools: ctx.emitTools }),
|
|
1357
|
+
strictRoles: true,
|
|
1358
|
+
toolLoop: ctx.emitToolLoop,
|
|
1359
|
+
cards: ctx.emitCards,
|
|
1360
|
+
thread: liveThreadBinding(
|
|
1361
|
+
'chat.messages',
|
|
1362
|
+
'(fn) => { chat.messages = fn(chat.messages); }',
|
|
1363
|
+
'chat.messages ?? []',
|
|
1364
|
+
),
|
|
1365
|
+
mock: ctx.isMock,
|
|
1366
|
+
}),
|
|
1367
|
+
...footer,
|
|
446
1368
|
].join('\n');
|
|
447
1369
|
}
|
|
448
1370
|
|
|
449
|
-
|
|
1371
|
+
/**
|
|
1372
|
+
* The `html` target: TWO files, split on the same `// ── path ──` separator the
|
|
1373
|
+
* backend routes already use.
|
|
1374
|
+
*
|
|
1375
|
+
* index.html the markup, plus a <script type="module" src="/src/main.ts">
|
|
1376
|
+
* src/main.ts the logic — see `htmlModule` for why it is not inline any more
|
|
1377
|
+
*
|
|
1378
|
+
* Both files are emitted for every target that lands here — the backend-only
|
|
1379
|
+
* frameworks (express/worker/fastapi) get the same browser side, since a module
|
|
1380
|
+
* their bundler can see beats an inline script for them too. Only the note about
|
|
1381
|
+
* replacing the vanilla-ts template's own entry is specific to `html`, so only
|
|
1382
|
+
* that is gated on `isViteHtmlTarget`.
|
|
1383
|
+
*/
|
|
1384
|
+
function renderHtml(components: readonly string[], ctx: RenderCtx, isViteHtmlTarget: boolean): string {
|
|
450
1385
|
const { p, emptyHint } = ctx;
|
|
1386
|
+
const scriptNote = isViteHtmlTarget
|
|
1387
|
+
? [
|
|
1388
|
+
`<!-- The logic is a real module, NOT an inline script: a stock vanilla-ts`,
|
|
1389
|
+
` tsconfig is "include": ["src"], so anything inline is type-checked by`,
|
|
1390
|
+
` nothing at all. Delete the template's src/main.ts and save the file`,
|
|
1391
|
+
` below in its place — this tag is the one the template already ships. -->`,
|
|
1392
|
+
]
|
|
1393
|
+
: [
|
|
1394
|
+
`<!-- The logic is a real module, not an inline script, so your own build`,
|
|
1395
|
+
` type-checks it. Save the file below as src/main.ts. -->`,
|
|
1396
|
+
];
|
|
451
1397
|
return [
|
|
452
|
-
`<!--
|
|
453
|
-
|
|
1398
|
+
`<!-- index.html — paste this into <body>. -->`,
|
|
1399
|
+
`<!-- ${ctx.label} — ${p.note} -->`,
|
|
1400
|
+
...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
|
|
454
1401
|
`<div style="${p.style}">`,
|
|
455
|
-
componentTags(
|
|
1402
|
+
componentTags(components, p.chatFill),
|
|
456
1403
|
`</div>`,
|
|
1404
|
+
...scriptNote,
|
|
1405
|
+
`<script type="module" src="/src/main.ts"></script>`,
|
|
457
1406
|
``,
|
|
458
|
-
|
|
1407
|
+
`<!-- empty-state hint: ${emptyHint} -->`,
|
|
459
1408
|
``,
|
|
460
|
-
|
|
1409
|
+
`// ── src/main.ts ──────────────────────────────────────────────────────────────`,
|
|
1410
|
+
htmlModule(ctx, components),
|
|
461
1411
|
].join('\n');
|
|
462
1412
|
}
|
|
463
1413
|
|
|
@@ -472,15 +1422,15 @@ function toPascalCase(tag: string): string {
|
|
|
472
1422
|
}
|
|
473
1423
|
|
|
474
1424
|
/** JSX usage for react/next: uses the official @kitn.ai/ui/react wrappers. */
|
|
475
|
-
function renderJsx(
|
|
476
|
-
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
|
|
1425
|
+
function renderJsx(components: readonly string[], ctx: RenderCtx, framework: string): string {
|
|
1426
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
477
1427
|
|
|
478
|
-
const hasEmbedded =
|
|
479
|
-
const workspace = isWorkspace(
|
|
1428
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1429
|
+
const workspace = isWorkspace(components);
|
|
480
1430
|
|
|
481
1431
|
// SCAF-9: exclude message-embedded tags from import list.
|
|
482
1432
|
// SCAF-14: workspace uses Resizable+ResizableItem+Artifact — keep them in the import list.
|
|
483
|
-
const renderableTags =
|
|
1433
|
+
const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
|
|
484
1434
|
// For workspace: replace 'kai-resizable' with 'kai-resizable-item' so we get ResizableItem too.
|
|
485
1435
|
const importTags = workspace
|
|
486
1436
|
? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
|
|
@@ -489,7 +1439,7 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
489
1439
|
const importList = wrapperNames.join(', ');
|
|
490
1440
|
|
|
491
1441
|
// SCAF-9: standalone companion tags (not kai-chat, not message-embedded, not workspace-structural).
|
|
492
|
-
const standaloneCompanionTags =
|
|
1442
|
+
const standaloneCompanionTags = components.filter(
|
|
493
1443
|
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
494
1444
|
);
|
|
495
1445
|
|
|
@@ -498,8 +1448,8 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
498
1448
|
const companionJsxLines: string[] = [];
|
|
499
1449
|
if (hasEmbedded) {
|
|
500
1450
|
companionJsxLines.push(
|
|
501
|
-
` {/* kai-tool / kai-reasoning render inside the thread
|
|
502
|
-
`
|
|
1451
|
+
` {/* kai-tool / kai-reasoning render inside the thread, as parts on the`,
|
|
1452
|
+
` assistant message the stream in onSubmit builds. */}`,
|
|
503
1453
|
);
|
|
504
1454
|
}
|
|
505
1455
|
for (const t of standaloneCompanionTags) {
|
|
@@ -515,19 +1465,19 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
515
1465
|
}
|
|
516
1466
|
const companions = companionJsxLines.join('\n');
|
|
517
1467
|
|
|
518
|
-
|
|
519
|
-
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
|
|
520
|
-
const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
|
|
1468
|
+
const mockInit = isMock ? mockResponderInit() : [];
|
|
521
1469
|
|
|
522
|
-
// SCAF-9:
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
1470
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE for the three ways
|
|
1471
|
+
// one broke a real app.
|
|
1472
|
+
const sampleMessagesInit = [
|
|
1473
|
+
...(hasEmbedded
|
|
1474
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
1475
|
+
`const sampleMessages: ChatMessage[] = [${literal}];`,
|
|
1476
|
+
`const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
|
|
1477
|
+
])
|
|
1478
|
+
: []),
|
|
1479
|
+
` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
|
|
1480
|
+
].join('\n');
|
|
531
1481
|
|
|
532
1482
|
// SCAF-9: sample sources data for knowledge-base archetype.
|
|
533
1483
|
const sampleSourcesInit =
|
|
@@ -543,68 +1493,53 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
543
1493
|
|
|
544
1494
|
// SCAF-8: model const for integrations that forward model to the upstream provider.
|
|
545
1495
|
const modelInit = defaultModel
|
|
546
|
-
? ` // SCAF-8: change this
|
|
1496
|
+
? ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.\n const model = '${defaultModel}';`
|
|
547
1497
|
: '';
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
//
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
` buffer += decoder.decode(chunk, { stream: true });`,
|
|
583
|
-
` const lines = buffer.split('\\n');`,
|
|
584
|
-
` buffer = lines.pop()!;`,
|
|
585
|
-
` for (const line of lines) {`,
|
|
586
|
-
` const s = line.trim();`,
|
|
587
|
-
` if (!s.startsWith('data:')) continue;`,
|
|
588
|
-
` const payload = s.slice(5).trim();`,
|
|
589
|
-
` if (payload === '[DONE]') continue;`,
|
|
590
|
-
` try {`,
|
|
591
|
-
` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
|
|
592
|
-
` if (!delta) continue;`,
|
|
593
|
-
` answer += delta;`,
|
|
594
|
-
` setMessages((ms) => ms.map((m) => (m.id === assistantId ? { ...m, content: answer } : m)));`,
|
|
595
|
-
` } catch { /* skip keep-alives */ }`,
|
|
596
|
-
` }`,
|
|
597
|
-
` }`,
|
|
598
|
-
` setLoading(false);`,
|
|
599
|
-
].join('\n');
|
|
1498
|
+
|
|
1499
|
+
const toolsInit = emitTools ? toolSchemaLines(' ', ctx.cardProvider).join('\n') : '';
|
|
1500
|
+
const toolRunner = emitToolLoop ? toolRunnerLines(' ', true).join('\n') : '';
|
|
1501
|
+
// MODULE scope, unlike `tools`/`runTool`: a registry is static data, so rebuilding
|
|
1502
|
+
// it on every render would allocate a new object per keystroke and hand <Chat> a
|
|
1503
|
+
// new cardTypes reference each time.
|
|
1504
|
+
const cardsInit = ctx.emitCards ? cardRegistryLines('') : [];
|
|
1505
|
+
// The two card props, as JSX props on the wrapper — which sets them as DOM
|
|
1506
|
+
// PROPERTIES on <kai-chat>, never as attributes. The explanation goes ABOVE the
|
|
1507
|
+
// element, not between its attributes: `{/* … */}` is only legal at element
|
|
1508
|
+
// position in JSX, and in attribute position it is a parse error.
|
|
1509
|
+
const cardProps = (pad: string): string[] =>
|
|
1510
|
+
ctx.emitCards
|
|
1511
|
+
? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`]
|
|
1512
|
+
: [];
|
|
1513
|
+
const cardPropsNote = (pad: string): string[] =>
|
|
1514
|
+
ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
|
|
1515
|
+
|
|
1516
|
+
// onSubmit body. The mock and the real backend differ by ONE expression — the
|
|
1517
|
+
// stream's source — and share every other line: see `realStreamBody`.
|
|
1518
|
+
const onSubmitBody = realStreamBody({
|
|
1519
|
+
pad: ' ',
|
|
1520
|
+
read: 'messages',
|
|
1521
|
+
commitSet: (expr) => `setMessages(${expr});`,
|
|
1522
|
+
// useState's setter IS a SetMessages: both are (updater) => void.
|
|
1523
|
+
setterAdapter: 'setMessages',
|
|
1524
|
+
setLoading: (v) => `setLoading(${v});`,
|
|
1525
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
1526
|
+
strictRoles: true,
|
|
1527
|
+
toolLoop: emitToolLoop,
|
|
1528
|
+
cards: ctx.emitCards,
|
|
1529
|
+
thread: REACT_THREAD,
|
|
1530
|
+
mock: isMock,
|
|
1531
|
+
});
|
|
600
1532
|
|
|
601
1533
|
// SCAF-2: Next.js App Router requires 'use client' for components that use hooks/interactivity.
|
|
602
1534
|
const useClientDirective = framework === 'next' ? [`'use client';`, ``] : [];
|
|
603
1535
|
|
|
604
|
-
// SCAF-6: For Next.js ONLY — use next/dynamic with { ssr: false }
|
|
605
|
-
//
|
|
606
|
-
//
|
|
607
|
-
//
|
|
1536
|
+
// SCAF-6: For Next.js ONLY — use next/dynamic with { ssr: false }. NOT because
|
|
1537
|
+
// importing the package on the server crashes: `@kitn.ai/ui/react`, `@kitn.ai/ui/elements`
|
|
1538
|
+
// and the state helpers are all SSR-import-safe (verified by prerendering a server
|
|
1539
|
+
// component that statically imports them). The reason is rendering: <kai-*> are
|
|
1540
|
+
// CLIENT-ONLY custom elements, and the server has no customElements registry, so a
|
|
1541
|
+
// server-rendered <kai-chat> is an inert unupgraded tag that mismatches the upgraded
|
|
1542
|
+
// client tree on hydration. Plain `react` (Vite) has no SSR and keeps top-level imports.
|
|
608
1543
|
if (framework === 'next') {
|
|
609
1544
|
// Build dynamic() calls for every renderable wrapper in the archetype.
|
|
610
1545
|
const dynamicImports = wrapperNames.map(
|
|
@@ -620,21 +1555,36 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
620
1555
|
...useClientDirective,
|
|
621
1556
|
`import { useState } from 'react';`,
|
|
622
1557
|
`import dynamic from 'next/dynamic';`,
|
|
1558
|
+
// The adapter is pure parsing + pure state; both entries are SSR-import-safe,
|
|
1559
|
+
// so they stay static imports even though the ELEMENTS have to be dynamic.
|
|
1560
|
+
...wireImportLines({
|
|
1561
|
+
typed: true,
|
|
1562
|
+
toolLoop: emitToolLoop,
|
|
1563
|
+
setMessagesType: emitToolLoop,
|
|
1564
|
+
cards: ctx.emitCards,
|
|
1565
|
+
cardTools: ctx.cardProvider !== null,
|
|
1566
|
+
mock: isMock,
|
|
1567
|
+
}),
|
|
623
1568
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
624
|
-
`// kai
|
|
1569
|
+
`// <kai-*> are client-only custom elements (the server has no customElements`,
|
|
1570
|
+
`// registry) → load client-only so hydration doesn't mismatch. The package itself`,
|
|
1571
|
+
`// is SSR-import-safe; importing it from a server component is fine.`,
|
|
625
1572
|
...dynamicImports,
|
|
626
1573
|
``,
|
|
627
1574
|
...nextConfigNote,
|
|
628
|
-
`// ${
|
|
629
|
-
...(p.altNote
|
|
630
|
-
|
|
1575
|
+
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
1576
|
+
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
1577
|
+
...mockInit,
|
|
631
1578
|
``,
|
|
1579
|
+
...cardsInit,
|
|
632
1580
|
`export default function App() {`,
|
|
633
1581
|
sampleMessagesInit,
|
|
634
1582
|
` const [loading, setLoading] = useState(false);`,
|
|
635
1583
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
636
1584
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
637
1585
|
...(modelInit ? [modelInit] : []),
|
|
1586
|
+
...(toolsInit ? [toolsInit] : []),
|
|
1587
|
+
...(toolRunner ? [toolRunner] : []),
|
|
638
1588
|
``,
|
|
639
1589
|
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
|
|
640
1590
|
onSubmitBody,
|
|
@@ -648,27 +1598,36 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
648
1598
|
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
649
1599
|
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
650
1600
|
` <ResizableItem size="40%" min="240px">`,
|
|
1601
|
+
...cardPropsNote(' '),
|
|
651
1602
|
` <Chat`,
|
|
652
1603
|
` messages={messages}`,
|
|
653
1604
|
` loading={loading}`,
|
|
654
1605
|
` suggestions={suggestions}`,
|
|
655
1606
|
` suggestionMode="submit"`,
|
|
1607
|
+
...cardProps(' '),
|
|
656
1608
|
` onSubmit={onSubmit}`,
|
|
657
1609
|
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
658
1610
|
` />`,
|
|
659
1611
|
` </ResizableItem>`,
|
|
660
1612
|
` <ResizableItem min="280px">`,
|
|
661
|
-
` {/* Replace src with your artifact
|
|
662
|
-
` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
|
|
1613
|
+
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
1614
|
+
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
663
1615
|
` </ResizableItem>`,
|
|
664
1616
|
` </Resizable>`,
|
|
1617
|
+
// Standalone companions are siblings of the SPLIT, not of the chat:
|
|
1618
|
+
// the split owns chat + artifact, and a sources panel or a voice input
|
|
1619
|
+
// belongs beside it. Dropping them here is what the workspace branch
|
|
1620
|
+
// used to do — see WORKSPACE_STRUCTURAL_TAGS.
|
|
1621
|
+
companions,
|
|
665
1622
|
]
|
|
666
1623
|
: [
|
|
1624
|
+
...cardPropsNote(' '),
|
|
667
1625
|
` <Chat`,
|
|
668
1626
|
` messages={messages}`,
|
|
669
1627
|
` loading={loading}`,
|
|
670
1628
|
` suggestions={suggestions}`,
|
|
671
1629
|
` suggestionMode="submit"`,
|
|
1630
|
+
...cardProps(' '),
|
|
672
1631
|
` onSubmit={onSubmit}`,
|
|
673
1632
|
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
674
1633
|
` />`,
|
|
@@ -693,19 +1652,30 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
693
1652
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
694
1653
|
`import { useState } from 'react';`,
|
|
695
1654
|
`import { ${importList} } from '@kitn.ai/ui/react';`,
|
|
1655
|
+
...wireImportLines({
|
|
1656
|
+
typed: true,
|
|
1657
|
+
toolLoop: emitToolLoop,
|
|
1658
|
+
setMessagesType: emitToolLoop,
|
|
1659
|
+
cards: ctx.emitCards,
|
|
1660
|
+
cardTools: ctx.cardProvider !== null,
|
|
1661
|
+
mock: isMock,
|
|
1662
|
+
}),
|
|
696
1663
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
697
1664
|
``,
|
|
698
1665
|
...nextConfigNote,
|
|
699
|
-
`// ${
|
|
700
|
-
...(p.altNote
|
|
701
|
-
|
|
1666
|
+
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
1667
|
+
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
1668
|
+
...mockInit,
|
|
702
1669
|
``,
|
|
1670
|
+
...cardsInit,
|
|
703
1671
|
`export default function App() {`,
|
|
704
1672
|
sampleMessagesInit,
|
|
705
1673
|
` const [loading, setLoading] = useState(false);`,
|
|
706
1674
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
707
1675
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
708
1676
|
...(modelInit ? [modelInit] : []),
|
|
1677
|
+
...(toolsInit ? [toolsInit] : []),
|
|
1678
|
+
...(toolRunner ? [toolRunner] : []),
|
|
709
1679
|
``,
|
|
710
1680
|
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
|
|
711
1681
|
onSubmitBody,
|
|
@@ -719,27 +1689,33 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
719
1689
|
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
720
1690
|
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
721
1691
|
` <ResizableItem size="40%" min="240px">`,
|
|
1692
|
+
...cardPropsNote(' '),
|
|
722
1693
|
` <Chat`,
|
|
723
1694
|
` messages={messages}`,
|
|
724
1695
|
` loading={loading}`,
|
|
725
1696
|
` suggestions={suggestions}`,
|
|
726
1697
|
` suggestionMode="submit"`,
|
|
1698
|
+
...cardProps(' '),
|
|
727
1699
|
` onSubmit={onSubmit}`,
|
|
728
1700
|
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
729
1701
|
` />`,
|
|
730
1702
|
` </ResizableItem>`,
|
|
731
1703
|
` <ResizableItem min="280px">`,
|
|
732
|
-
` {/* Replace src with your artifact
|
|
733
|
-
` <Artifact src="https://example.com" style={{ width: '100%', height: '100%' }} />`,
|
|
1704
|
+
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
1705
|
+
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
734
1706
|
` </ResizableItem>`,
|
|
735
1707
|
` </Resizable>`,
|
|
1708
|
+
// Siblings of the SPLIT — see the same line in the other JSX branch.
|
|
1709
|
+
companions,
|
|
736
1710
|
]
|
|
737
1711
|
: [
|
|
1712
|
+
...cardPropsNote(' '),
|
|
738
1713
|
` <Chat`,
|
|
739
1714
|
` messages={messages}`,
|
|
740
1715
|
` loading={loading}`,
|
|
741
1716
|
` suggestions={suggestions}`,
|
|
742
1717
|
` suggestionMode="submit"`,
|
|
1718
|
+
...cardProps(' '),
|
|
743
1719
|
` onSubmit={onSubmit}`,
|
|
744
1720
|
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
745
1721
|
` />`,
|
|
@@ -754,21 +1730,21 @@ function renderJsx(archetype: Archetype, ctx: RenderCtx, framework: string): str
|
|
|
754
1730
|
}
|
|
755
1731
|
|
|
756
1732
|
/** Vue: bind messages/suggestions as properties, listen for kai-submit with @. */
|
|
757
|
-
function renderVue(
|
|
758
|
-
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
|
|
1733
|
+
function renderVue(components: readonly string[], ctx: RenderCtx): string {
|
|
1734
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
759
1735
|
|
|
760
1736
|
// SCAF-9: exclude message-embedded tags from companion rendering.
|
|
761
1737
|
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
|
|
762
|
-
const workspace = isWorkspace(
|
|
763
|
-
const standaloneCompanionTags =
|
|
1738
|
+
const workspace = isWorkspace(components);
|
|
1739
|
+
const standaloneCompanionTags = components.filter(
|
|
764
1740
|
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
765
1741
|
);
|
|
766
|
-
const hasEmbedded =
|
|
1742
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
767
1743
|
|
|
768
1744
|
const companionLines: string[] = [];
|
|
769
1745
|
if (hasEmbedded) {
|
|
770
1746
|
companionLines.push(
|
|
771
|
-
` <!-- kai-tool / kai-reasoning render INSIDE the thread
|
|
1747
|
+
` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
|
|
772
1748
|
);
|
|
773
1749
|
}
|
|
774
1750
|
for (const t of standaloneCompanionTags) {
|
|
@@ -782,79 +1758,56 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
782
1758
|
}
|
|
783
1759
|
const companions = companionLines.join('\n');
|
|
784
1760
|
|
|
785
|
-
|
|
786
|
-
|
|
787
|
-
|
|
788
|
-
:
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
|
|
794
|
-
|
|
795
|
-
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
799
|
-
|
|
800
|
-
|
|
801
|
-
|
|
802
|
-
|
|
803
|
-
|
|
804
|
-
` const assistantId = crypto.randomUUID();`,
|
|
805
|
-
` messages.value = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
|
|
806
|
-
` loading.value = true;`,
|
|
807
|
-
` // POST to /api/chat, then stream the OpenAI-format SSE into the`,
|
|
808
|
-
` // assistant message (reassign messages.value per chunk) — see the Streaming recipe.`,
|
|
809
|
-
...(defaultModel
|
|
810
|
-
? [
|
|
811
|
-
` // SCAF-8: change this to any provider/model string you want to use.`,
|
|
812
|
-
` const model = '${defaultModel}';`,
|
|
813
|
-
]
|
|
814
|
-
: []),
|
|
815
|
-
` const res = await fetch('/api/chat', {`,
|
|
816
|
-
` method: 'POST',`,
|
|
817
|
-
` headers: { 'Content-Type': 'application/json' },`,
|
|
818
|
-
` body: JSON.stringify(${bodyPayload}),`,
|
|
819
|
-
` });`,
|
|
820
|
-
` // Stream the OpenAI-format SSE — see the Streaming recipe.`,
|
|
821
|
-
` const reader = res.body.getReader();`,
|
|
822
|
-
` const decoder = new TextDecoder();`,
|
|
823
|
-
` let buffer = '', answer = '';`,
|
|
824
|
-
` while (true) {`,
|
|
825
|
-
` const { value: chunk, done } = await reader.read();`,
|
|
826
|
-
` if (done) break;`,
|
|
827
|
-
` buffer += decoder.decode(chunk, { stream: true });`,
|
|
828
|
-
` const lines = buffer.split('\\n');`,
|
|
829
|
-
` buffer = lines.pop();`,
|
|
830
|
-
` for (const line of lines) {`,
|
|
831
|
-
` const s = line.trim();`,
|
|
832
|
-
` if (!s.startsWith('data:')) continue;`,
|
|
833
|
-
` const payload = s.slice(5).trim();`,
|
|
834
|
-
` if (payload === '[DONE]') continue;`,
|
|
835
|
-
` try {`,
|
|
836
|
-
` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
|
|
837
|
-
` if (!delta) continue;`,
|
|
838
|
-
` answer += delta;`,
|
|
839
|
-
` messages.value = messages.value.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
|
|
840
|
-
` } catch { /* skip keep-alives */ }`,
|
|
841
|
-
` }`,
|
|
842
|
-
` }`,
|
|
843
|
-
` loading.value = false;`,
|
|
844
|
-
].join('\n');
|
|
845
|
-
|
|
846
|
-
// SCAF-10: ChatMessage type for strict-TS Vue consumers — matches the React SCAF-4 type.
|
|
847
|
-
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
|
|
848
|
-
const chatMessageType = `type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
|
|
849
|
-
|
|
850
|
-
// SCAF-9: sample seeding for agentic archetype.
|
|
851
|
-
const sampleSeed = hasEmbedded
|
|
1761
|
+
const onSubmitBody = realStreamBody({
|
|
1762
|
+
pad: ' ',
|
|
1763
|
+
read: 'messages.value',
|
|
1764
|
+
commitSet: (expr) => `messages.value = ${expr};`,
|
|
1765
|
+
setterAdapter: '(fn) => { messages.value = fn(messages.value); }',
|
|
1766
|
+
setLoading: (v) => `loading.value = ${v};`,
|
|
1767
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
1768
|
+
strictRoles: true,
|
|
1769
|
+
toolLoop: emitToolLoop,
|
|
1770
|
+
cards: ctx.emitCards,
|
|
1771
|
+
thread: liveThreadBinding('messages.value', '(fn) => { messages.value = fn(messages.value); }'),
|
|
1772
|
+
mock: isMock,
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
// SCAF-10: ChatMessage declaration for strict-TS Vue consumers.
|
|
1776
|
+
const mockInit = isMock ? mockResponderInit() : [];
|
|
1777
|
+
|
|
1778
|
+
// SCAF-8: model const at module scope so onSubmit closes over it.
|
|
1779
|
+
const modelInit = defaultModel
|
|
852
1780
|
? [
|
|
853
|
-
`// SCAF-
|
|
854
|
-
|
|
855
|
-
`const messages = ref<ChatMessage[]>([${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}]);`,
|
|
1781
|
+
`// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
|
|
1782
|
+
`const model = '${defaultModel}';`,
|
|
856
1783
|
]
|
|
857
|
-
: [
|
|
1784
|
+
: [];
|
|
1785
|
+
|
|
1786
|
+
const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
|
|
1787
|
+
// Vue applies the card props in the SAME onMounted re-application the other
|
|
1788
|
+
// element properties go through, and NOT as a `:cardTypes.prop` template binding.
|
|
1789
|
+
// A registry is static — it is built once at module load and never changes — so a
|
|
1790
|
+
// reactive binding would buy nothing, and the one place that has to be right is
|
|
1791
|
+
// the post-upgrade re-apply: a property set on a not-yet-upgraded custom element
|
|
1792
|
+
// is dropped on upgrade.
|
|
1793
|
+
const cardPropAssign = ctx.emitCards
|
|
1794
|
+
? [`cardTypes: cards.tags`, `cardSchemas: cards.validationSchemas`]
|
|
1795
|
+
: [];
|
|
1796
|
+
|
|
1797
|
+
// Same scope, same reason: onSubmit puts `tools` in the request body.
|
|
1798
|
+
const toolsLines = emitTools ? toolSchemaLines('', ctx.cardProvider) : [];
|
|
1799
|
+
// Same scope again: the loop in onSubmit calls runTool.
|
|
1800
|
+
const runnerLines = emitToolLoop ? toolRunnerLines('', true) : [];
|
|
1801
|
+
|
|
1802
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
1803
|
+
const sampleSeed = [
|
|
1804
|
+
...(hasEmbedded
|
|
1805
|
+
? sampleSeedComment(isMock, '', (literal) => [
|
|
1806
|
+
`const messages = ref<ChatMessage[]>([${literal}]);`,
|
|
1807
|
+
])
|
|
1808
|
+
: []),
|
|
1809
|
+
`const messages = ref<ChatMessage[]>([]);`,
|
|
1810
|
+
];
|
|
858
1811
|
|
|
859
1812
|
// SCAF-9: sample sources setup.
|
|
860
1813
|
const sourcesSeed = standaloneCompanionTags.includes('kai-sources')
|
|
@@ -896,6 +1849,8 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
896
1849
|
` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
|
|
897
1850
|
` </kai-resizable-item>`,
|
|
898
1851
|
` </kai-resizable>`,
|
|
1852
|
+
// Siblings of the SPLIT — see the same line in renderJsx.
|
|
1853
|
+
companions,
|
|
899
1854
|
]
|
|
900
1855
|
: [
|
|
901
1856
|
` <kai-chat`,
|
|
@@ -910,22 +1865,33 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
910
1865
|
];
|
|
911
1866
|
|
|
912
1867
|
return [
|
|
913
|
-
`<!-- vue — ${
|
|
914
|
-
...(p.altNote
|
|
915
|
-
`<!-- SCAF-3: vite.config.ts
|
|
916
|
-
`
|
|
917
|
-
`
|
|
918
|
-
`
|
|
1868
|
+
`<!-- vue — ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint} -->`,
|
|
1869
|
+
...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
|
|
1870
|
+
`<!-- SCAF-3: pair this with the vite.config.ts in block (0) above. Without its`,
|
|
1871
|
+
` isCustomElement, every kai-* tag logs "[Vue warn]: Failed to resolve`,
|
|
1872
|
+
` component: kai-chat" in dev — the app still renders, but the console does`,
|
|
1873
|
+
` not, and that warning is Vue asking you for exactly that config. -->`,
|
|
919
1874
|
`<script setup lang="ts">`,
|
|
920
1875
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
1876
|
+
...wireImportLines({
|
|
1877
|
+
typed: true,
|
|
1878
|
+
toolLoop: emitToolLoop,
|
|
1879
|
+
cards: ctx.emitCards,
|
|
1880
|
+
cardTools: ctx.cardProvider !== null,
|
|
1881
|
+
mock: isMock,
|
|
1882
|
+
}),
|
|
921
1883
|
`import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
922
1884
|
vueImports,
|
|
923
1885
|
``,
|
|
924
|
-
|
|
1886
|
+
...mockInit,
|
|
925
1887
|
``,
|
|
1888
|
+
...cardsInit,
|
|
926
1889
|
...sampleSeed,
|
|
927
1890
|
`const loading = ref(false);`,
|
|
928
1891
|
`const suggestions = ${jsArray(suggestions)};`,
|
|
1892
|
+
...modelInit,
|
|
1893
|
+
...toolsLines,
|
|
1894
|
+
...runnerLines,
|
|
929
1895
|
...sourcesSeed,
|
|
930
1896
|
``,
|
|
931
1897
|
`// SCAF-15: kai-* register via an async dynamic import (SSR-safety). The .prop`,
|
|
@@ -933,8 +1899,9 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
933
1899
|
`// the element is defined so the initial messages/suggestions/loading stick.`,
|
|
934
1900
|
`onMounted(async () => {`,
|
|
935
1901
|
` await customElements.whenDefined('kai-chat');`,
|
|
1902
|
+
...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
|
|
936
1903
|
` const el = document.querySelector('kai-chat');`,
|
|
937
|
-
` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions });`,
|
|
1904
|
+
` if (el) Object.assign(el, { messages: messages.value, loading: loading.value, suggestions${cardPropAssign.length ? `, ${cardPropAssign.join(', ')}` : ''} });`,
|
|
938
1905
|
`});`,
|
|
939
1906
|
``,
|
|
940
1907
|
`async function onSubmit(e: CustomEvent<{ value: string }>) {`,
|
|
@@ -953,21 +1920,22 @@ function renderVue(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
953
1920
|
}
|
|
954
1921
|
|
|
955
1922
|
/** Svelte: use bind:this to set array/object properties reactively; on:kai-submit for the event. */
|
|
956
|
-
function renderSvelte(
|
|
957
|
-
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
|
|
1923
|
+
function renderSvelte(components: readonly string[], ctx: RenderCtx): string {
|
|
1924
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
958
1925
|
|
|
959
1926
|
// SCAF-9: exclude message-embedded tags from companion rendering.
|
|
960
1927
|
// SCAF-14: also exclude workspace structural tags (handled by the workspace block below).
|
|
961
|
-
const workspace = isWorkspace(
|
|
962
|
-
const standaloneCompanionTags =
|
|
1928
|
+
const workspace = isWorkspace(components);
|
|
1929
|
+
const standaloneCompanionTags = components.filter(
|
|
963
1930
|
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
964
1931
|
);
|
|
965
|
-
const hasEmbedded =
|
|
1932
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1933
|
+
const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
|
|
966
1934
|
|
|
967
1935
|
const companionLinesList: string[] = [];
|
|
968
1936
|
if (hasEmbedded) {
|
|
969
1937
|
companionLinesList.push(
|
|
970
|
-
` <!-- kai-tool / kai-reasoning render INSIDE the thread
|
|
1938
|
+
` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
|
|
971
1939
|
);
|
|
972
1940
|
}
|
|
973
1941
|
for (const t of standaloneCompanionTags) {
|
|
@@ -981,81 +1949,66 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
981
1949
|
}
|
|
982
1950
|
const companionLines = companionLinesList.join('\n');
|
|
983
1951
|
|
|
984
|
-
|
|
985
|
-
|
|
986
|
-
|
|
987
|
-
:
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
|
|
992
|
-
|
|
993
|
-
|
|
994
|
-
|
|
995
|
-
|
|
996
|
-
|
|
997
|
-
|
|
998
|
-
|
|
999
|
-
|
|
1000
|
-
|
|
1001
|
-
|
|
1002
|
-
|
|
1003
|
-
` const assistantId = crypto.randomUUID();`,
|
|
1004
|
-
` messages = [...history, { id: assistantId, role: 'assistant' as const, content: '' }];`,
|
|
1005
|
-
` loading = true;`,
|
|
1006
|
-
...(defaultModel
|
|
1007
|
-
? [
|
|
1008
|
-
` // SCAF-8: change this to any provider/model string you want to use.`,
|
|
1009
|
-
` const model = '${defaultModel}';`,
|
|
1010
|
-
]
|
|
1011
|
-
: []),
|
|
1012
|
-
` const res = await fetch('/api/chat', {`,
|
|
1013
|
-
` method: 'POST',`,
|
|
1014
|
-
` headers: { 'Content-Type': 'application/json' },`,
|
|
1015
|
-
` body: JSON.stringify(${bodyPayload}),`,
|
|
1016
|
-
` });`,
|
|
1017
|
-
` // Stream the OpenAI-format SSE into the assistant message — see the Streaming recipe.`,
|
|
1018
|
-
` const reader = res.body.getReader();`,
|
|
1019
|
-
` const decoder = new TextDecoder();`,
|
|
1020
|
-
` let buffer = '', answer = '';`,
|
|
1021
|
-
` while (true) {`,
|
|
1022
|
-
` const { value: chunk, done } = await reader.read();`,
|
|
1023
|
-
` if (done) break;`,
|
|
1024
|
-
` buffer += decoder.decode(chunk, { stream: true });`,
|
|
1025
|
-
` const lines = buffer.split('\\n');`,
|
|
1026
|
-
` buffer = lines.pop();`,
|
|
1027
|
-
` for (const line of lines) {`,
|
|
1028
|
-
` const s = line.trim();`,
|
|
1029
|
-
` if (!s.startsWith('data:')) continue;`,
|
|
1030
|
-
` const payload = s.slice(5).trim();`,
|
|
1031
|
-
` if (payload === '[DONE]') continue;`,
|
|
1032
|
-
` try {`,
|
|
1033
|
-
` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
|
|
1034
|
-
` if (!delta) continue;`,
|
|
1035
|
-
` answer += delta;`,
|
|
1036
|
-
` messages = messages.map((m) => (m.id === assistantId ? { ...m, content: answer } : m));`,
|
|
1037
|
-
` } catch { /* skip keep-alives */ }`,
|
|
1038
|
-
` }`,
|
|
1039
|
-
` }`,
|
|
1040
|
-
` loading = false;`,
|
|
1041
|
-
].join('\n');
|
|
1042
|
-
|
|
1043
|
-
// SCAF-10: ChatMessage type for strict-TS Svelte consumers — matches the React SCAF-4 type.
|
|
1044
|
-
// SCAF-11: state must be the library's 4-value union (not bare string); reasoning carries optional label.
|
|
1045
|
-
const chatMessageType = ` type ChatMessage = { id: string; role: 'user' | 'assistant'; content: string; reasoning?: { text: string; label?: string }; tools?: { type: string; state: 'input-streaming' | 'input-available' | 'output-available' | 'output-error'; input?: Record<string, unknown>; output?: Record<string, unknown>; toolCallId?: string }[] };`;
|
|
1046
|
-
|
|
1047
|
-
// SCAF-9: seed sample messages for agentic archetype.
|
|
1048
|
-
const sampleMessagesInit = hasEmbedded
|
|
1952
|
+
const onSubmitBody = realStreamBody({
|
|
1953
|
+
pad: ' ',
|
|
1954
|
+
read: 'messages',
|
|
1955
|
+
commitSet: (expr) => `messages = ${expr};`,
|
|
1956
|
+
setterAdapter: '(fn) => { messages = fn(messages); }',
|
|
1957
|
+
setLoading: (v) => `loading = ${v};`,
|
|
1958
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
1959
|
+
strictRoles: true,
|
|
1960
|
+
toolLoop: emitToolLoop,
|
|
1961
|
+
cards: ctx.emitCards,
|
|
1962
|
+
thread: liveThreadBinding('messages', '(fn) => { messages = fn(messages); }'),
|
|
1963
|
+
mock: isMock,
|
|
1964
|
+
});
|
|
1965
|
+
|
|
1966
|
+
// SCAF-10: ChatMessage declaration for strict-TS Svelte consumers.
|
|
1967
|
+
const mockInit = isMock ? mockResponderInit(' ') : [];
|
|
1968
|
+
|
|
1969
|
+
// SCAF-8: model const at script scope so onSubmit closes over it.
|
|
1970
|
+
const modelInit = defaultModel
|
|
1049
1971
|
? [
|
|
1050
|
-
` // SCAF-
|
|
1051
|
-
`
|
|
1052
|
-
` let messages: ChatMessage[] = [${JSON.stringify(SAMPLE_AGENTIC_MESSAGE)}];`,
|
|
1972
|
+
` // SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
|
|
1973
|
+
` const model = '${defaultModel}';`,
|
|
1053
1974
|
]
|
|
1054
|
-
: [
|
|
1975
|
+
: [];
|
|
1976
|
+
|
|
1977
|
+
const cardsInit = ctx.emitCards ? cardRegistryLines(' ') : [];
|
|
1978
|
+
// Applied in the same upgrade-gated $effect as messages/loading/suggestions, for
|
|
1979
|
+
// the same reason: a property set on a not-yet-upgraded custom element is dropped
|
|
1980
|
+
// on upgrade. `cards` never changes, so re-running the effect re-applies the same
|
|
1981
|
+
// two references and does nothing.
|
|
1982
|
+
const cardPropEffect = ctx.emitCards
|
|
1983
|
+
? ` chatEl.cardTypes = cards.tags; chatEl.cardSchemas = cards.validationSchemas;`
|
|
1984
|
+
: '';
|
|
1985
|
+
|
|
1986
|
+
// Same scope, same reason: onSubmit puts `tools` in the request body.
|
|
1987
|
+
const toolsLines = emitTools ? toolSchemaLines(' ', ctx.cardProvider) : [];
|
|
1988
|
+
// Same scope again: the loop in onSubmit calls runTool.
|
|
1989
|
+
const runnerLines = emitToolLoop ? toolRunnerLines(' ', true) : [];
|
|
1990
|
+
|
|
1991
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
1992
|
+
//
|
|
1993
|
+
// `$state.raw`, not `$state`: the kit's contract is a NEW array reference per
|
|
1994
|
+
// chunk (mutating in place does not re-render), which is exactly what raw state
|
|
1995
|
+
// tracks. Deep state would also proxy every message object on its way into a
|
|
1996
|
+
// Solid-backed custom element, for reactivity this code never relies on.
|
|
1997
|
+
const sampleMessagesInit = [
|
|
1998
|
+
...(hasEmbedded
|
|
1999
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
2000
|
+
`let messages = $state.raw<ChatMessage[]>([${literal}]);`,
|
|
2001
|
+
])
|
|
2002
|
+
: []),
|
|
2003
|
+
` let messages = $state.raw<ChatMessage[]>([]);`,
|
|
2004
|
+
];
|
|
1055
2005
|
|
|
1056
|
-
// SCAF-9: sources element ref + sample data.
|
|
1057
|
-
|
|
1058
|
-
|
|
2006
|
+
// SCAF-9: sources element ref + sample data. Typed as the kit's own element
|
|
2007
|
+
// interface (not HTMLElement) so the `.sources =` assignment below typechecks
|
|
2008
|
+
// honestly under `tsc --strict`: HTMLElement has no `sources` property.
|
|
2009
|
+
// `bind:this` writes to this binding, so in runes mode it has to be $state.
|
|
2010
|
+
const sourcesEl = hasSourcesCompanion
|
|
2011
|
+
? [` let sourcesEl = $state<KaiSourcesElement | undefined>(undefined);`]
|
|
1059
2012
|
: [];
|
|
1060
2013
|
const sourcesReactive = standaloneCompanionTags.includes('kai-sources')
|
|
1061
2014
|
? [
|
|
@@ -1064,7 +2017,7 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1064
2017
|
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
|
|
1065
2018
|
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
|
|
1066
2019
|
` ];`,
|
|
1067
|
-
`
|
|
2020
|
+
` $effect(() => { if (sourcesEl) { sourcesEl.sources = sampleSources; } });`,
|
|
1068
2021
|
]
|
|
1069
2022
|
: [];
|
|
1070
2023
|
|
|
@@ -1075,42 +2028,65 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1075
2028
|
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
|
|
1076
2029
|
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
|
|
1077
2030
|
` <kai-resizable-item size="40%" min="240px">`,
|
|
1078
|
-
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}"
|
|
2031
|
+
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
|
|
1079
2032
|
` </kai-resizable-item>`,
|
|
1080
2033
|
` <kai-resizable-item min="280px">`,
|
|
1081
2034
|
` <!-- Replace src with your artifact URL or set .files for multi-file preview. -->`,
|
|
1082
2035
|
` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
|
|
1083
2036
|
` </kai-resizable-item>`,
|
|
1084
2037
|
` </kai-resizable>`,
|
|
2038
|
+
// Siblings of the SPLIT — see the same line in renderJsx.
|
|
2039
|
+
companionLines,
|
|
1085
2040
|
]
|
|
1086
2041
|
: [
|
|
1087
|
-
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}"
|
|
2042
|
+
` <kai-chat bind:this={chatEl} suggestion-mode="submit" style="${p.chatFill}" onkai-submit={onSubmit}></kai-chat>`,
|
|
1088
2043
|
companionLines,
|
|
1089
2044
|
];
|
|
1090
2045
|
|
|
1091
2046
|
return [
|
|
1092
|
-
`<!-- svelte — ${
|
|
1093
|
-
...(p.altNote
|
|
1094
|
-
`<!-- SCAF-5:
|
|
1095
|
-
` runes
|
|
2047
|
+
`<!-- svelte — ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint} -->`,
|
|
2048
|
+
...(p.altNote ?? []).map((l) => `<!-- ${l} -->`),
|
|
2049
|
+
`<!-- SCAF-5: Svelte 5 RUNES ($state / $effect, onkai-submit). \`sv create\` forces`,
|
|
2050
|
+
` runes mode project-wide (see the compilerOptions in its vite.config.ts), so the`,
|
|
2051
|
+
` Svelte-4 forms this used to emit are hard errors there, not deprecations:`,
|
|
2052
|
+
` "\`$:\` is not allowed in runes mode" fails svelte-check AND vite build. -->`,
|
|
1096
2053
|
`<script lang="ts">`,
|
|
1097
2054
|
` import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
1098
|
-
|
|
2055
|
+
// KaiSourcesElement is only imported when a kai-sources companion is actually
|
|
2056
|
+
// declared below: an always-on import would be unused (and fail noUnusedLocals)
|
|
2057
|
+
// on every archetype without kai-sources.
|
|
2058
|
+
` import type { ${hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement'} } from '@kitn.ai/ui/elements';`,
|
|
2059
|
+
...wireImportLines({
|
|
2060
|
+
pad: ' ',
|
|
2061
|
+
typed: true,
|
|
2062
|
+
toolLoop: emitToolLoop,
|
|
2063
|
+
cards: ctx.emitCards,
|
|
2064
|
+
cardTools: ctx.cardProvider !== null,
|
|
2065
|
+
mock: isMock,
|
|
2066
|
+
}),
|
|
1099
2067
|
` import '@kitn.ai/ui/theme.tokens.css'; // compiled token defaults; use theme.css only for Tailwind-source apps`,
|
|
1100
2068
|
` import { onMount } from 'svelte';`,
|
|
1101
|
-
|
|
1102
|
-
`
|
|
2069
|
+
...mockInit,
|
|
2070
|
+
` // \`bind:this\` writes to this binding, so under runes it must be $state.`,
|
|
2071
|
+
` let chatEl = $state<KaiChatElement | undefined>(undefined);`,
|
|
1103
2072
|
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety). Gate the`,
|
|
1104
|
-
` //
|
|
2073
|
+
` // property $effect on the upgrade so the first application isn't dropped`,
|
|
1105
2074
|
` // (props set on a not-yet-upgraded element are lost on upgrade).`,
|
|
1106
|
-
` let defined = false;`,
|
|
2075
|
+
` let defined = $state(false);`,
|
|
1107
2076
|
` onMount(async () => { await customElements.whenDefined('kai-chat'); defined = true; });`,
|
|
1108
2077
|
...sourcesEl,
|
|
1109
2078
|
...sampleMessagesInit,
|
|
1110
|
-
` let loading
|
|
2079
|
+
` let loading = $state(false);`,
|
|
1111
2080
|
` const suggestions: string[] = ${jsArray(suggestions)};`,
|
|
2081
|
+
...modelInit,
|
|
2082
|
+
...cardsInit,
|
|
2083
|
+
...toolsLines,
|
|
2084
|
+
...runnerLines,
|
|
1112
2085
|
` // suggestions/messages are JS PROPERTIES (arrays/objects can't be attributes)`,
|
|
1113
|
-
|
|
2086
|
+
...(ctx.emitCards ? CARD_PROP_COMMENT.map((l) => ` ${l}`) : []),
|
|
2087
|
+
` $effect(() => {`,
|
|
2088
|
+
` if (chatEl && defined) { chatEl.messages = messages; chatEl.loading = loading; chatEl.suggestions = suggestions;${cardPropEffect} }`,
|
|
2089
|
+
` });`,
|
|
1114
2090
|
...sourcesReactive,
|
|
1115
2091
|
``,
|
|
1116
2092
|
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
|
|
@@ -1138,19 +2114,23 @@ function renderSvelte(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1138
2114
|
* Scaffold command (official TanStack CLI, non-interactive):
|
|
1139
2115
|
* npx @tanstack/cli@latest create <app-name> --framework react --no-git --package-manager npm -y
|
|
1140
2116
|
*
|
|
1141
|
-
* After scaffolding,
|
|
1142
|
-
*
|
|
2117
|
+
* After scaffolding, install what block (3) lists — `@kitn.ai/ui` plus whatever
|
|
2118
|
+
* the chosen integration's route imports, which this renderer cannot know and so
|
|
2119
|
+
* no longer guesses — then drop this file into `src/routes/chat.tsx`. Start the
|
|
2120
|
+
* dev server with `npm run dev` (port 3000).
|
|
1143
2121
|
* Build: `npm run build`; preview: `npm run preview` (or `node dist/server/server.js`).
|
|
1144
2122
|
* Note: `npm start` does NOT exist in TanStack Start projects — use `npm run dev` / `npm run preview`.
|
|
1145
2123
|
*
|
|
1146
|
-
* Backend:
|
|
1147
|
-
*
|
|
1148
|
-
*
|
|
1149
|
-
*
|
|
1150
|
-
*
|
|
2124
|
+
* Backend: the emitted front end fetches `/api/chat`, so it needs a SERVER ROUTE,
|
|
2125
|
+
* not a server function. In TanStack Start that is a file route carrying a
|
|
2126
|
+
* `server.handlers` block — `src/routes/api/chat.ts` with
|
|
2127
|
+
* `createFileRoute('/api/chat')({ server: { handlers: { POST } } })`. Block (2)
|
|
2128
|
+
* emits exactly that. This comment used to name `createServerFn` and
|
|
2129
|
+
* `src/server/chat.ts`, which can never answer a `fetch('/api/chat')`: the two
|
|
2130
|
+
* halves of the same scaffold contradicted each other.
|
|
1151
2131
|
*/
|
|
1152
|
-
function renderTanstackStart(
|
|
1153
|
-
const { p, emptyHint, suggestions, isMock, defaultModel } = ctx;
|
|
2132
|
+
function renderTanstackStart(components: readonly string[], ctx: RenderCtx): string {
|
|
2133
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
1154
2134
|
|
|
1155
2135
|
// TanStack Start is React — reuse all the React composition logic:
|
|
1156
2136
|
// same ChatMessage type, same state/loading/suggestions, same mock stream body,
|
|
@@ -1161,25 +2141,25 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1161
2141
|
// 4. No `import '@kitn.ai/ui/elements'` needed as a top-level import (same as next's dynamic approach
|
|
1162
2142
|
// is not needed here — the library is SSR-import-safe, but we include elements for safety)
|
|
1163
2143
|
|
|
1164
|
-
const hasEmbedded =
|
|
1165
|
-
const workspace = isWorkspace(
|
|
2144
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
2145
|
+
const workspace = isWorkspace(components);
|
|
1166
2146
|
|
|
1167
|
-
const renderableTags =
|
|
2147
|
+
const renderableTags = components.filter((t) => !MESSAGE_EMBEDDED_TAGS.has(t));
|
|
1168
2148
|
const importTags = workspace
|
|
1169
2149
|
? [...new Set([...renderableTags.filter((t) => t !== 'kai-resizable'), 'kai-resizable', 'kai-resizable-item'])]
|
|
1170
2150
|
: renderableTags;
|
|
1171
2151
|
const wrapperNames = importTags.map(toPascalCase);
|
|
1172
2152
|
const importList = wrapperNames.join(', ');
|
|
1173
2153
|
|
|
1174
|
-
const standaloneCompanionTags =
|
|
2154
|
+
const standaloneCompanionTags = components.filter(
|
|
1175
2155
|
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
1176
2156
|
);
|
|
1177
2157
|
|
|
1178
2158
|
const companionJsxLines: string[] = [];
|
|
1179
2159
|
if (hasEmbedded) {
|
|
1180
2160
|
companionJsxLines.push(
|
|
1181
|
-
` {/* kai-tool / kai-reasoning render inside the thread
|
|
1182
|
-
`
|
|
2161
|
+
` {/* kai-tool / kai-reasoning render inside the thread, as parts on the`,
|
|
2162
|
+
` assistant message the stream in onSubmit builds. */}`,
|
|
1183
2163
|
);
|
|
1184
2164
|
}
|
|
1185
2165
|
for (const t of standaloneCompanionTags) {
|
|
@@ -1195,16 +2175,18 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1195
2175
|
}
|
|
1196
2176
|
const companions = companionJsxLines.join('\n');
|
|
1197
2177
|
|
|
1198
|
-
const
|
|
2178
|
+
const mockInit = isMock ? mockResponderInit() : [];
|
|
1199
2179
|
|
|
1200
|
-
|
|
1201
|
-
|
|
1202
|
-
|
|
1203
|
-
|
|
1204
|
-
|
|
1205
|
-
|
|
1206
|
-
|
|
1207
|
-
|
|
2180
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
2181
|
+
const sampleMessagesInit = [
|
|
2182
|
+
...(hasEmbedded
|
|
2183
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
2184
|
+
`const sampleMessages: ChatMessage[] = [${literal}];`,
|
|
2185
|
+
`const [messages, setMessages] = useState<ChatMessage[]>(sampleMessages);`,
|
|
2186
|
+
])
|
|
2187
|
+
: []),
|
|
2188
|
+
` const [messages, setMessages] = useState<ChatMessage[]>([]);`,
|
|
2189
|
+
].join('\n');
|
|
1208
2190
|
|
|
1209
2191
|
const sampleSourcesInit =
|
|
1210
2192
|
standaloneCompanionTags.includes('kai-sources')
|
|
@@ -1218,67 +2200,48 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1218
2200
|
: '';
|
|
1219
2201
|
|
|
1220
2202
|
const modelInit = defaultModel
|
|
1221
|
-
? ` // SCAF-8: change this
|
|
2203
|
+
? ` // SCAF-8: change this model id to another id THIS PROVIDER accepts.\n const model = '${defaultModel}';`
|
|
1222
2204
|
: '';
|
|
1223
|
-
|
|
1224
|
-
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
const
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
|
|
1235
|
-
|
|
1236
|
-
:
|
|
1237
|
-
|
|
1238
|
-
|
|
1239
|
-
|
|
1240
|
-
|
|
1241
|
-
|
|
1242
|
-
|
|
1243
|
-
|
|
1244
|
-
|
|
1245
|
-
|
|
1246
|
-
|
|
1247
|
-
|
|
1248
|
-
|
|
1249
|
-
` const reader = res.body!.getReader();`,
|
|
1250
|
-
` const decoder = new TextDecoder();`,
|
|
1251
|
-
` let buffer = '', answer = '';`,
|
|
1252
|
-
` while (true) {`,
|
|
1253
|
-
` const { value: chunk, done } = await reader.read();`,
|
|
1254
|
-
` if (done) break;`,
|
|
1255
|
-
` buffer += decoder.decode(chunk, { stream: true });`,
|
|
1256
|
-
` const lines = buffer.split('\\n');`,
|
|
1257
|
-
` buffer = lines.pop()!;`,
|
|
1258
|
-
` for (const line of lines) {`,
|
|
1259
|
-
` const s = line.trim();`,
|
|
1260
|
-
` if (!s.startsWith('data:')) continue;`,
|
|
1261
|
-
` const payload = s.slice(5).trim();`,
|
|
1262
|
-
` if (payload === '[DONE]') continue;`,
|
|
1263
|
-
` try {`,
|
|
1264
|
-
` const delta = JSON.parse(payload).choices?.[0]?.delta?.content;`,
|
|
1265
|
-
` if (!delta) continue;`,
|
|
1266
|
-
` answer += delta;`,
|
|
1267
|
-
` setMessages((ms) => ms.map((m) => (m.id === assistantId ? { ...m, content: answer } : m)));`,
|
|
1268
|
-
` } catch { /* skip keep-alives */ }`,
|
|
1269
|
-
` }`,
|
|
1270
|
-
` }`,
|
|
1271
|
-
` setLoading(false);`,
|
|
1272
|
-
].join('\n');
|
|
2205
|
+
|
|
2206
|
+
const toolsInit = emitTools ? toolSchemaLines(' ', ctx.cardProvider).join('\n') : '';
|
|
2207
|
+
const toolRunner = emitToolLoop ? toolRunnerLines(' ', true).join('\n') : '';
|
|
2208
|
+
// Module scope: static data, so it is not rebuilt on every render (see renderJsx).
|
|
2209
|
+
const cardsInit = ctx.emitCards ? cardRegistryLines('') : [];
|
|
2210
|
+
const cardProps = (pad: string): string[] =>
|
|
2211
|
+
ctx.emitCards
|
|
2212
|
+
? [`${pad}cardTypes={cards.tags}`, `${pad}cardSchemas={cards.validationSchemas}`]
|
|
2213
|
+
: [];
|
|
2214
|
+
const cardPropsNote = (pad: string): string[] =>
|
|
2215
|
+
ctx.emitCards ? jsxComment(CARD_PROP_COMMENT, pad) : [];
|
|
2216
|
+
|
|
2217
|
+
const onSubmitBody = realStreamBody({
|
|
2218
|
+
pad: ' ',
|
|
2219
|
+
read: 'messages',
|
|
2220
|
+
commitSet: (expr) => `setMessages(${expr});`,
|
|
2221
|
+
// useState's setter IS a SetMessages: both are (updater) => void.
|
|
2222
|
+
setterAdapter: 'setMessages',
|
|
2223
|
+
setLoading: (v) => `setLoading(${v});`,
|
|
2224
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2225
|
+
strictRoles: true,
|
|
2226
|
+
toolLoop: emitToolLoop,
|
|
2227
|
+
cards: ctx.emitCards,
|
|
2228
|
+
thread: REACT_THREAD,
|
|
2229
|
+
mock: isMock,
|
|
2230
|
+
});
|
|
1273
2231
|
|
|
1274
2232
|
// File path guidance for TanStack Start (file-based routing)
|
|
1275
2233
|
const filePathNote = [
|
|
1276
2234
|
`// TanStack Start route file — save as: src/routes/chat.tsx`,
|
|
1277
2235
|
`// Scaffold command: npx @tanstack/cli@latest create <app-name> --framework react --no-git --package-manager npm -y`,
|
|
1278
|
-
|
|
2236
|
+
// NOT "npm install @kitn.ai/ui". This block is emitted for every integration,
|
|
2237
|
+
// so a fixed package list here is wrong for the eight of them whose route
|
|
2238
|
+
// imports something else — and it silently WAS, for langgraph and mastra.
|
|
2239
|
+
// Block (3) builds the line from the integration's own `deps`.
|
|
2240
|
+
`// Then: install the packages block (3) lists.`,
|
|
1279
2241
|
`// Dev: npm run dev (port 3000) Build: npm run build Preview: npm run preview`,
|
|
1280
2242
|
`// Note: there is no 'npm start' script — use 'npm run dev' or 'npm run preview'.`,
|
|
1281
|
-
`// Backend:
|
|
2243
|
+
`// Backend: the fetch below hits /api/chat, so it needs a SERVER ROUTE, not a`,
|
|
2244
|
+
`// server function: src/routes/api/chat.ts with server.handlers.POST — block (2).`,
|
|
1282
2245
|
``,
|
|
1283
2246
|
];
|
|
1284
2247
|
|
|
@@ -1290,12 +2253,21 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1290
2253
|
// Elements registration: the library is SSR-import-safe; top-level import is safe here
|
|
1291
2254
|
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
1292
2255
|
`import { ${importList} } from '@kitn.ai/ui/react'`,
|
|
2256
|
+
...wireImportLines({
|
|
2257
|
+
typed: true,
|
|
2258
|
+
toolLoop: emitToolLoop,
|
|
2259
|
+
setMessagesType: emitToolLoop,
|
|
2260
|
+
cards: ctx.emitCards,
|
|
2261
|
+
cardTools: ctx.cardProvider !== null,
|
|
2262
|
+
mock: isMock,
|
|
2263
|
+
}),
|
|
1293
2264
|
`import '@kitn.ai/ui/theme.tokens.css' // compiled token defaults`,
|
|
1294
2265
|
``,
|
|
1295
|
-
`// ${
|
|
1296
|
-
...(p.altNote
|
|
1297
|
-
|
|
2266
|
+
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
2267
|
+
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
2268
|
+
...mockInit,
|
|
1298
2269
|
``,
|
|
2270
|
+
...cardsInit,
|
|
1299
2271
|
`// ssr: false keeps the Solid-based web component client-only.`,
|
|
1300
2272
|
`// Server HTML for /chat omits <kai-chat> → no hydration mismatch.`,
|
|
1301
2273
|
`export const Route = createFileRoute('/chat')({`,
|
|
@@ -1309,51 +2281,847 @@ function renderTanstackStart(archetype: Archetype, ctx: RenderCtx): string {
|
|
|
1309
2281
|
` const suggestions = ${jsArray(suggestions)};`,
|
|
1310
2282
|
...(sampleSourcesInit ? [sampleSourcesInit] : []),
|
|
1311
2283
|
...(modelInit ? [modelInit] : []),
|
|
2284
|
+
...(toolsInit ? [toolsInit] : []),
|
|
2285
|
+
...(toolRunner ? [toolRunner] : []),
|
|
1312
2286
|
``,
|
|
1313
2287
|
` async function onSubmit(e: CustomEvent<{ value: string }>) {`,
|
|
1314
2288
|
onSubmitBody,
|
|
1315
2289
|
` }`,
|
|
1316
2290
|
``,
|
|
1317
2291
|
` return (`,
|
|
1318
|
-
` <main style={{ ${jsxStyle(p.style)} }}>`,
|
|
1319
|
-
...(workspace
|
|
1320
|
-
? [
|
|
1321
|
-
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
1322
|
-
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
1323
|
-
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
1324
|
-
` <ResizableItem size="40%" min="240px">`,
|
|
1325
|
-
|
|
1326
|
-
`
|
|
1327
|
-
`
|
|
1328
|
-
`
|
|
1329
|
-
`
|
|
1330
|
-
`
|
|
1331
|
-
|
|
1332
|
-
`
|
|
1333
|
-
`
|
|
1334
|
-
`
|
|
1335
|
-
`
|
|
1336
|
-
`
|
|
1337
|
-
`
|
|
1338
|
-
`
|
|
1339
|
-
|
|
1340
|
-
|
|
1341
|
-
|
|
1342
|
-
|
|
1343
|
-
|
|
1344
|
-
|
|
1345
|
-
|
|
1346
|
-
`
|
|
1347
|
-
`
|
|
1348
|
-
`
|
|
1349
|
-
|
|
1350
|
-
|
|
1351
|
-
|
|
2292
|
+
` <main style={{ ${jsxStyle(p.style)} }}>`,
|
|
2293
|
+
...(workspace
|
|
2294
|
+
? [
|
|
2295
|
+
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2296
|
+
` {/* Resizable needs ResizableItem children to render panels. */}`,
|
|
2297
|
+
` <Resizable orientation="horizontal" style={{ display: 'block', width: '100%', height: '100%' }}>`,
|
|
2298
|
+
` <ResizableItem size="40%" min="240px">`,
|
|
2299
|
+
...cardPropsNote(' '),
|
|
2300
|
+
` <Chat`,
|
|
2301
|
+
` messages={messages}`,
|
|
2302
|
+
` loading={loading}`,
|
|
2303
|
+
` suggestions={suggestions}`,
|
|
2304
|
+
` suggestionMode="submit"`,
|
|
2305
|
+
...cardProps(' '),
|
|
2306
|
+
` onSubmit={onSubmit}`,
|
|
2307
|
+
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2308
|
+
` />`,
|
|
2309
|
+
` </ResizableItem>`,
|
|
2310
|
+
` <ResizableItem min="280px">`,
|
|
2311
|
+
` {/* Replace src + files with your real artifact data (files is required: array/object props are never optional attributes on a kai-* element). */}`,
|
|
2312
|
+
` <Artifact src="https://example.com" files={[{ path: 'index.html', url: 'https://example.com' }]} style={{ width: '100%', height: '100%' }} />`,
|
|
2313
|
+
` </ResizableItem>`,
|
|
2314
|
+
` </Resizable>`,
|
|
2315
|
+
// Siblings of the SPLIT — see the same line in the other JSX branch.
|
|
2316
|
+
companions,
|
|
2317
|
+
]
|
|
2318
|
+
: [
|
|
2319
|
+
...cardPropsNote(' '),
|
|
2320
|
+
` <Chat`,
|
|
2321
|
+
` messages={messages}`,
|
|
2322
|
+
` loading={loading}`,
|
|
2323
|
+
` suggestions={suggestions}`,
|
|
2324
|
+
` suggestionMode="submit"`,
|
|
2325
|
+
...cardProps(' '),
|
|
2326
|
+
` onSubmit={onSubmit}`,
|
|
2327
|
+
` style={{ ${jsxStyle(p.chatFill)} }}`,
|
|
2328
|
+
` />`,
|
|
2329
|
+
companions,
|
|
2330
|
+
]),
|
|
2331
|
+
` </main>`,
|
|
2332
|
+
` );`,
|
|
2333
|
+
`}`,
|
|
2334
|
+
]
|
|
2335
|
+
.filter((l) => l !== '')
|
|
2336
|
+
.join('\n');
|
|
2337
|
+
}
|
|
2338
|
+
|
|
2339
|
+
/**
|
|
2340
|
+
* Angular: the `kai-*` custom elements, same as vue/svelte/html — plus the two
|
|
2341
|
+
* things Angular needs and no other framework does.
|
|
2342
|
+
*
|
|
2343
|
+
* 1. `schemas: [CUSTOM_ELEMENTS_SCHEMA]` on the component. Without it the
|
|
2344
|
+
* template compiler REJECTS every unknown tag ("'kai-chat' is not a known
|
|
2345
|
+
* element") and `ng build` fails outright. With it, Angular stamps the tag
|
|
2346
|
+
* and passes the bindings straight through to the DOM.
|
|
2347
|
+
* 2. `[messages]="messages()"` — the square brackets are what make it a DOM
|
|
2348
|
+
* PROPERTY. `messages="…"` would be an ATTRIBUTE, i.e. the string
|
|
2349
|
+
* "[object Object]": arrays and objects only ever reach a custom element as
|
|
2350
|
+
* properties. Same for `[suggestions]` and `[loading]`.
|
|
2351
|
+
*
|
|
2352
|
+
* Two Angular-specific facts the emitted comments carry, because getting either
|
|
2353
|
+
* wrong is a build error rather than a subtle bug:
|
|
2354
|
+
*
|
|
2355
|
+
* · Stylesheets come from `angular.json` -> architect.build.options.styles.
|
|
2356
|
+
* `@angular/build` does not take a TS `import './x.css'`, so the theme
|
|
2357
|
+
* cannot be imported the way every other framework imports it here.
|
|
2358
|
+
* · `$event` on an unknown custom-element event is typed `Event` under
|
|
2359
|
+
* `strictTemplates`, not `CustomEvent`, so the handler takes an `Event` and
|
|
2360
|
+
* narrows inside — exactly what examples/starters/angular does.
|
|
2361
|
+
*
|
|
2362
|
+
* The thread is an Angular signal, which reads back synchronously, so this uses
|
|
2363
|
+
* `accessorThreadBinding` and not React's turn-scoped `thread` copy.
|
|
2364
|
+
*/
|
|
2365
|
+
function renderAngular(components: readonly string[], ctx: RenderCtx): string {
|
|
2366
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
2367
|
+
|
|
2368
|
+
const workspace = isWorkspace(components);
|
|
2369
|
+
const standaloneCompanionTags = components.filter(
|
|
2370
|
+
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
2371
|
+
);
|
|
2372
|
+
const hasEmbedded = components.some((t) => MESSAGE_EMBEDDED_TAGS.has(t));
|
|
2373
|
+
const hasSourcesCompanion = standaloneCompanionTags.includes('kai-sources');
|
|
2374
|
+
|
|
2375
|
+
const companionLines: string[] = [];
|
|
2376
|
+
if (hasEmbedded) {
|
|
2377
|
+
companionLines.push(
|
|
2378
|
+
` <!-- kai-tool / kai-reasoning render INSIDE the thread, as parts on the assistant message the stream builds. -->`,
|
|
2379
|
+
);
|
|
2380
|
+
}
|
|
2381
|
+
for (const t of standaloneCompanionTags) {
|
|
2382
|
+
if (t === 'kai-sources') {
|
|
2383
|
+
companionLines.push(
|
|
2384
|
+
` <!-- Replace sampleSources with your real data. [sources] is a PROPERTY binding: an array can't be an attribute. -->`,
|
|
2385
|
+
` <kai-sources #sources [sources]="sampleSources"></kai-sources>`,
|
|
2386
|
+
);
|
|
2387
|
+
} else {
|
|
2388
|
+
companionLines.push(` <!-- wire data props — see the component_reference MCP tool -->`);
|
|
2389
|
+
companionLines.push(` <${t}></${t}>`);
|
|
2390
|
+
}
|
|
2391
|
+
}
|
|
2392
|
+
|
|
2393
|
+
// Angular signals: `this.messages()` reads, `this.messages.set(next)` writes a
|
|
2394
|
+
// BRAND-NEW array, which is what re-renders <kai-chat>.
|
|
2395
|
+
const read = 'this.messages()';
|
|
2396
|
+
const commit = (value: string) => `this.messages.set(${value});`;
|
|
2397
|
+
const setter = '(fn) => this.messages.set(fn(this.messages()))';
|
|
2398
|
+
|
|
2399
|
+
const onSubmitBody = realStreamBody({
|
|
2400
|
+
pad: ' ',
|
|
2401
|
+
read,
|
|
2402
|
+
commitSet: (expr) => commit(expr),
|
|
2403
|
+
setterAdapter: setter,
|
|
2404
|
+
setLoading: (v) => `this.loading.set(${v});`,
|
|
2405
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2406
|
+
strictRoles: true,
|
|
2407
|
+
toolLoop: emitToolLoop,
|
|
2408
|
+
cards: ctx.emitCards,
|
|
2409
|
+
thread: accessorThreadBinding(read, commit, setter),
|
|
2410
|
+
mock: isMock,
|
|
2411
|
+
});
|
|
2412
|
+
|
|
2413
|
+
// Module scope, exactly like vue: a class can hold neither a bare `const` nor a
|
|
2414
|
+
// `function` declaration, and the methods below close over all three.
|
|
2415
|
+
const modelInit = defaultModel
|
|
2416
|
+
? [
|
|
2417
|
+
`// SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
|
|
2418
|
+
`const model = '${defaultModel}';`,
|
|
2419
|
+
``,
|
|
2420
|
+
]
|
|
2421
|
+
: [];
|
|
2422
|
+
const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
|
|
2423
|
+
const toolsLines = emitTools ? [...toolSchemaLines('', ctx.cardProvider), ``] : [];
|
|
2424
|
+
const runnerLines = emitToolLoop ? [...toolRunnerLines('', true), ``] : [];
|
|
2425
|
+
// Set in the SAME afterNextRender re-application as messages/loading/suggestions,
|
|
2426
|
+
// not as a `[cardTypes]` template binding. Two reasons, and the second is the
|
|
2427
|
+
// load-bearing one: a registry is static, so a binding buys nothing; and an
|
|
2428
|
+
// Angular template can only read CLASS members, so a template binding would need
|
|
2429
|
+
// the module-scope `cards` restated as a field. The re-apply is also the point
|
|
2430
|
+
// that matters — a property set on a not-yet-upgraded custom element is dropped.
|
|
2431
|
+
const cardPropAssign = ctx.emitCards
|
|
2432
|
+
? [
|
|
2433
|
+
...CARD_PROP_COMMENT.map((l) => ` ${l}`),
|
|
2434
|
+
` cardTypes: cards.tags,`,
|
|
2435
|
+
` cardSchemas: cards.validationSchemas,`,
|
|
2436
|
+
]
|
|
2437
|
+
: [];
|
|
2438
|
+
|
|
2439
|
+
// SCAF-9: no fabricated seed — see SAMPLE_AGENTIC_MESSAGE.
|
|
2440
|
+
const sampleSeed = [
|
|
2441
|
+
...(hasEmbedded
|
|
2442
|
+
? sampleSeedComment(isMock, ' ', (literal) => [
|
|
2443
|
+
`readonly messages = signal<ChatMessage[]>([${literal}]);`,
|
|
2444
|
+
])
|
|
2445
|
+
: []),
|
|
2446
|
+
` readonly messages = signal<ChatMessage[]>([]);`,
|
|
2447
|
+
];
|
|
2448
|
+
|
|
2449
|
+
const sourcesField = hasSourcesCompanion
|
|
2450
|
+
? [
|
|
2451
|
+
` // Replace sampleSources with your real source data.`,
|
|
2452
|
+
` readonly sampleSources = [`,
|
|
2453
|
+
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
|
|
2454
|
+
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
|
|
2455
|
+
` ];`,
|
|
2456
|
+
` private readonly sourcesEl = viewChild.required<ElementRef<KaiSourcesElement>>('sources');`,
|
|
2457
|
+
]
|
|
2458
|
+
: [];
|
|
2459
|
+
const sourcesReapply = hasSourcesCompanion
|
|
2460
|
+
? [` Object.assign(this.sourcesEl().nativeElement, { sources: this.sampleSources });`]
|
|
2461
|
+
: [];
|
|
2462
|
+
|
|
2463
|
+
const chatTag = (pad: string) =>
|
|
2464
|
+
[
|
|
2465
|
+
`<kai-chat`,
|
|
2466
|
+
` #chat`,
|
|
2467
|
+
` [messages]="messages()"`,
|
|
2468
|
+
` [loading]="loading()"`,
|
|
2469
|
+
` [suggestions]="suggestions"`,
|
|
2470
|
+
` suggestion-mode="submit"`,
|
|
2471
|
+
` style="${p.chatFill}"`,
|
|
2472
|
+
` (kai-submit)="onSubmit($event)"`,
|
|
2473
|
+
`></kai-chat>`,
|
|
2474
|
+
].map((l) => `${pad}${l}`);
|
|
2475
|
+
|
|
2476
|
+
const templateBody = workspace
|
|
2477
|
+
? [
|
|
2478
|
+
` <!-- SCAF-14: workspace split — chat pane left, artifact preview right. -->`,
|
|
2479
|
+
` <!-- kai-resizable needs kai-resizable-item children to render panels. -->`,
|
|
2480
|
+
` <kai-resizable orientation="horizontal" style="display:block;width:100%;height:100%">`,
|
|
2481
|
+
` <kai-resizable-item size="40%" min="240px">`,
|
|
2482
|
+
...chatTag(' '),
|
|
2483
|
+
` </kai-resizable-item>`,
|
|
2484
|
+
` <kai-resizable-item min="280px">`,
|
|
2485
|
+
` <!-- Replace src with your artifact URL or set [files] for multi-file preview. -->`,
|
|
2486
|
+
` <kai-artifact src="https://example.com" style="width:100%;height:100%"></kai-artifact>`,
|
|
2487
|
+
` </kai-resizable-item>`,
|
|
2488
|
+
` </kai-resizable>`,
|
|
2489
|
+
// Siblings of the SPLIT — see the same line in renderJsx.
|
|
2490
|
+
...companionLines,
|
|
2491
|
+
]
|
|
2492
|
+
: [...chatTag(' '), ...companionLines];
|
|
2493
|
+
|
|
2494
|
+
// KaiSourcesElement is imported only when a kai-sources companion is really
|
|
2495
|
+
// declared: an always-on import is unused on every other archetype, and a stock
|
|
2496
|
+
// Angular tsconfig turns on the checks that make that a build error.
|
|
2497
|
+
const elementTypes = hasSourcesCompanion ? 'KaiChatElement, KaiSourcesElement' : 'KaiChatElement';
|
|
2498
|
+
|
|
2499
|
+
return [
|
|
2500
|
+
`// Angular standalone component — save as: src/app/chat.component.ts`,
|
|
2501
|
+
`// Render it: put <app-chat /> in your root template and add ChatComponent to`,
|
|
2502
|
+
`// that component's \`imports: [...]\`.`,
|
|
2503
|
+
`//`,
|
|
2504
|
+
`// SETUP, once — Angular takes stylesheets from angular.json, NOT from a TS`,
|
|
2505
|
+
`// \`import './x.css'\`, so the theme cannot be imported here the way it is in`,
|
|
2506
|
+
`// every other framework. Add it to architect.build.options.styles:`,
|
|
2507
|
+
`// "styles": ["node_modules/@kitn.ai/ui/dist/theme.tokens.css", "src/styles.css"]`,
|
|
2508
|
+
`// (@kitn.ai/ui/theme.tokens.css is the compiled token file; theme.css is`,
|
|
2509
|
+
`// Tailwind source and is only for apps that compile Tailwind themselves.)`,
|
|
2510
|
+
`import { CUSTOM_ELEMENTS_SCHEMA, Component, ElementRef, afterNextRender, signal, viewChild } from '@angular/core';`,
|
|
2511
|
+
`import '@kitn.ai/ui/elements'; // registers <kai-*> — required, must come first`,
|
|
2512
|
+
`import type { ${elementTypes} } from '@kitn.ai/ui/elements';`,
|
|
2513
|
+
...wireImportLines({
|
|
2514
|
+
typed: true,
|
|
2515
|
+
toolLoop: emitToolLoop,
|
|
2516
|
+
cards: ctx.emitCards,
|
|
2517
|
+
cardTools: ctx.cardProvider !== null,
|
|
2518
|
+
mock: isMock,
|
|
2519
|
+
}),
|
|
2520
|
+
``,
|
|
2521
|
+
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
2522
|
+
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
2523
|
+
...(isMock ? mockResponderInit() : []),
|
|
2524
|
+
``,
|
|
2525
|
+
...modelInit,
|
|
2526
|
+
...cardsInit,
|
|
2527
|
+
...toolsLines,
|
|
2528
|
+
...runnerLines,
|
|
2529
|
+
`@Component({`,
|
|
2530
|
+
` selector: 'app-chat',`,
|
|
2531
|
+
` // REQUIRED. Angular does not know the kai-* tags; without this the template`,
|
|
2532
|
+
` // compiler fails with "'kai-chat' is not a known element". With it, Angular`,
|
|
2533
|
+
` // stamps the tag and passes [prop] bindings through to the DOM.`,
|
|
2534
|
+
` schemas: [CUSTOM_ELEMENTS_SCHEMA],`,
|
|
2535
|
+
` template: \``,
|
|
2536
|
+
` <div style="${p.style}">`,
|
|
2537
|
+
...templateBody,
|
|
2538
|
+
` </div>`,
|
|
2539
|
+
` \`,`,
|
|
2540
|
+
`})`,
|
|
2541
|
+
`export class ChatComponent {`,
|
|
2542
|
+
` // Every write assigns a NEW array. That reference change is what re-renders`,
|
|
2543
|
+
` // <kai-chat> — mutating the array in place does nothing.`,
|
|
2544
|
+
...sampleSeed,
|
|
2545
|
+
` readonly loading = signal(false);`,
|
|
2546
|
+
` readonly suggestions = ${jsArray(suggestions)};`,
|
|
2547
|
+
...sourcesField,
|
|
2548
|
+
` private readonly chatEl = viewChild.required<ElementRef<KaiChatElement>>('chat');`,
|
|
2549
|
+
``,
|
|
2550
|
+
` constructor() {`,
|
|
2551
|
+
` // SCAF-15: kai-* register via an async dynamic import (SSR-safety), so the`,
|
|
2552
|
+
` // element may not be upgraded when Angular first applies the bindings above —`,
|
|
2553
|
+
` // and a property set on a not-yet-upgraded element is dropped on upgrade.`,
|
|
2554
|
+
` // Re-apply once it is defined so the initial messages/suggestions/loading stick.`,
|
|
2555
|
+
` // afterNextRender never runs on the server, which is also what keeps this`,
|
|
2556
|
+
` // \`customElements\` reference safe under SSR/prerender.`,
|
|
2557
|
+
` afterNextRender(async () => {`,
|
|
2558
|
+
` await customElements.whenDefined('kai-chat');`,
|
|
2559
|
+
` Object.assign(this.chatEl().nativeElement, {`,
|
|
2560
|
+
` messages: this.messages(),`,
|
|
2561
|
+
` loading: this.loading(),`,
|
|
2562
|
+
` suggestions: this.suggestions,`,
|
|
2563
|
+
...cardPropAssign,
|
|
2564
|
+
` });`,
|
|
2565
|
+
...sourcesReapply,
|
|
2566
|
+
` });`,
|
|
2567
|
+
` }`,
|
|
2568
|
+
``,
|
|
2569
|
+
` // \`Event\`, not \`CustomEvent\`: under strictTemplates Angular types \`$event\` on`,
|
|
2570
|
+
` // an unknown custom-element event as a plain Event, so the narrowing happens`,
|
|
2571
|
+
` // here rather than in the signature.`,
|
|
2572
|
+
` async onSubmit(event: Event) {`,
|
|
2573
|
+
` const e = event as CustomEvent<{ value: string }>;`,
|
|
2574
|
+
onSubmitBody,
|
|
2575
|
+
` }`,
|
|
2576
|
+
`}`,
|
|
2577
|
+
]
|
|
2578
|
+
// Collapse runs of blanks rather than dropping every blank (what the JSX/vue
|
|
2579
|
+
// renderers do): this target emits one long file — imports, module-scope
|
|
2580
|
+
// consts, the decorator, the class — and with no separators at all it reads
|
|
2581
|
+
// as a wall.
|
|
2582
|
+
.filter((l, i, arr) => l !== '' || (i > 0 && i < arr.length - 1 && arr[i - 1] !== ''))
|
|
2583
|
+
.join('\n');
|
|
2584
|
+
}
|
|
2585
|
+
|
|
2586
|
+
/**
|
|
2587
|
+
* SolidJS — the one target that does NOT render `kai-*`.
|
|
2588
|
+
*
|
|
2589
|
+
* The kit is AUTHORED in Solid, so a Solid consumer imports the real components
|
|
2590
|
+
* from the `@kitn.ai/ui` root entry and gets real props and real fine-grained
|
|
2591
|
+
* reactivity. Routing it through the custom-element facade would ship the Solid
|
|
2592
|
+
* runtime twice and put a reactive-context boundary in the middle of the app for
|
|
2593
|
+
* no gain.
|
|
2594
|
+
*
|
|
2595
|
+
* THE GRANULARITY GAP. `<kai-chat>` is a coarse preset: one tag renders the
|
|
2596
|
+
* thread, the parts, the scroll behaviour, the suggestions and the composer. The
|
|
2597
|
+
* Solid layer is fine-grained — ChatContainer / Message / MessageContent /
|
|
2598
|
+
* PromptInput / … — so the same capability has to be composed here. Two
|
|
2599
|
+
* consequences the emitted code has to handle, and both are silent failures if
|
|
2600
|
+
* it does not:
|
|
2601
|
+
*
|
|
2602
|
+
* 1. The thread renders exactly what `renderPart` renders, so `renderPart` has
|
|
2603
|
+
* to carry a branch for EVERY variant of `MessagePart`. `<kai-chat>` owns
|
|
2604
|
+
* that switch internally; a hand-composed tree that only handles `text`
|
|
2605
|
+
* shows nothing when a reasoning or tool part streams in. A missing branch
|
|
2606
|
+
* is not a type error — a `<Switch>` with fewer `<Match>`es compiles fine —
|
|
2607
|
+
* it is a part that arrives in the data and renders nothing, which is how
|
|
2608
|
+
* `card` and `source` were silently dropped here while this very paragraph
|
|
2609
|
+
* claimed otherwise. `solidPartCoverageCheck` in
|
|
2610
|
+
* scripts/verify-scaffold-compiles.mjs now derives the variant list from
|
|
2611
|
+
* the `MessagePart` union and fails the build when one has no branch. The
|
|
2612
|
+
* part renderer is emitted for EVERY archetype, not just the agentic one —
|
|
2613
|
+
* the stream can produce any variant regardless of which components the
|
|
2614
|
+
* archetype names.
|
|
2615
|
+
* 2. The components are Tailwind-v4 SOURCE, not shadow-encapsulated CSS. The
|
|
2616
|
+
* class names have to survive into the consumer's stylesheet, which is what
|
|
2617
|
+
* the `@source` line in the setup note is for. Without it Tailwind scans
|
|
2618
|
+
* only `src/`, strips every kit class as unused, and the app renders
|
|
2619
|
+
* unstyled — see examples/starters/solid/README.md.
|
|
2620
|
+
*
|
|
2621
|
+
* Solid signals read back synchronously, so this uses `accessorThreadBinding`
|
|
2622
|
+
* and not React's turn-scoped `thread` copy.
|
|
2623
|
+
*/
|
|
2624
|
+
function renderSolid(components: readonly string[], ctx: RenderCtx): string {
|
|
2625
|
+
const { p, emptyHint, suggestions, isMock, defaultModel, emitTools, emitToolLoop } = ctx;
|
|
2626
|
+
|
|
2627
|
+
const workspace = isWorkspace(components);
|
|
2628
|
+
const standaloneCompanionTags = components.filter(
|
|
2629
|
+
(t) => t !== 'kai-chat' && !MESSAGE_EMBEDDED_TAGS.has(t) && !WORKSPACE_STRUCTURAL_TAGS.has(t),
|
|
2630
|
+
);
|
|
2631
|
+
const hasSources = standaloneCompanionTags.includes('kai-sources');
|
|
2632
|
+
const hasVoice = standaloneCompanionTags.includes('kai-voice-input');
|
|
2633
|
+
|
|
2634
|
+
// Every name here is referenced by the emitted tree below — `noUnusedLocals` is
|
|
2635
|
+
// on in a stock Solid app (`npm run build` runs `tsc` first), so an extra one
|
|
2636
|
+
// is a build failure.
|
|
2637
|
+
//
|
|
2638
|
+
// The unconditional block is what `renderPart` needs, and it is unconditional
|
|
2639
|
+
// because `renderPart` is: the stream can produce any MessagePart variant no
|
|
2640
|
+
// matter which components the archetype named. `Source*` in particular is NOT
|
|
2641
|
+
// gated on `hasSources` — that flag only decides whether the archetype also
|
|
2642
|
+
// gets a standalone sample source list, while `source` PARTS arrive from any
|
|
2643
|
+
// model that cites.
|
|
2644
|
+
const componentImports = [
|
|
2645
|
+
'Attachment',
|
|
2646
|
+
'AttachmentInfo',
|
|
2647
|
+
'AttachmentPreview',
|
|
2648
|
+
'Attachments',
|
|
2649
|
+
'Button',
|
|
2650
|
+
'CardRenderer',
|
|
2651
|
+
'ChatConfig',
|
|
2652
|
+
'ChatContainer',
|
|
2653
|
+
'ChatContainerContent',
|
|
2654
|
+
'ChatContainerScrollAnchor',
|
|
2655
|
+
'Message',
|
|
2656
|
+
'MessageContent',
|
|
2657
|
+
'PromptInput',
|
|
2658
|
+
'PromptInputActions',
|
|
2659
|
+
'PromptInputTextarea',
|
|
2660
|
+
'PromptSuggestion',
|
|
2661
|
+
'Reasoning',
|
|
2662
|
+
'ReasoningContent',
|
|
2663
|
+
'ReasoningTrigger',
|
|
2664
|
+
'ScrollButton',
|
|
2665
|
+
'Source',
|
|
2666
|
+
'SourceContent',
|
|
2667
|
+
'SourceList',
|
|
2668
|
+
'SourceTrigger',
|
|
2669
|
+
'Tool',
|
|
2670
|
+
...(workspace ? ['Artifact', 'ResizableHandle', 'ResizablePanel', 'ResizablePanelGroup'] : []),
|
|
2671
|
+
...(hasVoice ? ['VoiceInput'] : []),
|
|
2672
|
+
].sort();
|
|
2673
|
+
|
|
2674
|
+
// Solid signals: `messages()` reads, `setMessages(next)` writes a NEW array.
|
|
2675
|
+
const read = 'messages()';
|
|
2676
|
+
const commit = (value: string) => `setMessages(${value});`;
|
|
2677
|
+
// Solid's own Setter is overloaded (and treats a function argument as an
|
|
2678
|
+
// updater), so it is wrapped rather than handed over directly — the wrapper is
|
|
2679
|
+
// exactly the `SetMessages` shape createAssistantStream wants.
|
|
2680
|
+
const setter = '(fn) => setMessages((prev) => fn(prev))';
|
|
2681
|
+
|
|
2682
|
+
const onSubmitBody = realStreamBody({
|
|
2683
|
+
pad: ' ',
|
|
2684
|
+
read,
|
|
2685
|
+
commitSet: (expr) => commit(expr),
|
|
2686
|
+
setterAdapter: setter,
|
|
2687
|
+
setLoading: (v) => `setLoading(${v});`,
|
|
2688
|
+
bodyPayload: realBodyPayload({ defaultModel, tools: emitTools }),
|
|
2689
|
+
strictRoles: true,
|
|
2690
|
+
toolLoop: emitToolLoop,
|
|
2691
|
+
cards: ctx.emitCards,
|
|
2692
|
+
thread: accessorThreadBinding(read, commit, setter),
|
|
2693
|
+
valueSource: 'input()',
|
|
2694
|
+
afterValue: [`setInput('');`],
|
|
2695
|
+
mock: isMock,
|
|
2696
|
+
});
|
|
2697
|
+
|
|
2698
|
+
const modelInit = defaultModel
|
|
2699
|
+
? [
|
|
2700
|
+
` // SCAF-8: change this model id to another id THIS PROVIDER accepts.`,
|
|
2701
|
+
` const model = '${defaultModel}';`,
|
|
2702
|
+
]
|
|
2703
|
+
: [];
|
|
2704
|
+
// MODULE scope in this target and not merely by convention: `renderPart` is a
|
|
2705
|
+
// top-level function (it has to be — <Index> calls it per row), and it is what
|
|
2706
|
+
// hands the registry to <CardRenderer>. A const inside App() would be invisible
|
|
2707
|
+
// to it.
|
|
2708
|
+
const cardsInit = ctx.emitCards ? [...cardRegistryLines(''), ``] : [];
|
|
2709
|
+
const toolsLines = emitTools ? toolSchemaLines(' ', ctx.cardProvider) : [];
|
|
2710
|
+
const runnerLines = emitToolLoop ? toolRunnerLines(' ', true) : [];
|
|
2711
|
+
// Solid renders the components directly, so the props go on <CardRenderer>
|
|
2712
|
+
// itself rather than on <kai-chat>: `types` is the Solid-component half of
|
|
2713
|
+
// `cardTypes` (a component, not a tag name) and `schemas` is `cardSchemas`.
|
|
2714
|
+
const cardRendererProps = ctx.emitCards
|
|
2715
|
+
? [
|
|
2716
|
+
` types={cards.components}`,
|
|
2717
|
+
` schemas={cards.validationSchemas}`,
|
|
2718
|
+
]
|
|
2719
|
+
: [];
|
|
2720
|
+
|
|
2721
|
+
const sourcesInit = hasSources
|
|
2722
|
+
? [
|
|
2723
|
+
` // Replace sampleSources with your real source data.`,
|
|
2724
|
+
` const sampleSources = [`,
|
|
2725
|
+
` { href: 'https://example.com/doc1', title: 'Getting started', description: 'Overview of the product.' },`,
|
|
2726
|
+
` { href: 'https://example.com/doc2', title: 'API reference', description: 'Full API documentation.' },`,
|
|
2727
|
+
` ];`,
|
|
2728
|
+
]
|
|
2729
|
+
: [];
|
|
2730
|
+
|
|
2731
|
+
// The scrolling thread + composer. Reused verbatim inside the workspace split.
|
|
2732
|
+
// `fill` is how this column claims its height: the placement's own chatFill at
|
|
2733
|
+
// the top level, 100% of the pane inside a resizable panel.
|
|
2734
|
+
const surface = (pad: string, fill: string): string[] =>
|
|
2735
|
+
[
|
|
2736
|
+
`<div class="flex w-full flex-col" style={{ ${fill} }}>`,
|
|
2737
|
+
` <div class="relative min-h-0 flex-1">`,
|
|
2738
|
+
// ChatContainer IS the scroll container (it carries overflow-y-auto and the
|
|
2739
|
+
// stick-to-bottom ref), so the wrapper above must not also scroll.
|
|
2740
|
+
` <ChatContainer class="h-full">`,
|
|
2741
|
+
` <ChatContainerContent class="px-5 pt-4 pb-12">`,
|
|
2742
|
+
` {/* Keyed by message id (see messageKeys), so a delta updates the row`,
|
|
2743
|
+
` instead of replacing it. */}`,
|
|
2744
|
+
` <For each={messageKeys()}>`,
|
|
2745
|
+
` {(_id, i) => (`,
|
|
2746
|
+
` // The row reads its message through <For>'s index accessor, never`,
|
|
2747
|
+
` // through a captured value: it outlives the delta that replaced its`,
|
|
2748
|
+
` // object, so every read below has to go through m(). <Show> supplies`,
|
|
2749
|
+
` // the non-null accessor and covers the frame where a removal has`,
|
|
2750
|
+
` // shortened the array.`,
|
|
2751
|
+
` <Show when={messages()[i()]}>`,
|
|
2752
|
+
` {(m) => (`,
|
|
2753
|
+
` <Message class={\`mx-auto flex w-full max-w-3xl flex-col gap-2 px-6 \${m().role === 'user' ? 'items-end' : 'items-start'}\`}>`,
|
|
2754
|
+
` {/* <Index>, not <For>, INSIDE a message. The state folds only`,
|
|
2755
|
+
` ever append a part or patch one in place — they never`,
|
|
2756
|
+
` reorder — so a part's POSITION is a stable identity, and`,
|
|
2757
|
+
` <Index> hands each row its part as a SIGNAL so the row stays`,
|
|
2758
|
+
` mounted while its content grows. (Position is not a valid`,
|
|
2759
|
+
` key for the message list above it, which the host splices.) */}`,
|
|
2760
|
+
` {/* renderPart also takes the part's INDEX and the array it came`,
|
|
2761
|
+
` from: consecutive source/file parts render as one row, so a`,
|
|
2762
|
+
` branch has to be able to see its neighbours. \`() => m().parts\``,
|
|
2763
|
+
` stays an accessor for the same reason \`part\` does. */}`,
|
|
2764
|
+
` <Index each={m().parts}>`,
|
|
2765
|
+
` {(part, pi) => renderPart(part, pi, () => m().parts, m().role)}`,
|
|
2766
|
+
` </Index>`,
|
|
2767
|
+
` </Message>`,
|
|
2768
|
+
` )}`,
|
|
2769
|
+
` </Show>`,
|
|
2770
|
+
` )}`,
|
|
2771
|
+
` </For>`,
|
|
2772
|
+
...(hasSources
|
|
2773
|
+
? [
|
|
2774
|
+
` {/* Replace sampleSources with your real data. */}`,
|
|
2775
|
+
` <SourceList class="mx-auto w-full max-w-3xl px-6 pt-2">`,
|
|
2776
|
+
` <For each={sampleSources}>`,
|
|
2777
|
+
` {(s) => (`,
|
|
2778
|
+
` <Source href={s.href}>`,
|
|
2779
|
+
` <SourceTrigger showFavicon />`,
|
|
2780
|
+
` <SourceContent title={s.title} description={s.description} />`,
|
|
2781
|
+
` </Source>`,
|
|
2782
|
+
` )}`,
|
|
2783
|
+
` </For>`,
|
|
2784
|
+
` </SourceList>`,
|
|
2785
|
+
]
|
|
2786
|
+
: []),
|
|
2787
|
+
` <ChatContainerScrollAnchor />`,
|
|
2788
|
+
` </ChatContainerContent>`,
|
|
2789
|
+
` <div class="absolute bottom-4 left-1/2 flex w-full max-w-3xl -translate-x-1/2 justify-center px-5">`,
|
|
2790
|
+
` <ScrollButton />`,
|
|
2791
|
+
` </div>`,
|
|
2792
|
+
` </ChatContainer>`,
|
|
2793
|
+
` </div>`,
|
|
2794
|
+
``,
|
|
2795
|
+
` <div class="shrink-0 px-3 pb-3 md:px-5 md:pb-5">`,
|
|
2796
|
+
` <div class="mx-auto max-w-3xl">`,
|
|
2797
|
+
` {/* Starter prompts, shown only while the thread is empty. */}`,
|
|
2798
|
+
` <Show when={messages().length === 0}>`,
|
|
2799
|
+
` <div class="flex flex-wrap gap-2 pb-3">`,
|
|
2800
|
+
` <For each={suggestions}>`,
|
|
2801
|
+
` {(s) => <PromptSuggestion onClick={() => { setInput(s); void onSubmit(); }}>{s}</PromptSuggestion>}`,
|
|
2802
|
+
` </For>`,
|
|
2803
|
+
` </div>`,
|
|
2804
|
+
` </Show>`,
|
|
2805
|
+
` <PromptInput value={input()} onValueChange={setInput} onSubmit={onSubmit} isLoading={loading()}>`,
|
|
2806
|
+
` <div class="flex flex-col">`,
|
|
2807
|
+
` <PromptInputTextarea placeholder="Send a message…" class="min-h-[44px] pt-3 pl-4" />`,
|
|
2808
|
+
` <PromptInputActions class="mt-2 flex w-full items-center justify-end gap-2 px-3 pb-3">`,
|
|
2809
|
+
...(hasVoice
|
|
2810
|
+
? [
|
|
2811
|
+
` {/* hasTranscribe={false} uses the browser's native SpeechRecognition.`,
|
|
2812
|
+
` Point onTranscribe at your speech-to-text endpoint to record + upload instead. */}`,
|
|
2813
|
+
` <VoiceInput`,
|
|
2814
|
+
` hasTranscribe={false}`,
|
|
2815
|
+
` onTranscribe={async (audio) => {`,
|
|
2816
|
+
` const res = await fetch('/api/transcribe', { method: 'POST', body: audio });`,
|
|
2817
|
+
` const data = (await res.json()) as { text: string };`,
|
|
2818
|
+
` return data.text;`,
|
|
2819
|
+
` }}`,
|
|
2820
|
+
` onTranscription={(text) => setInput(text)}`,
|
|
2821
|
+
` />`,
|
|
2822
|
+
]
|
|
2823
|
+
: []),
|
|
2824
|
+
` <Button size="sm" class="rounded-full" disabled={!input().trim() || loading()} onClick={onSubmit}>`,
|
|
2825
|
+
` Send`,
|
|
2826
|
+
` </Button>`,
|
|
2827
|
+
` </PromptInputActions>`,
|
|
2828
|
+
` </div>`,
|
|
2829
|
+
` </PromptInput>`,
|
|
2830
|
+
` </div>`,
|
|
2831
|
+
` </div>`,
|
|
2832
|
+
`</div>`,
|
|
2833
|
+
].map((l) => (l === '' ? l : `${pad}${l}`));
|
|
2834
|
+
|
|
2835
|
+
const tree = workspace
|
|
2836
|
+
? [
|
|
2837
|
+
` {/* SCAF-14: workspace split — chat pane left, artifact preview right. */}`,
|
|
2838
|
+
` <ResizablePanelGroup orientation="horizontal" class="h-full w-full">`,
|
|
2839
|
+
` <ResizablePanel defaultSize={40} minSize="240px">`,
|
|
2840
|
+
...surface(' ', `'height': '100%', 'min-height': '0'`),
|
|
2841
|
+
` </ResizablePanel>`,
|
|
2842
|
+
` <ResizableHandle handle="grip" />`,
|
|
2843
|
+
` <ResizablePanel minSize="280px">`,
|
|
2844
|
+
` {/* Replace src + files with your real artifact data. */}`,
|
|
2845
|
+
` <Artifact`,
|
|
2846
|
+
` src="https://example.com"`,
|
|
2847
|
+
` files={[{ path: 'index.html', url: 'https://example.com' }]}`,
|
|
2848
|
+
` class="h-full w-full"`,
|
|
2849
|
+
` />`,
|
|
2850
|
+
` </ResizablePanel>`,
|
|
2851
|
+
` </ResizablePanelGroup>`,
|
|
2852
|
+
]
|
|
2853
|
+
: surface(' ', solidStyle(p.chatFill));
|
|
2854
|
+
|
|
2855
|
+
return [
|
|
2856
|
+
`// SolidJS + Vite — save as: src/App.tsx`,
|
|
2857
|
+
`//`,
|
|
2858
|
+
`// This target does NOT use the <kai-*> custom elements, and that is deliberate:`,
|
|
2859
|
+
`// the kit is AUTHORED in SolidJS, so a Solid app renders the real components`,
|
|
2860
|
+
`// with real props and real fine-grained reactivity. Going through the`,
|
|
2861
|
+
`// web-component facade would ship the Solid runtime twice and cross a reactive`,
|
|
2862
|
+
`// boundary for nothing.`,
|
|
2863
|
+
`//`,
|
|
2864
|
+
`// SETUP, once. These components are Tailwind-v4 SOURCE (not shadow-encapsulated),`,
|
|
2865
|
+
`// so their class names have to reach YOUR stylesheet:`,
|
|
2866
|
+
`// npm i -D tailwindcss @tailwindcss/vite vite-plugin-solid`,
|
|
2867
|
+
`// vite.config.ts -> plugins: [solid(), tailwindcss()]`,
|
|
2868
|
+
`// src/styles.css -> @import "tailwindcss";`,
|
|
2869
|
+
`// @import "@kitn.ai/ui/theme.css"; /* --color-* tokens */`,
|
|
2870
|
+
`// @source "../node_modules/@kitn.ai/ui"; /* scan the kit for classes */`,
|
|
2871
|
+
`// The @source line is NOT optional: without it Tailwind scans only src/, strips`,
|
|
2872
|
+
`// every kit utility class as unused, and the whole UI renders unstyled.`,
|
|
2873
|
+
`// (theme.css here, not theme.tokens.css: this app compiles Tailwind itself.)`,
|
|
2874
|
+
`import { For, Index, Match, Show, Switch, createMemo, createSignal } from 'solid-js';`,
|
|
2875
|
+
`import {`,
|
|
2876
|
+
...componentImports.map((n) => ` ${n},`),
|
|
2877
|
+
`} from '@kitn.ai/ui';`,
|
|
2878
|
+
`// The kit's own types, from the same entry the components come from.`,
|
|
2879
|
+
`import type { ChatMessage, MessagePart, MessageSource } from '@kitn.ai/ui';`,
|
|
2880
|
+
...wireImportLines({
|
|
2881
|
+
typed: false,
|
|
2882
|
+
toolLoop: emitToolLoop,
|
|
2883
|
+
cards: ctx.emitCards,
|
|
2884
|
+
cardTools: ctx.cardProvider !== null,
|
|
2885
|
+
mock: isMock,
|
|
2886
|
+
}),
|
|
2887
|
+
``,
|
|
2888
|
+
`// ${ctx.label} — ${p.note}. empty-state hint: ${emptyHint}`,
|
|
2889
|
+
...(p.altNote ?? []).map((l) => `// ${l}`),
|
|
2890
|
+
``,
|
|
2891
|
+
...(isMock ? [...mockResponderInit(), ``] : []),
|
|
2892
|
+
...cardsInit,
|
|
2893
|
+
`// Narrow a part to ONE variant, or false. One read, one cast — and the JSX`,
|
|
2894
|
+
`// below re-runs it on every delta, which is what keeps a growing text or`,
|
|
2895
|
+
`// reasoning block updating while its row stays put.`,
|
|
2896
|
+
`function partAs<T extends MessagePart['type']>(`,
|
|
2897
|
+
` part: MessagePart,`,
|
|
2898
|
+
` type: T,`,
|
|
2899
|
+
`): Extract<MessagePart, { type: T }> | false {`,
|
|
2900
|
+
` return part.type === type ? (part as Extract<MessagePart, { type: T }>) : false;`,
|
|
2901
|
+
`}`,
|
|
2902
|
+
``,
|
|
2903
|
+
`// Two part kinds render as a RUN rather than one row each: the N citations one`,
|
|
2904
|
+
`// search produced are ONE wrapped row, and consecutive attachments share one`,
|
|
2905
|
+
`// attachment row. This returns the whole run when \`index\` is where it STARTS`,
|
|
2906
|
+
`// and false at every later member of it, so a run renders exactly once and`,
|
|
2907
|
+
`// stays exactly where it sat in \`parts\`.`,
|
|
2908
|
+
`function runAt<T extends MessagePart['type']>(`,
|
|
2909
|
+
` parts: MessagePart[],`,
|
|
2910
|
+
` index: number,`,
|
|
2911
|
+
` type: T,`,
|
|
2912
|
+
`): Extract<MessagePart, { type: T }>[] | false {`,
|
|
2913
|
+
` if (index > 0 && parts[index - 1].type === type) return false;`,
|
|
2914
|
+
` const run: Extract<MessagePart, { type: T }>[] = [];`,
|
|
2915
|
+
` for (let i = index; i < parts.length && parts[i].type === type; i += 1) {`,
|
|
2916
|
+
` run.push(parts[i] as Extract<MessagePart, { type: T }>);`,
|
|
2917
|
+
` }`,
|
|
2918
|
+
` return run.length > 0 && run;`,
|
|
2919
|
+
`}`,
|
|
2920
|
+
``,
|
|
2921
|
+
`// The citation chip's label: the model's own number when it numbered its`,
|
|
2922
|
+
`// citations, otherwise nothing so SourceTrigger falls back to the domain. A`,
|
|
2923
|
+
`// source with no url has no domain to fall back to, so its title stands in`,
|
|
2924
|
+
`// rather than rendering an empty chip.`,
|
|
2925
|
+
`function citationLabel(s: MessageSource): string | number | undefined {`,
|
|
2926
|
+
` if (s.index !== undefined) return s.index;`,
|
|
2927
|
+
` if (s.url) return undefined;`,
|
|
2928
|
+
` return s.title || 'Source';`,
|
|
2929
|
+
`}`,
|
|
2930
|
+
``,
|
|
2931
|
+
`// EVERY variant of MessagePart, in thread order: text, reasoning, tool, card,`,
|
|
2932
|
+
`// source, file. <kai-chat> owns that switch internally for the element-based`,
|
|
2933
|
+
`// targets; composing from the SolidJS layer moves it here, so the thread`,
|
|
2934
|
+
`// renders exactly what this function renders and a branch that is missing is a`,
|
|
2935
|
+
`// part that arrives in the data and renders NOTHING. The compiler cannot help`,
|
|
2936
|
+
`// — a <Switch> with fewer <Match>es is valid code — so the kit's`,
|
|
2937
|
+
`// verify-scaffold-compiles gate derives the variant list from the MessagePart`,
|
|
2938
|
+
`// union and fails when one has no branch here.`,
|
|
2939
|
+
`//`,
|
|
2940
|
+
`// Branch for branch this is what the kit's own message renderer does (the one`,
|
|
2941
|
+
`// behind <kai-chat>), runs included: consecutive \`source\` parts collapse into`,
|
|
2942
|
+
`// ONE citation row and consecutive \`file\` parts into one attachment row, and`,
|
|
2943
|
+
`// the citation row is a SIBLING of the text bubble rather than a child of it.`,
|
|
2944
|
+
`// The one thing deliberately NOT copied over is the bubble's own padding and`,
|
|
2945
|
+
`// radius — styling, and yours to change.`,
|
|
2946
|
+
...(ctx.emitCards
|
|
2947
|
+
? [
|
|
2948
|
+
`// The card branch DOES carry the registry through: \`types\` is the`,
|
|
2949
|
+
`// Solid-component half of <kai-chat>'s \`cardTypes\` (a component, not a tag`,
|
|
2950
|
+
`// name) and \`schemas\` is its \`cardSchemas\`.`,
|
|
2951
|
+
]
|
|
2952
|
+
: []),
|
|
2953
|
+
`//`,
|
|
2954
|
+
`// \`part\` is an ACCESSOR, not a value. The <Index> that calls this keeps each`,
|
|
2955
|
+
`// row MOUNTED while the message streams, so the row outlives the delta that`,
|
|
2956
|
+
`// replaced its part object: every read has to go through part() for the new`,
|
|
2957
|
+
`// content to reach the DOM. Capturing part() once freezes the row at its first`,
|
|
2958
|
+
`// delta — a panel that stays open but stops updating. \`index\` and \`parts\` are`,
|
|
2959
|
+
`// what let the source/file branches see their neighbours.`,
|
|
2960
|
+
`function renderPart(`,
|
|
2961
|
+
` part: () => MessagePart,`,
|
|
2962
|
+
` index: number,`,
|
|
2963
|
+
` parts: () => MessagePart[],`,
|
|
2964
|
+
` role: ChatMessage['role'],`,
|
|
2965
|
+
`) {`,
|
|
2966
|
+
` return (`,
|
|
2967
|
+
` // Nothing reaches this fallback today: MessagePart is a closed union and`,
|
|
2968
|
+
` // every variant of it has a branch below. It is where a variant ADDED to`,
|
|
2969
|
+
` // the union after this file was generated would land.`,
|
|
2970
|
+
` <Switch fallback={null}>`,
|
|
2971
|
+
` <Match when={partAs(part(), 'text')}>`,
|
|
2972
|
+
` {(p) =>`,
|
|
2973
|
+
` role === 'user' ? (`,
|
|
2974
|
+
` <MessageContent class="bg-muted text-primary max-w-[85%] rounded-3xl px-5 py-2.5">`,
|
|
2975
|
+
` {p().text}`,
|
|
2976
|
+
` </MessageContent>`,
|
|
2977
|
+
` ) : (`,
|
|
2978
|
+
` <MessageContent markdown class="text-foreground prose flex-1 rounded-lg bg-transparent p-0">`,
|
|
2979
|
+
` {p().text}`,
|
|
2980
|
+
` </MessageContent>`,
|
|
2981
|
+
` )`,
|
|
2982
|
+
` }`,
|
|
2983
|
+
` </Match>`,
|
|
2984
|
+
` <Match when={partAs(part(), 'reasoning')}>`,
|
|
2985
|
+
` {(p) => (`,
|
|
2986
|
+
` // A reasoning part with NO text is a round-trip carrier, not something`,
|
|
2987
|
+
` // to show: Anthropic's redacted_thinking blocks carry an opaque blob`,
|
|
2988
|
+
` // with no readable text, and the encoder needs them kept in \`parts\`,`,
|
|
2989
|
+
` // in order. Render one and you get a blank disclosure.`,
|
|
2990
|
+
` <Show when={p().text !== ''}>`,
|
|
2991
|
+
` <Reasoning class="w-full">`,
|
|
2992
|
+
` <ReasoningTrigger>{p().label ?? 'Reasoning'}</ReasoningTrigger>`,
|
|
2993
|
+
` <ReasoningContent markdown>{p().text}</ReasoningContent>`,
|
|
2994
|
+
` </Reasoning>`,
|
|
2995
|
+
` </Show>`,
|
|
2996
|
+
` )}`,
|
|
2997
|
+
` </Match>`,
|
|
2998
|
+
` <Match when={partAs(part(), 'tool')}>`,
|
|
2999
|
+
` {(p) => <Tool toolPart={p().tool} />}`,
|
|
3000
|
+
` </Match>`,
|
|
3001
|
+
` <Match when={partAs(part(), 'card')}>`,
|
|
3002
|
+
` {(p) => (`,
|
|
3003
|
+
` // Generative-UI cards. An envelope whose type is not registered gets`,
|
|
3004
|
+
` // the built-in fallback card, so an unknown card is visible rather`,
|
|
3005
|
+
` // than absent; register your own components on the card registry.`,
|
|
3006
|
+
...(ctx.emitCards
|
|
3007
|
+
? [
|
|
3008
|
+
` <CardRenderer`,
|
|
3009
|
+
` envelope={p().envelope}`,
|
|
3010
|
+
...cardRendererProps,
|
|
3011
|
+
` />`,
|
|
3012
|
+
]
|
|
3013
|
+
: [` <CardRenderer envelope={p().envelope} />`]),
|
|
3014
|
+
` )}`,
|
|
3015
|
+
` </Match>`,
|
|
3016
|
+
` <Match when={partAs(part(), 'source')}>`,
|
|
3017
|
+
` {/* ONE citation row per RUN, and a SIBLING of the bubble rather than a`,
|
|
3018
|
+
` child of it: a citation rendered inside the prose is indistinguishable`,
|
|
3019
|
+
` from a link the model typed itself. part="citations" is the same`,
|
|
3020
|
+
` ::part name the kai-chat element exposes, so a stylesheet written`,
|
|
3021
|
+
` against one target works against the other. */}`,
|
|
3022
|
+
` <Show when={runAt(parts(), index, 'source')}>`,
|
|
3023
|
+
` {(run) => (`,
|
|
3024
|
+
` <SourceList part="citations" class={role === 'user' ? 'justify-end' : undefined}>`,
|
|
3025
|
+
` {/* Reference-keyed <For> is right HERE: the run's part objects are`,
|
|
3026
|
+
` carried over untouched by the state folds. */}`,
|
|
3027
|
+
` <For each={run()}>`,
|
|
3028
|
+
` {(sp) => (`,
|
|
3029
|
+
` <Source href={sp.source.url}>`,
|
|
3030
|
+
` <SourceTrigger label={citationLabel(sp.source)} />`,
|
|
3031
|
+
` <SourceContent`,
|
|
3032
|
+
` title={sp.source.title ?? sp.source.url ?? 'Source'}`,
|
|
3033
|
+
` description={sp.source.snippet ?? ''}`,
|
|
3034
|
+
` />`,
|
|
3035
|
+
` </Source>`,
|
|
3036
|
+
` )}`,
|
|
3037
|
+
` </For>`,
|
|
3038
|
+
` </SourceList>`,
|
|
3039
|
+
` )}`,
|
|
3040
|
+
` </Show>`,
|
|
3041
|
+
` </Match>`,
|
|
3042
|
+
` <Match when={partAs(part(), 'file')}>`,
|
|
3043
|
+
` {/* Same run-collapse as citations: consecutive attachments share one`,
|
|
3044
|
+
` row instead of each opening its own. */}`,
|
|
3045
|
+
` <Show when={runAt(parts(), index, 'file')}>`,
|
|
3046
|
+
` {(run) => (`,
|
|
3047
|
+
` <Attachments variant="inline" class={role === 'user' ? 'mb-2 justify-end' : 'mb-2'}>`,
|
|
3048
|
+
` <For each={run()}>`,
|
|
3049
|
+
` {(fp) => (`,
|
|
3050
|
+
` <Attachment data={fp.attachment}>`,
|
|
3051
|
+
` <AttachmentPreview />`,
|
|
3052
|
+
` <AttachmentInfo />`,
|
|
3053
|
+
` </Attachment>`,
|
|
3054
|
+
` )}`,
|
|
3055
|
+
` </For>`,
|
|
3056
|
+
` </Attachments>`,
|
|
3057
|
+
` )}`,
|
|
3058
|
+
` </Show>`,
|
|
3059
|
+
` </Match>`,
|
|
3060
|
+
` </Switch>`,
|
|
3061
|
+
` );`,
|
|
3062
|
+
`}`,
|
|
3063
|
+
``,
|
|
3064
|
+
`export default function App() {`,
|
|
3065
|
+
` // Each write assigns a NEW array, and createAssistantStream rebuilds the`,
|
|
3066
|
+
` // streaming message as a NEW OBJECT on every delta: a new reference IS the`,
|
|
3067
|
+
` // re-render signal. Which is exactly why the thread below is keyed by message`,
|
|
3068
|
+
` // id rather than by the message objects — see messageKeys.`,
|
|
3069
|
+
` const [messages, setMessages] = createSignal<ChatMessage[]>([]);`,
|
|
3070
|
+
` const [loading, setLoading] = createSignal(false);`,
|
|
3071
|
+
` // PromptInput is CONTROLLED here, so this signal — not a kai-submit event — is`,
|
|
3072
|
+
` // where the submitted text comes from.`,
|
|
3073
|
+
` const [input, setInput] = createSignal('');`,
|
|
3074
|
+
` // THE MESSAGE LIST'S KEY. <For> is REFERENCE-keyed, so keying it on the`,
|
|
3075
|
+
` // message objects makes every streaming delta look like an entirely new list`,
|
|
3076
|
+
` // and tears the whole row down — taking with it everything the user did`,
|
|
3077
|
+
` // inside it (expanding a tool or reasoning panel mid-stream would silently do`,
|
|
3078
|
+
` // nothing: the disclosure opens and the next token discards it). Ids are`,
|
|
3079
|
+
` // stable across the object churn, so <For> over this array diffs by string`,
|
|
3080
|
+
` // value, no row moves, and the content updates through accessors instead.`,
|
|
3081
|
+
` const messageKeys = createMemo(() => messages().map((m) => m.id));`,
|
|
3082
|
+
` const suggestions = ${jsArray(suggestions)};`,
|
|
3083
|
+
...sourcesInit,
|
|
3084
|
+
...modelInit,
|
|
3085
|
+
...toolsLines,
|
|
3086
|
+
...runnerLines,
|
|
3087
|
+
``,
|
|
3088
|
+
` async function onSubmit() {`,
|
|
3089
|
+
onSubmitBody,
|
|
3090
|
+
` }`,
|
|
3091
|
+
``,
|
|
3092
|
+
` return (`,
|
|
3093
|
+
` // ChatConfig carries prose size / code theme / portal target to every`,
|
|
3094
|
+
` // component below it. Mount it once, at the top.`,
|
|
3095
|
+
` <ChatConfig>`,
|
|
3096
|
+
` <div style={{ ${solidStyle(p.style)} }}>`,
|
|
3097
|
+
...tree,
|
|
3098
|
+
` </div>`,
|
|
3099
|
+
` </ChatConfig>`,
|
|
1352
3100
|
` );`,
|
|
1353
3101
|
`}`,
|
|
1354
|
-
]
|
|
1355
|
-
|
|
1356
|
-
|
|
3102
|
+
].join('\n');
|
|
3103
|
+
}
|
|
3104
|
+
|
|
3105
|
+
/**
|
|
3106
|
+
* Translate an inline CSS string into a SOLID style-object entry list.
|
|
3107
|
+
*
|
|
3108
|
+
* Not `jsxStyle`. Solid's `style` prop is typed from csstype's HYPHENATED
|
|
3109
|
+
* property set (`'flex-direction'`) and applied with `style.setProperty(key,
|
|
3110
|
+
* value)`; React's is camelCased (`flexDirection`). Handing Solid React's shape
|
|
3111
|
+
* is a TS2561 on every cell AND, if it ever got past the compiler, a declaration
|
|
3112
|
+
* that silently does nothing at runtime. Keys are quoted because
|
|
3113
|
+
* `flex-direction` is not a bare identifier.
|
|
3114
|
+
*/
|
|
3115
|
+
function solidStyle(style: string): string {
|
|
3116
|
+
return style
|
|
3117
|
+
.split(';')
|
|
3118
|
+
.map((d) => d.trim())
|
|
3119
|
+
.filter(Boolean)
|
|
3120
|
+
.map((d) => {
|
|
3121
|
+
const [prop, ...rest] = d.split(':');
|
|
3122
|
+
return `'${prop.trim()}': '${rest.join(':').trim()}'`;
|
|
3123
|
+
})
|
|
3124
|
+
.join(', ');
|
|
1357
3125
|
}
|
|
1358
3126
|
|
|
1359
3127
|
/** Translate an inline CSS string into JSX style-object entries. */
|
|
@@ -1370,31 +3138,127 @@ function jsxStyle(style: string): string {
|
|
|
1370
3138
|
.join(', ');
|
|
1371
3139
|
}
|
|
1372
3140
|
|
|
1373
|
-
|
|
1374
|
-
|
|
1375
|
-
|
|
1376
|
-
|
|
1377
|
-
|
|
1378
|
-
|
|
1379
|
-
|
|
1380
|
-
|
|
1381
|
-
)
|
|
1382
|
-
|
|
3141
|
+
/**
|
|
3142
|
+
* A human label for a components list, for the emitted banner comment.
|
|
3143
|
+
*
|
|
3144
|
+
* Derived, never passed in — see `RenderCtx.label`. `kai-chat` is dropped because
|
|
3145
|
+
* every surface has it, so naming it in every banner says nothing; a surface that
|
|
3146
|
+
* is ONLY the chat says so.
|
|
3147
|
+
*/
|
|
3148
|
+
function surfaceLabel(components: readonly string[]): string {
|
|
3149
|
+
const rest = components.filter((c) => c !== 'kai-chat');
|
|
3150
|
+
if (rest.length === 0) return 'chat';
|
|
3151
|
+
return `chat + ${rest.map((c) => c.replace(/^kai-/, '')).join(' + ')}`;
|
|
3152
|
+
}
|
|
3153
|
+
|
|
3154
|
+
/** What `renderSurface` needs to emit one chat surface. */
|
|
3155
|
+
export interface SurfaceRequest {
|
|
3156
|
+
/** html | react | next | vue | svelte | angular | solid | tanstack-start */
|
|
3157
|
+
framework: string;
|
|
3158
|
+
/**
|
|
3159
|
+
* The `kai-*` components this surface composes — THE AXIS, not an archetype id.
|
|
3160
|
+
*
|
|
3161
|
+
* A list rather than a preset name is the whole point of this function. The six
|
|
3162
|
+
* archetypes are six points in this space and the CLI's feature multi-select
|
|
3163
|
+
* reaches the rest of it, so a renderer keyed on a preset id could only ever
|
|
3164
|
+
* emit what someone had already thought to name. `kai-chat` is expected to be
|
|
3165
|
+
* present; everything else is a capability.
|
|
3166
|
+
*/
|
|
3167
|
+
components: readonly string[];
|
|
3168
|
+
/** The backend this surface talks to. `mock` takes the no-backend branch. */
|
|
3169
|
+
integration: Integration;
|
|
3170
|
+
/** full-page | side | docked-widget | inline. Defaults to full-page. */
|
|
3171
|
+
placement?: string;
|
|
3172
|
+
/** Starter prompts for the empty thread. Defaults to `DEFAULT_SUGGESTIONS`. */
|
|
3173
|
+
suggestions?: string[];
|
|
3174
|
+
/** Audience hint — tweaks the empty-state comment only. */
|
|
3175
|
+
audience?: string;
|
|
3176
|
+
}
|
|
3177
|
+
|
|
3178
|
+
/**
|
|
3179
|
+
* THE ONE RENDERER. Emits the front-end block for a chat surface.
|
|
3180
|
+
*
|
|
3181
|
+
* Shared by the `kai` MCP (via `compose`, which wraps it in the backend route and
|
|
3182
|
+
* the run note) and, by design, by `create-kai`. There is deliberately no second
|
|
3183
|
+
* entry point keyed on an archetype: `compose` resolves a preset to its
|
|
3184
|
+
* `components` and calls this, so a preset is DATA over this function rather than
|
|
3185
|
+
* a parallel path through it. `assertPresetsAreData` in
|
|
3186
|
+
* scripts/verify-scaffold-compiles.mjs holds that open by requiring the two
|
|
3187
|
+
* request shapes to emit byte-identical surfaces.
|
|
3188
|
+
*
|
|
3189
|
+
* It takes the `integration` rather than the six booleans it derives from it,
|
|
3190
|
+
* because those derivations ARE the contract between the surface and the wire —
|
|
3191
|
+
* whether a tools array is forwarded, whether there is a backend to POST a second
|
|
3192
|
+
* round to, which envelope a card tool takes — and a caller that computed them
|
|
3193
|
+
* itself would be free to compute them differently. `create-kai` gets them right
|
|
3194
|
+
* by not being asked.
|
|
3195
|
+
*/
|
|
3196
|
+
export function renderSurface(req: SurfaceRequest): string {
|
|
3197
|
+
const { framework, components, integration } = req;
|
|
3198
|
+
const placement = req.placement || 'full-page';
|
|
3199
|
+
const suggestions = req.suggestions?.length ? req.suggestions : DEFAULT_SUGGESTIONS;
|
|
3200
|
+
const emptyHint = req.audience
|
|
3201
|
+
? `tuned for ${req.audience} — keep the empty state and tone audience-appropriate`
|
|
3202
|
+
: 'add an empty-state prompt that fits your product';
|
|
3203
|
+
|
|
3204
|
+
const isMock = integration.id === 'mock';
|
|
3205
|
+
// SCAF-8: compute the default model only for non-mock integrations that forward model.
|
|
3206
|
+
const defaultModel = isMock ? undefined : defaultModelFor(integration);
|
|
3207
|
+
const emitTools = !isMock && emitsToolSchemas(components, integration);
|
|
3208
|
+
// The loop needs a backend to POST the second round to; `mock` has none.
|
|
3209
|
+
// It does NOT require `emitTools`: an integration that builds its tools
|
|
3210
|
+
// server-side (langgraph, mastra, pi) still streams tool calls back, and the
|
|
3211
|
+
// loop that answers them is the same loop.
|
|
3212
|
+
const emitToolLoop = !isMock && hasToolPanel(components);
|
|
3213
|
+
// Cards need a MODEL to ask for one, so `mock` (which streams a canned reply in
|
|
3214
|
+
// the browser and never sees a tool call) is out for the same reason the loop is.
|
|
3215
|
+
const emitCards = !isMock && bearsCards(components);
|
|
3216
|
+
// Only shape card tools when the array is really forwarded AND we know which
|
|
3217
|
+
// envelope this route's provider takes. Either half missing means the registry
|
|
3218
|
+
// is still wired to the client (cardTypes/cardSchemas) while the model is not
|
|
3219
|
+
// offered a card tool — which is the honest state for langgraph/mastra/pi, whose
|
|
3220
|
+
// routes own their tool list server-side.
|
|
3221
|
+
// `assertCardToolFormat`, not `cardToolProviderFor`: reaching here with both
|
|
3222
|
+
// flags true means a tools array IS going into the request body, so an
|
|
3223
|
+
// undeclared envelope is a scaffold that silently ships no card tool. Fail
|
|
3224
|
+
// loudly instead. (`cardEmitPlan` keeps returning null for the same case on
|
|
3225
|
+
// purpose — it is a planning function the guards read, and its job is to let
|
|
3226
|
+
// them report the gap in their own words rather than blow up first.)
|
|
3227
|
+
const cardProvider = emitCards && emitTools ? assertCardToolFormat(integration) : null;
|
|
3228
|
+
|
|
3229
|
+
const ctx: RenderCtx = {
|
|
3230
|
+
p: placementStyle(placement),
|
|
3231
|
+
emptyHint,
|
|
3232
|
+
suggestions,
|
|
3233
|
+
label: surfaceLabel(components),
|
|
3234
|
+
isMock,
|
|
3235
|
+
defaultModel,
|
|
3236
|
+
emitTools,
|
|
3237
|
+
emitToolLoop,
|
|
3238
|
+
emitCards,
|
|
3239
|
+
cardProvider,
|
|
3240
|
+
};
|
|
1383
3241
|
switch (framework) {
|
|
1384
3242
|
case 'react':
|
|
1385
3243
|
case 'next':
|
|
1386
|
-
return renderJsx(
|
|
3244
|
+
return renderJsx(components, ctx, framework);
|
|
1387
3245
|
case 'vue':
|
|
1388
|
-
return renderVue(
|
|
3246
|
+
return renderVue(components, ctx);
|
|
1389
3247
|
case 'svelte':
|
|
1390
|
-
return renderSvelte(
|
|
3248
|
+
return renderSvelte(components, ctx);
|
|
3249
|
+
case 'angular':
|
|
3250
|
+
return renderAngular(components, ctx);
|
|
3251
|
+
case 'solid':
|
|
3252
|
+
return renderSolid(components, ctx);
|
|
1391
3253
|
case 'tanstack-start':
|
|
1392
|
-
return renderTanstackStart(
|
|
3254
|
+
return renderTanstackStart(components, ctx);
|
|
1393
3255
|
case 'html':
|
|
1394
3256
|
default:
|
|
1395
3257
|
// html, and any backend-only framework (fastapi/express/worker) gets the
|
|
1396
|
-
// framework-agnostic web-components surface.
|
|
1397
|
-
|
|
3258
|
+
// framework-agnostic web-components surface. The Vite/tsc setup note
|
|
3259
|
+
// (SCAF-19) only applies to the actual `html` target — the backend-only
|
|
3260
|
+
// frameworks aren't paired with a `tsc && vite build` script.
|
|
3261
|
+
return renderHtml(components, ctx, framework === 'html');
|
|
1398
3262
|
}
|
|
1399
3263
|
}
|
|
1400
3264
|
|
|
@@ -1413,9 +3277,489 @@ const RUNTIME_LABEL: Record<string, string> = {
|
|
|
1413
3277
|
worker: 'Cloudflare Worker',
|
|
1414
3278
|
fastapi: 'FastAPI (Python)',
|
|
1415
3279
|
html: 'browser-direct (no server route)',
|
|
1416
|
-
'tanstack-start': 'TanStack Start server
|
|
3280
|
+
'tanstack-start': 'TanStack Start server route',
|
|
3281
|
+
angular: 'Angular SSR server (Express, src/server.ts)',
|
|
3282
|
+
solid: 'Vite dev-server middleware (Node)',
|
|
3283
|
+
};
|
|
3284
|
+
|
|
3285
|
+
/** How the emitted framework reads in a warning: "will NOT run in ___". */
|
|
3286
|
+
const FRAMEWORK_LABEL: Record<string, string> = {
|
|
3287
|
+
html: 'a static HTML page',
|
|
3288
|
+
react: 'a Vite React SPA',
|
|
3289
|
+
next: 'a Next.js app',
|
|
3290
|
+
vue: 'a Vite Vue SPA',
|
|
3291
|
+
svelte: 'a SvelteKit app',
|
|
3292
|
+
angular: 'an Angular app',
|
|
3293
|
+
solid: 'a Vite SolidJS SPA',
|
|
3294
|
+
'tanstack-start': 'a TanStack Start app',
|
|
3295
|
+
express: 'an Express server',
|
|
3296
|
+
worker: 'a Cloudflare Worker',
|
|
3297
|
+
fastapi: 'a FastAPI service',
|
|
3298
|
+
};
|
|
3299
|
+
|
|
3300
|
+
/**
|
|
3301
|
+
* Why the emitted route cannot be dropped into THIS framework, one line each.
|
|
3302
|
+
*
|
|
3303
|
+
* The warning used to be gated on `framework === 'react'`, so the two targets
|
|
3304
|
+
* with the worst failure modes got nothing: svelte compiled a Next.js handler
|
|
3305
|
+
* and threw `req.json is not a function` on the first submit, and html was
|
|
3306
|
+
* handed a server snippet with no server anywhere to put it in.
|
|
3307
|
+
*/
|
|
3308
|
+
const CANNOT_HOST_NOTE: Record<string, string> = {
|
|
3309
|
+
html: 'a static page has no server at all — run this route on a separate server (framework: "express" | "worker" | "next") and either proxy /api/chat to it or point the fetch at its absolute URL (CORS applies).',
|
|
3310
|
+
react: 'a Vite React SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or use framework: "next" | "tanstack-start".',
|
|
3311
|
+
vue: 'a Vite Vue SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or use framework: "next".',
|
|
3312
|
+
svelte: 'SvelteKit routes live at src/routes/api/chat/+server.ts and are called as POST(event) — `request` is a FIELD on that event, so a handler written to take a bare Request typechecks here and then throws "req.json is not a function" on the first submit.',
|
|
3313
|
+
angular: "Angular's server route lives in src/server.ts — the Express app `ng add @angular/ssr` generates — and has to be registered BEFORE the Angular catch-all `app.use(...)` that renders the app, or the renderer answers /api/chat with HTML. A file exporting POST is never called.",
|
|
3314
|
+
solid: 'a Vite SolidJS SPA has no /api routes — add a dev-server middleware (Vite: server.middlewares in a plugin), proxy /api/chat to a separate server, or run the handler on a real server (framework: "express" | "worker" | "next").',
|
|
3315
|
+
'tanstack-start': "TanStack Start routes the FILE: it needs createFileRoute('/api/chat')({ server: { handlers: { POST } } }) in src/routes/api/chat.ts. A bare `export async function POST` is never called.",
|
|
3316
|
+
next: 'Next.js needs the handler exported as POST from app/api/chat/route.ts.',
|
|
3317
|
+
express: 'Express hands the handler (req, res) — it does not take a Request or return a Response, so the code needs bridging.',
|
|
3318
|
+
worker: 'a Worker exports `default { fetch(request) }` and reads secrets off `env`, not process.env.',
|
|
3319
|
+
fastapi: 'FastAPI is Python — this route is TypeScript.',
|
|
3320
|
+
};
|
|
3321
|
+
|
|
3322
|
+
// ── the portable route: one web-standard handler, wrapped per framework ───────
|
|
3323
|
+
|
|
3324
|
+
/**
|
|
3325
|
+
* How a framework DECLARES a route around the portable `chatHandler`.
|
|
3326
|
+
*
|
|
3327
|
+
* The body of a chat route is the same everywhere: read the request JSON, call
|
|
3328
|
+
* the provider, stream the response back. Only the declaration differs. That is
|
|
3329
|
+
* the whole reason every non-next framework used to be handed a Next.js
|
|
3330
|
+
* handler — the catalogs only ever filled in `next` and the rest fell through
|
|
3331
|
+
* to it.
|
|
3332
|
+
*
|
|
3333
|
+
* `before` is emitted above the integration's fragment (framework imports),
|
|
3334
|
+
* `after` below it (the declaration that calls `chatHandler`). The fragment
|
|
3335
|
+
* itself may open with its own imports; they all land at the top of the file.
|
|
3336
|
+
*/
|
|
3337
|
+
interface WebRouteAdapter {
|
|
3338
|
+
runtime: string;
|
|
3339
|
+
/** Path comment that opens the block, so the code has somewhere to go. */
|
|
3340
|
+
file: string;
|
|
3341
|
+
before?: string[];
|
|
3342
|
+
after: string[];
|
|
3343
|
+
/**
|
|
3344
|
+
* Rewrite the integration's portable handler into this framework's own idioms,
|
|
3345
|
+
* returning the new fragment plus any imports the rewrite needs.
|
|
3346
|
+
*
|
|
3347
|
+
* The portable fragments are written for a Node-shaped host, and one of them
|
|
3348
|
+
* does not port: `process.env`. Only SvelteKit uses this so far — see
|
|
3349
|
+
* `svelteEnvAccess`.
|
|
3350
|
+
*/
|
|
3351
|
+
adaptFragment?: (fragment: string) => { fragment: string; imports: string[] };
|
|
3352
|
+
}
|
|
3353
|
+
|
|
3354
|
+
/**
|
|
3355
|
+
* `process.env.X` -> SvelteKit's own `env.X`.
|
|
3356
|
+
*
|
|
3357
|
+
* A fresh `sv create` app installs no `@types/node`, so `process` is not a name
|
|
3358
|
+
* that exists: every emitted route reading a key failed `svelte-check` with
|
|
3359
|
+
* TS2580 on the first run. `$env/dynamic/private` is Kit's own accessor, is
|
|
3360
|
+
* typed by the `.svelte-kit/ambient.d.ts` its own `sync` step generates, and is
|
|
3361
|
+
* the form its docs prescribe — so this is the idiomatic fix, not a workaround
|
|
3362
|
+
* for a missing dependency. It also keeps the key off the client by
|
|
3363
|
+
* construction: importing `$env/dynamic/private` from client code is an error
|
|
3364
|
+
* Kit raises for you.
|
|
3365
|
+
*/
|
|
3366
|
+
function svelteEnvAccess(fragment: string): { fragment: string; imports: string[] } {
|
|
3367
|
+
if (!/\bprocess\.env\./.test(fragment)) return { fragment, imports: [] };
|
|
3368
|
+
return {
|
|
3369
|
+
fragment: fragment.replace(/\bprocess\.env\.([A-Za-z_$][\w$]*)/g, 'env.$1'),
|
|
3370
|
+
imports: [
|
|
3371
|
+
`// SvelteKit's own env accessor. \`process.env\` would need @types/node, which`,
|
|
3372
|
+
`// a fresh \`sv create\` app does not install — and this is the idiomatic form:`,
|
|
3373
|
+
`// it is typed by .svelte-kit/ambient.d.ts and cannot be imported client-side.`,
|
|
3374
|
+
`import { env } from '$env/dynamic/private';`,
|
|
3375
|
+
],
|
|
3376
|
+
};
|
|
3377
|
+
}
|
|
3378
|
+
|
|
3379
|
+
/**
|
|
3380
|
+
* The Vite dev-server middleware, which is what a plain SPA (react/vue) can
|
|
3381
|
+
* actually host. Dev-only on purpose, and it says so: a Vite SPA has no
|
|
3382
|
+
* production server to deploy a route to.
|
|
3383
|
+
*/
|
|
3384
|
+
function viteMiddlewareAdapter(plugin: string): WebRouteAdapter {
|
|
3385
|
+
return {
|
|
3386
|
+
runtime: 'Vite dev-server middleware (Node)',
|
|
3387
|
+
// `server/chat.ts`, NOT `src/server/chat.ts`. A create-vite app splits its
|
|
3388
|
+
// tsconfig in two: tsconfig.app.json is `"include": ["src"]` with
|
|
3389
|
+
// `types: ["vite/client"]` and no node types, and tsconfig.node.json covers
|
|
3390
|
+
// vite.config.ts and what it imports. A handler under `src/` is therefore
|
|
3391
|
+
// compiled by the BROWSER project as well, where `process.env` is
|
|
3392
|
+
// TS2591 "Cannot find name 'process'" — measured in a stock react-ts app.
|
|
3393
|
+
// Outside `src/` only the node project claims it, which is the one with the
|
|
3394
|
+
// node types it needs.
|
|
3395
|
+
file: 'server/chat.ts',
|
|
3396
|
+
after: [
|
|
3397
|
+
``,
|
|
3398
|
+
`// vite.config.ts imports it from here.`,
|
|
3399
|
+
`export { chatHandler };`,
|
|
3400
|
+
``,
|
|
3401
|
+
`// ── vite-chat-api.ts ─────────────────────────────────────────────────────────`,
|
|
3402
|
+
`// A Vite SPA has no server routes, so fetch('/api/chat') has nothing to answer`,
|
|
3403
|
+
`// it. This plugin mounts the SAME handler on the dev server. DEV ONLY: for`,
|
|
3404
|
+
`// production, deploy the handler to a real server (Next, SvelteKit, a Worker,`,
|
|
3405
|
+
`// Express) or point the fetch at one.`,
|
|
3406
|
+
`import type { Plugin } from 'vite';`,
|
|
3407
|
+
`// The '.js' is REQUIRED and is not a typo: the stock tsconfig.node.json sets`,
|
|
3408
|
+
`// "module": "nodenext", where an extensionless relative import is TS2835. TS`,
|
|
3409
|
+
`// resolves './server/chat.js' to server/chat.ts, and so does Vite.`,
|
|
3410
|
+
`import { chatHandler } from './server/chat.js';`,
|
|
3411
|
+
``,
|
|
3412
|
+
`export function chatApiPlugin(): Plugin {`,
|
|
3413
|
+
` return {`,
|
|
3414
|
+
` name: 'chat-api',`,
|
|
3415
|
+
` configureServer(server) {`,
|
|
3416
|
+
` server.middlewares.use('/api/chat', async (req, res) => {`,
|
|
3417
|
+
` let body = '';`,
|
|
3418
|
+
` req.setEncoding('utf8');`,
|
|
3419
|
+
` for await (const chunk of req) body += chunk;`,
|
|
3420
|
+
``,
|
|
3421
|
+
` const response = await chatHandler(`,
|
|
3422
|
+
` new Request('http://localhost/api/chat', {`,
|
|
3423
|
+
` method: 'POST',`,
|
|
3424
|
+
` headers: { 'Content-Type': 'application/json' },`,
|
|
3425
|
+
` body,`,
|
|
3426
|
+
` }),`,
|
|
3427
|
+
` );`,
|
|
3428
|
+
``,
|
|
3429
|
+
` // The STATUS has to survive the bridge: a 401 from the provider that`,
|
|
3430
|
+
` // arrives at the browser as a 200 is a blank bubble and no error.`,
|
|
3431
|
+
` res.statusCode = response.status;`,
|
|
3432
|
+
` // Annotated: a vite.config / server tsconfig has no DOM lib, so Headers`,
|
|
3433
|
+
` // comes from @types/node and these params are implicitly \`any\` (TS7006)`,
|
|
3434
|
+
` // under a stock \`npm create vite\` app's noImplicitAny.`,
|
|
3435
|
+
` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
|
|
3436
|
+
` if (!response.body) { res.end(); return; }`,
|
|
3437
|
+
``,
|
|
3438
|
+
` // Write each chunk as it lands — buffering here defeats streaming.`,
|
|
3439
|
+
` const reader = response.body.getReader();`,
|
|
3440
|
+
` for (;;) {`,
|
|
3441
|
+
` const { value, done } = await reader.read();`,
|
|
3442
|
+
` if (done) break;`,
|
|
3443
|
+
` res.write(value);`,
|
|
3444
|
+
` }`,
|
|
3445
|
+
` res.end();`,
|
|
3446
|
+
` });`,
|
|
3447
|
+
` },`,
|
|
3448
|
+
` };`,
|
|
3449
|
+
`}`,
|
|
3450
|
+
``,
|
|
3451
|
+
`// ── vite.config.ts ───────────────────────────────────────────────────────────`,
|
|
3452
|
+
`// Again '.js', for the same reason: tsconfig.node.json is "module": "nodenext",`,
|
|
3453
|
+
`// where './vite-chat-api' is TS2835 and fails \`npm run build\`.`,
|
|
3454
|
+
`// import { chatApiPlugin } from './vite-chat-api.js';`,
|
|
3455
|
+
`// export default defineConfig({ plugins: [${plugin}, chatApiPlugin()] });`,
|
|
3456
|
+
],
|
|
3457
|
+
};
|
|
3458
|
+
}
|
|
3459
|
+
|
|
3460
|
+
const WEB_ROUTE_ADAPTERS: Record<string, WebRouteAdapter> = {
|
|
3461
|
+
next: {
|
|
3462
|
+
runtime: 'Next.js route handler (Node/Edge)',
|
|
3463
|
+
file: 'app/api/chat/route.ts',
|
|
3464
|
+
after: [
|
|
3465
|
+
``,
|
|
3466
|
+
`// Next.js App Router: the file exports the HTTP method.`,
|
|
3467
|
+
`export async function POST(req: Request): Promise<Response> {`,
|
|
3468
|
+
` return chatHandler(req);`,
|
|
3469
|
+
`}`,
|
|
3470
|
+
],
|
|
3471
|
+
},
|
|
3472
|
+
svelte: {
|
|
3473
|
+
runtime: 'SvelteKit +server.ts endpoint',
|
|
3474
|
+
file: 'src/routes/api/chat/+server.ts',
|
|
3475
|
+
before: [`import type { RequestHandler } from './$types';`],
|
|
3476
|
+
adaptFragment: svelteEnvAccess,
|
|
3477
|
+
after: [
|
|
3478
|
+
``,
|
|
3479
|
+
`// SvelteKit calls POST(event), NOT POST(request): \`request\` is a FIELD on the`,
|
|
3480
|
+
`// event. That one line is the whole difference from the Next.js route — a`,
|
|
3481
|
+
`// Next-shaped \`export async function POST(req: Request)\` typechecks here and`,
|
|
3482
|
+
`// then throws "req.json is not a function" on the first submit.`,
|
|
3483
|
+
`export const POST: RequestHandler = ({ request }) => chatHandler(request);`,
|
|
3484
|
+
],
|
|
3485
|
+
},
|
|
3486
|
+
'tanstack-start': {
|
|
3487
|
+
runtime: 'TanStack Start server route',
|
|
3488
|
+
file: 'src/routes/api/chat.ts',
|
|
3489
|
+
before: [
|
|
3490
|
+
`import { createFileRoute } from '@tanstack/react-router';`,
|
|
3491
|
+
`// Side-effect import, REQUIRED: it loads the server-route type augmentation`,
|
|
3492
|
+
`// (@tanstack/start-client-core) that adds \`server\` to the route options. With`,
|
|
3493
|
+
`// nothing under src/ importing @tanstack/react-start the augmentation never`,
|
|
3494
|
+
`// loads and the block below fails to typecheck: TS2353 on \`server\`, then`,
|
|
3495
|
+
`// TS7031 on \`request\`.`,
|
|
3496
|
+
`import '@tanstack/react-start';`,
|
|
3497
|
+
],
|
|
3498
|
+
after: [
|
|
3499
|
+
``,
|
|
3500
|
+
`// TanStack Start routes the FILE. A bare \`export async function POST\` is never`,
|
|
3501
|
+
`// called — the handler has to hang off the route's \`server.handlers\`.`,
|
|
3502
|
+
`export const Route = createFileRoute('/api/chat')({`,
|
|
3503
|
+
` server: { handlers: { POST: ({ request }) => chatHandler(request) } },`,
|
|
3504
|
+
`});`,
|
|
3505
|
+
],
|
|
3506
|
+
},
|
|
3507
|
+
react: viteMiddlewareAdapter('react()'),
|
|
3508
|
+
// NOT a bare `vue()`. This one-liner and block (0) configure the same file, and
|
|
3509
|
+
// a consumer who pastes this over that silently drops `isCustomElement`, at
|
|
3510
|
+
// which point every kai-* tag stops resolving and the page renders empty.
|
|
3511
|
+
vue: viteMiddlewareAdapter('vue({ /* keep the template.compilerOptions from block (0) */ })'),
|
|
3512
|
+
// A Solid app is a Vite SPA, so it hosts the route exactly the way react/vue
|
|
3513
|
+
// do. `tailwindcss()` is in the plugin list because the Solid front end needs
|
|
3514
|
+
// it (the components are Tailwind source) — see renderSolid's setup note.
|
|
3515
|
+
solid: viteMiddlewareAdapter('solid(), tailwindcss()'),
|
|
3516
|
+
/**
|
|
3517
|
+
* Angular DOES have a server, and this is where it lives.
|
|
3518
|
+
*
|
|
3519
|
+
* `ng add @angular/ssr` (or `ng new --ssr`) generates `src/server.ts` as an
|
|
3520
|
+
* Express app, and the Angular CLI loads it through the `reqHandler` export at
|
|
3521
|
+
* the bottom for BOTH `ng serve` and `ng build` — the generated file's own
|
|
3522
|
+
* comment reads "Request handler used by the Angular CLI (for dev-server and
|
|
3523
|
+
* during build)". So an endpoint registered here answers in development and in
|
|
3524
|
+
* production, unlike the react/vue Vite middleware, which is dev-only.
|
|
3525
|
+
*
|
|
3526
|
+
* There is no second option: `@angular/build:dev-server` exposes no middleware
|
|
3527
|
+
* hook and takes no Vite plugins, so an Angular app WITHOUT SSR enabled cannot
|
|
3528
|
+
* host `/api/chat` at all — it has to proxy to a separate server.
|
|
3529
|
+
*
|
|
3530
|
+
* The whole file is emitted rather than a fragment because placement is the
|
|
3531
|
+
* part that goes wrong: the route must be registered BEFORE the catch-all
|
|
3532
|
+
* `app.use` that renders the Angular app, or the renderer answers /api/chat
|
|
3533
|
+
* with an HTML page and the front end tries to parse it as SSE.
|
|
3534
|
+
*/
|
|
3535
|
+
angular: {
|
|
3536
|
+
runtime: 'Angular SSR server (Express, src/server.ts)',
|
|
3537
|
+
file: 'src/server.ts',
|
|
3538
|
+
before: [
|
|
3539
|
+
`// This is the file \`ng add @angular/ssr\` generates (\`ng new --ssr\` too), with`,
|
|
3540
|
+
`// the chat endpoint added. The Angular CLI loads it via the \`reqHandler\` export`,
|
|
3541
|
+
`// at the bottom for BOTH \`ng serve\` and \`ng build\`, so the route below answers`,
|
|
3542
|
+
`// in development and in production.`,
|
|
3543
|
+
`//`,
|
|
3544
|
+
`// If your app has no src/server.ts, you have no SSR: run \`ng add @angular/ssr\`.`,
|
|
3545
|
+
`// @angular/build's dev server takes no middleware and no Vite plugins, so a`,
|
|
3546
|
+
`// non-SSR Angular app cannot host /api/chat — proxy it to a separate server`,
|
|
3547
|
+
`// instead (framework: "express" | "worker" | "next").`,
|
|
3548
|
+
`import {`,
|
|
3549
|
+
` AngularNodeAppEngine,`,
|
|
3550
|
+
` createNodeRequestHandler,`,
|
|
3551
|
+
` isMainModule,`,
|
|
3552
|
+
` writeResponseToNodeResponse,`,
|
|
3553
|
+
`} from '@angular/ssr/node';`,
|
|
3554
|
+
`import express from 'express';`,
|
|
3555
|
+
`import { join } from 'node:path';`,
|
|
3556
|
+
],
|
|
3557
|
+
after: [
|
|
3558
|
+
``,
|
|
3559
|
+
`const browserDistFolder = join(import.meta.dirname, '../browser');`,
|
|
3560
|
+
``,
|
|
3561
|
+
`const app = express();`,
|
|
3562
|
+
`const angularApp = new AngularNodeAppEngine();`,
|
|
3563
|
+
``,
|
|
3564
|
+
`// ORDER MATTERS: this has to come BEFORE the catch-all below, or Angular`,
|
|
3565
|
+
`// renders the app for /api/chat and the browser parses an HTML page as SSE.`,
|
|
3566
|
+
`// express.json() is scoped to this one route on purpose — page requests have`,
|
|
3567
|
+
`// no reason to be body-parsed.`,
|
|
3568
|
+
`app.post('/api/chat', express.json(), async (req, res) => {`,
|
|
3569
|
+
` const response = await chatHandler(`,
|
|
3570
|
+
` new Request('http://localhost/api/chat', {`,
|
|
3571
|
+
` method: 'POST',`,
|
|
3572
|
+
` headers: { 'Content-Type': 'application/json' },`,
|
|
3573
|
+
` body: JSON.stringify(req.body),`,
|
|
3574
|
+
` }),`,
|
|
3575
|
+
` );`,
|
|
3576
|
+
``,
|
|
3577
|
+
` // The STATUS has to survive the bridge: a 401 from the provider that arrives`,
|
|
3578
|
+
` // at the browser as a 200 is a blank bubble and no error.`,
|
|
3579
|
+
` res.status(response.status);`,
|
|
3580
|
+
` // Annotated: a server-side tsconfig has no DOM lib, so Headers comes from`,
|
|
3581
|
+
` // @types/node and these params are implicitly \`any\` (TS7006) under noImplicitAny.`,
|
|
3582
|
+
` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
|
|
3583
|
+
` if (!response.body) { res.end(); return; }`,
|
|
3584
|
+
``,
|
|
3585
|
+
` // Write each chunk as it lands — buffering here defeats streaming.`,
|
|
3586
|
+
` const reader = response.body.getReader();`,
|
|
3587
|
+
` for (;;) {`,
|
|
3588
|
+
` const { value, done } = await reader.read();`,
|
|
3589
|
+
` if (done) break;`,
|
|
3590
|
+
` res.write(value);`,
|
|
3591
|
+
` }`,
|
|
3592
|
+
` res.end();`,
|
|
3593
|
+
`});`,
|
|
3594
|
+
``,
|
|
3595
|
+
`// Serve the built browser assets.`,
|
|
3596
|
+
`app.use(`,
|
|
3597
|
+
` express.static(browserDistFolder, { maxAge: '1y', index: false, redirect: false }),`,
|
|
3598
|
+
`);`,
|
|
3599
|
+
``,
|
|
3600
|
+
`// Everything else renders the Angular application.`,
|
|
3601
|
+
`app.use((req, res, next) => {`,
|
|
3602
|
+
` angularApp`,
|
|
3603
|
+
` .handle(req)`,
|
|
3604
|
+
` .then((response) => (response ? writeResponseToNodeResponse(response, res) : next()))`,
|
|
3605
|
+
` .catch(next);`,
|
|
3606
|
+
`});`,
|
|
3607
|
+
``,
|
|
3608
|
+
`if (isMainModule(import.meta.url)) {`,
|
|
3609
|
+
` const port = process.env['PORT'] || 4000;`,
|
|
3610
|
+
` app.listen(port, (error) => {`,
|
|
3611
|
+
` if (error) {`,
|
|
3612
|
+
` throw error;`,
|
|
3613
|
+
` }`,
|
|
3614
|
+
``,
|
|
3615
|
+
` console.log(\`Node Express server listening on http://localhost:\${port}\`);`,
|
|
3616
|
+
` });`,
|
|
3617
|
+
`}`,
|
|
3618
|
+
``,
|
|
3619
|
+
`// The Angular CLI (dev-server and build) picks the app up from here.`,
|
|
3620
|
+
`export const reqHandler = createNodeRequestHandler(app);`,
|
|
3621
|
+
],
|
|
3622
|
+
},
|
|
3623
|
+
worker: {
|
|
3624
|
+
runtime: 'Cloudflare Worker',
|
|
3625
|
+
file: 'src/index.ts',
|
|
3626
|
+
after: [
|
|
3627
|
+
``,
|
|
3628
|
+
`// A Worker IS the web standard: fetch(Request) -> Response.`,
|
|
3629
|
+
`export default {`,
|
|
3630
|
+
` fetch(request: Request): Promise<Response> {`,
|
|
3631
|
+
` return chatHandler(request);`,
|
|
3632
|
+
` },`,
|
|
3633
|
+
`};`,
|
|
3634
|
+
`// Secrets live on \`env\`, not process.env: either thread \`env\` through to the`,
|
|
3635
|
+
`// handler, or set compatibility_date "2025-04-01" (or later) with nodejs_compat,`,
|
|
3636
|
+
`// which populates process.env from your bindings.`,
|
|
3637
|
+
],
|
|
3638
|
+
},
|
|
3639
|
+
express: {
|
|
3640
|
+
runtime: 'Express handler (Node)',
|
|
3641
|
+
file: 'server.ts',
|
|
3642
|
+
before: [`import express from 'express';`],
|
|
3643
|
+
after: [
|
|
3644
|
+
``,
|
|
3645
|
+
`const app = express();`,
|
|
3646
|
+
`app.use(express.json());`,
|
|
3647
|
+
``,
|
|
3648
|
+
`// Express is (req, res) — it neither takes a Request nor returns a Response, so`,
|
|
3649
|
+
`// the web handler is bridged here. Node 18+ has Request/Response as globals.`,
|
|
3650
|
+
`app.post('/api/chat', async (req, res) => {`,
|
|
3651
|
+
` const response = await chatHandler(`,
|
|
3652
|
+
` new Request('http://localhost/api/chat', {`,
|
|
3653
|
+
` method: 'POST',`,
|
|
3654
|
+
` headers: { 'Content-Type': 'application/json' },`,
|
|
3655
|
+
` body: JSON.stringify(req.body),`,
|
|
3656
|
+
` }),`,
|
|
3657
|
+
` );`,
|
|
3658
|
+
``,
|
|
3659
|
+
` // The STATUS has to survive the bridge: a 401 from the provider that arrives`,
|
|
3660
|
+
` // at the browser as a 200 is a blank bubble and no error.`,
|
|
3661
|
+
` res.status(response.status);`,
|
|
3662
|
+
` // Annotated: a server-side tsconfig has no DOM lib, so Headers comes from`,
|
|
3663
|
+
` // @types/node and these params are implicitly \`any\` (TS7006) under noImplicitAny.`,
|
|
3664
|
+
` response.headers.forEach((value: string, key: string) => res.setHeader(key, value));`,
|
|
3665
|
+
` if (!response.body) { res.end(); return; }`,
|
|
3666
|
+
``,
|
|
3667
|
+
` // Write each chunk as it lands — buffering here defeats streaming.`,
|
|
3668
|
+
` const reader = response.body.getReader();`,
|
|
3669
|
+
` for (;;) {`,
|
|
3670
|
+
` const { value, done } = await reader.read();`,
|
|
3671
|
+
` if (done) break;`,
|
|
3672
|
+
` res.write(value);`,
|
|
3673
|
+
` }`,
|
|
3674
|
+
` res.end();`,
|
|
3675
|
+
`});`,
|
|
3676
|
+
``,
|
|
3677
|
+
`app.listen(3001, () => console.log('chat api: http://localhost:3001/api/chat'));`,
|
|
3678
|
+
`// The front end fetches a RELATIVE /api/chat, so proxy that path to this port`,
|
|
3679
|
+
`// from your dev server, or serve both from one origin.`,
|
|
3680
|
+
],
|
|
3681
|
+
},
|
|
1417
3682
|
};
|
|
1418
3683
|
|
|
3684
|
+
/**
|
|
3685
|
+
* The request body, declared once per route file.
|
|
3686
|
+
*
|
|
3687
|
+
* `await request.json()` is `unknown` — it is whatever the client sent — so
|
|
3688
|
+
* destructuring it directly is TS2339 on EVERY field. That is not pedantry: it
|
|
3689
|
+
* is a hard `npm run build` failure the moment a Node-typed project compiles the
|
|
3690
|
+
* route, where `Request` comes from undici (`json(): Promise<unknown>`) rather
|
|
3691
|
+
* than from the DOM lib (`json(): Promise<any>`). A stock Vite app does exactly
|
|
3692
|
+
* that — `tsc -b` walks vite.config.ts → vite-chat-api.ts → src/server/chat.ts
|
|
3693
|
+
* with `lib` and no DOM — so the route ran fine and the build did not.
|
|
3694
|
+
*
|
|
3695
|
+
* `messages` is typed as the kit's OWN encoder output rather than restated
|
|
3696
|
+
* structurally, which keeps the two halves of the scaffold pinned to one type:
|
|
3697
|
+
* the front end sends `toOpenAIMessages(thread)`, and this is what that returns.
|
|
3698
|
+
* The import is type-only and erases at build time, so the route ships no
|
|
3699
|
+
* runtime dependency on the kit.
|
|
3700
|
+
*/
|
|
3701
|
+
const CHAT_REQUEST_BODY_IMPORT = `import type { OpenAIWireMessage } from '@kitn.ai/ui/wire';`;
|
|
3702
|
+
const CHAT_REQUEST_BODY_DECL = [
|
|
3703
|
+
`/**`,
|
|
3704
|
+
` * What the front end POSTs. \`request.json()\` is \`unknown\` (it is whatever the`,
|
|
3705
|
+
` * client sent), so the body is narrowed once here instead of at every use —`,
|
|
3706
|
+
` * without it this route does not compile under a server tsconfig. Widen it as`,
|
|
3707
|
+
` * you add fields of your own.`,
|
|
3708
|
+
` */`,
|
|
3709
|
+
`type ChatRequestBody = {`,
|
|
3710
|
+
` messages: OpenAIWireMessage[];`,
|
|
3711
|
+
` model?: string;`,
|
|
3712
|
+
` tools?: unknown[];`,
|
|
3713
|
+
`};`,
|
|
3714
|
+
``,
|
|
3715
|
+
`/** Narrow the JSON body once, at the edge. */`,
|
|
3716
|
+
`async function readChatRequest(request: Request): Promise<ChatRequestBody> {`,
|
|
3717
|
+
` return (await request.json()) as ChatRequestBody;`,
|
|
3718
|
+
`}`,
|
|
3719
|
+
];
|
|
3720
|
+
|
|
3721
|
+
/**
|
|
3722
|
+
* Slot the body type in just above `chatHandler`.
|
|
3723
|
+
*
|
|
3724
|
+
* Not at the very top: a fragment may open with its own imports (langgraph,
|
|
3725
|
+
* mastra, vercel-ai-sdk all do), and a type declaration wedged above them reads
|
|
3726
|
+
* like a mistake. Anchoring on the handler — and stepping back over the comment
|
|
3727
|
+
* block that documents it — puts the declaration where a person would have
|
|
3728
|
+
* written it.
|
|
3729
|
+
*/
|
|
3730
|
+
function withChatRequestBody(fragment: string): string {
|
|
3731
|
+
const lines = fragment.split('\n');
|
|
3732
|
+
let at = lines.findIndex((l) => /^(?:export\s+)?async function chatHandler\b/.test(l));
|
|
3733
|
+
if (at < 0) return [...CHAT_REQUEST_BODY_DECL, ``, ...lines].join('\n');
|
|
3734
|
+
while (at > 0 && /^\s*(?:\/\/|\/\*|\*)/.test(lines[at - 1])) at -= 1;
|
|
3735
|
+
return [...lines.slice(0, at), ...CHAT_REQUEST_BODY_DECL, ``, ...lines.slice(at)].join('\n');
|
|
3736
|
+
}
|
|
3737
|
+
|
|
3738
|
+
/** Wrap an integration's portable handler in the target framework's declaration. */
|
|
3739
|
+
function webRouteFor(integration: Integration, framework: string): RouteChoice | undefined {
|
|
3740
|
+
const fragment = integration.webRoute;
|
|
3741
|
+
const adapter = WEB_ROUTE_ADAPTERS[framework];
|
|
3742
|
+
if (!fragment || !adapter) return undefined;
|
|
3743
|
+
// A framework may have to rewrite the portable handler into its own idioms and
|
|
3744
|
+
// pull in an import to do it (SvelteKit's $env accessor). Both land at the top
|
|
3745
|
+
// of the file, beside the adapter's own `before` lines.
|
|
3746
|
+
const adapted = adapter.adaptFragment?.(fragment) ?? { fragment, imports: [] };
|
|
3747
|
+
return {
|
|
3748
|
+
framework,
|
|
3749
|
+
runtime: adapter.runtime,
|
|
3750
|
+
exact: true,
|
|
3751
|
+
template: [
|
|
3752
|
+
`// ${adapter.file}`,
|
|
3753
|
+
CHAT_REQUEST_BODY_IMPORT,
|
|
3754
|
+
...(adapter.before ?? []),
|
|
3755
|
+
...adapted.imports,
|
|
3756
|
+
``,
|
|
3757
|
+
withChatRequestBody(adapted.fragment),
|
|
3758
|
+
...adapter.after,
|
|
3759
|
+
].join('\n'),
|
|
3760
|
+
};
|
|
3761
|
+
}
|
|
3762
|
+
|
|
1419
3763
|
/** Prefer the language's canonical server framework when there's no exact match. */
|
|
1420
3764
|
function preferredKeyFor(integration: Integration): string[] {
|
|
1421
3765
|
return integration.language === 'python'
|
|
@@ -1426,19 +3770,41 @@ function preferredKeyFor(integration: Integration): string[] {
|
|
|
1426
3770
|
function chooseRoute(integration: Integration, framework: string): RouteChoice | undefined {
|
|
1427
3771
|
const templates = integration.routeTemplates;
|
|
1428
3772
|
|
|
1429
|
-
// 1. exact match
|
|
1430
|
-
|
|
3773
|
+
// 1. exact match. Never for 'html': that key can only hold a browser snippet,
|
|
3774
|
+
// and block (1) already emits the whole browser side. Selecting one here
|
|
3775
|
+
// printed a SECOND <kai-chat id="chat"> under a "BACKEND ROUTE" heading,
|
|
3776
|
+
// with its own kai-submit listener, so pasting both blocks gave a duplicate
|
|
3777
|
+
// element id and two fetches per submit. Step 4 below has always skipped
|
|
3778
|
+
// 'html' for the same reason; step 1 did not.
|
|
3779
|
+
//
|
|
3780
|
+
// This is where a route that CANNOT be portable wins: a Worker on an `env`
|
|
3781
|
+
// binding, an Express bridge, a FastAPI service.
|
|
3782
|
+
if (framework !== 'html' && templates[framework]) {
|
|
1431
3783
|
return { framework, template: templates[framework], runtime: RUNTIME_LABEL[framework] ?? framework, exact: true };
|
|
1432
3784
|
}
|
|
1433
3785
|
|
|
1434
|
-
// 2.
|
|
3786
|
+
// 2. the portable handler, wrapped in this framework's own route declaration.
|
|
3787
|
+
const portable = webRouteFor(integration, framework);
|
|
3788
|
+
if (portable) return portable;
|
|
3789
|
+
|
|
3790
|
+
// 3. no adapter for this framework at all (html, fastapi): the framework
|
|
3791
|
+
// cannot host ANY route, so emit the portable handler under a host that can
|
|
3792
|
+
// run it standalone — with the warning from step 5. Emitting nothing here
|
|
3793
|
+
// would leave `html` with no backend code whatsoever, which is worse than a
|
|
3794
|
+
// route it has to run elsewhere: the handler is still the thing to deploy.
|
|
3795
|
+
if (!WEB_ROUTE_ADAPTERS[framework] && integration.webRoute) {
|
|
3796
|
+
const host = webRouteFor(integration, 'express');
|
|
3797
|
+
if (host) return { ...host, exact: false };
|
|
3798
|
+
}
|
|
3799
|
+
|
|
3800
|
+
// 4. language-canonical fallback (python → fastapi; ts → next/express/worker)
|
|
1435
3801
|
for (const key of preferredKeyFor(integration)) {
|
|
1436
3802
|
if (templates[key]) {
|
|
1437
3803
|
return { framework: key, template: templates[key], runtime: RUNTIME_LABEL[key] ?? key, exact: false };
|
|
1438
3804
|
}
|
|
1439
3805
|
}
|
|
1440
3806
|
|
|
1441
|
-
//
|
|
3807
|
+
// 5. anything usable that isn't a pure front-end snippet
|
|
1442
3808
|
for (const [key, template] of Object.entries(templates)) {
|
|
1443
3809
|
if (key === 'html') continue;
|
|
1444
3810
|
return { framework: key, template, runtime: RUNTIME_LABEL[key] ?? key, exact: false };
|
|
@@ -1447,29 +3813,262 @@ function chooseRoute(integration: Integration, framework: string): RouteChoice |
|
|
|
1447
3813
|
return undefined;
|
|
1448
3814
|
}
|
|
1449
3815
|
|
|
3816
|
+
/**
|
|
3817
|
+
* The honest warning for a route the target framework cannot host.
|
|
3818
|
+
*
|
|
3819
|
+
* Emitted for EVERY such framework. It used to be gated on
|
|
3820
|
+
* `framework === 'react'`, which left the two worst cases silent: svelte, whose
|
|
3821
|
+
* Next-shaped handler compiles and then throws at runtime, and html, which has
|
|
3822
|
+
* no server to paste anything into.
|
|
3823
|
+
*/
|
|
3824
|
+
function cannotHostWarning(integration: Integration, route: RouteChoice, framework: string): string[] {
|
|
3825
|
+
const target = FRAMEWORK_LABEL[framework] ?? `a ${framework} app`;
|
|
3826
|
+
const options = [
|
|
3827
|
+
// Led by the option `keyExposure` unlocks, where there is one. The generic
|
|
3828
|
+
// note below tells a static page to "run this route on a separate server",
|
|
3829
|
+
// which for ollama is work nobody has to do: its route holds no credential
|
|
3830
|
+
// and reaches localhost, so the page can call it. That advice was emitted
|
|
3831
|
+
// identically for every integration because nothing read the flag that
|
|
3832
|
+
// distinguishes them.
|
|
3833
|
+
...(integration.keyExposure === 'frontend-safe'
|
|
3834
|
+
? [
|
|
3835
|
+
`# • or drop the route entirely: this integration needs no server hop — see the`,
|
|
3836
|
+
`# run note — so the page may call it directly (CORS applies).`,
|
|
3837
|
+
]
|
|
3838
|
+
: []),
|
|
3839
|
+
`# • ${CANNOT_HOST_NOTE[framework] ?? "port it to this framework's route convention."}`,
|
|
3840
|
+
...(integration.language === 'python'
|
|
3841
|
+
? [
|
|
3842
|
+
`# • it is a separate SERVICE either way: run it (uvicorn main:app) and proxy`,
|
|
3843
|
+
`# /api/chat to it, or point the fetch at http://localhost:8000/api/chat.`,
|
|
3844
|
+
]
|
|
3845
|
+
: [`# • or run it where it belongs: framework: "${route.framework}".`]),
|
|
3846
|
+
`# • or use integration: "mock" for a zero-config local stream (no backend, no key).`,
|
|
3847
|
+
];
|
|
3848
|
+
return [
|
|
3849
|
+
`#`,
|
|
3850
|
+
`# WARNING: the route below is written for ${route.runtime} and will NOT run`,
|
|
3851
|
+
`# as-is in ${target}.`,
|
|
3852
|
+
...options,
|
|
3853
|
+
];
|
|
3854
|
+
}
|
|
3855
|
+
|
|
3856
|
+
// ── block (0): setup a framework REQUIRES before block (1) runs at all ────────
|
|
3857
|
+
|
|
3858
|
+
/**
|
|
3859
|
+
* SCAF-3, promoted from a comment to a step.
|
|
3860
|
+
*
|
|
3861
|
+
* The placement was the defect. This used to be an HTML comment sitting ABOVE the
|
|
3862
|
+
* `<script setup>` block, and block (1) is a `<script setup>` + `<template>` pair:
|
|
3863
|
+
* an agent or a developer copying "the component" copies that pair, and the one
|
|
3864
|
+
* thing that configures it is not inside it. As its own labelled block, ordered
|
|
3865
|
+
* first, it cannot be lost to that copy.
|
|
3866
|
+
*
|
|
3867
|
+
* WHAT IT ACTUALLY DOES, measured rather than assumed. On Vue 3.5.39, skipping it
|
|
3868
|
+
* does NOT blank the page: Vue falls back to rendering the unresolved tag as a
|
|
3869
|
+
* native element, and its runtime sets `key in el` bindings as DOM properties, so
|
|
3870
|
+
* a scaffold built from this output still runs — verified in a stock `vue-ts` app
|
|
3871
|
+
* in dev AND in a production build, with and without the `.prop` modifiers, and
|
|
3872
|
+
* the mock reply streamed in every time. What you get instead is
|
|
3873
|
+
*
|
|
3874
|
+
* [Vue warn]: Failed to resolve component: kai-chat
|
|
3875
|
+
*
|
|
3876
|
+
* on every kai-* tag in development, which reads like a bug and is the exact
|
|
3877
|
+
* warning Vue tells you to fix with `compilerOptions.isCustomElement`.
|
|
3878
|
+
*
|
|
3879
|
+
* So the emitted copy says that, and does not claim a blank page it cannot
|
|
3880
|
+
* produce. Overstating it would be worse than the buried comment was: the first
|
|
3881
|
+
* developer to skip the step and find their app working stops believing the rest
|
|
3882
|
+
* of the scaffold.
|
|
3883
|
+
*
|
|
3884
|
+
* The whole file is emitted rather than a fragment because the plugin list is
|
|
3885
|
+
* where this collides with block (2): a Vite SPA's dev API route adds
|
|
3886
|
+
* `chatApiPlugin()` to the SAME array, and a consumer who pastes one config over
|
|
3887
|
+
* the other silently loses whichever came first. The emitted comment says so.
|
|
3888
|
+
*/
|
|
3889
|
+
function setupBlock(framework: string): string | undefined {
|
|
3890
|
+
if (framework !== 'vue') return undefined;
|
|
3891
|
+
return [
|
|
3892
|
+
`=== (0) REQUIRED SETUP — do this FIRST ===`,
|
|
3893
|
+
``,
|
|
3894
|
+
`// vite.config.ts`,
|
|
3895
|
+
`//`,
|
|
3896
|
+
`// Tell Vue that kai-* tags are CUSTOM ELEMENTS rather than Vue components.`,
|
|
3897
|
+
`// This is its own step, not a note inside block (1), because block (1) is a`,
|
|
3898
|
+
`// <script setup> + <template> pair and this configures it from outside — copy`,
|
|
3899
|
+
`// just the component and you never see it.`,
|
|
3900
|
+
`//`,
|
|
3901
|
+
`// Without it, every kai-* tag logs this in development:`,
|
|
3902
|
+
`// [Vue warn]: Failed to resolve component: kai-chat`,
|
|
3903
|
+
`// If this is a native custom element, make sure to exclude it from component`,
|
|
3904
|
+
`// resolution via compilerOptions.isCustomElement.`,
|
|
3905
|
+
`// The app does still render — Vue falls back to a native element — so this is`,
|
|
3906
|
+
`// a warning to remove, not a crash to avoid. Removing it is the point: it is`,
|
|
3907
|
+
`// the fix Vue's own message asks for, and it stops the console reading like`,
|
|
3908
|
+
`// something is broken.`,
|
|
3909
|
+
`import vue from '@vitejs/plugin-vue';`,
|
|
3910
|
+
`import { defineConfig } from 'vite';`,
|
|
3911
|
+
``,
|
|
3912
|
+
`export default defineConfig({`,
|
|
3913
|
+
` plugins: [`,
|
|
3914
|
+
` vue({`,
|
|
3915
|
+
` template: {`,
|
|
3916
|
+
` compilerOptions: {`,
|
|
3917
|
+
` // Every kai-* tag is a custom element, not a Vue component.`,
|
|
3918
|
+
` isCustomElement: (tag) => tag.startsWith('kai-'),`,
|
|
3919
|
+
` },`,
|
|
3920
|
+
` },`,
|
|
3921
|
+
` }),`,
|
|
3922
|
+
` // If you also add the dev API route from block (2), its chatApiPlugin() goes`,
|
|
3923
|
+
` // in THIS array — do not replace this file with the one-liner shown there.`,
|
|
3924
|
+
` ],`,
|
|
3925
|
+
`});`,
|
|
3926
|
+
].join('\n');
|
|
3927
|
+
}
|
|
3928
|
+
|
|
3929
|
+
// ── block (3): what to install, and where the key may live ───────────────────
|
|
3930
|
+
|
|
3931
|
+
/**
|
|
3932
|
+
* The install command, DERIVED from the catalog's `deps`.
|
|
3933
|
+
*
|
|
3934
|
+
* `deps` was declared by all eleven integrations and consumed by nothing: the
|
|
3935
|
+
* schema typed it, `registry.test.ts` checked its SHAPE, and the fact a developer
|
|
3936
|
+
* actually needs — which packages to install — lived a SECOND time as prose in
|
|
3937
|
+
* each integration's `runNote`. Two copies of one fact drift silently, and both
|
|
3938
|
+
* copies already had: langgraph's prose named three packages where `deps.npm`
|
|
3939
|
+
* carries four (it never mentioned `zod`, which its route imports and its app
|
|
3940
|
+
* therefore fails to build without), and pydantic-ai's named three where
|
|
3941
|
+
* `deps.pip` carries four (it never mentioned `pydantic`, which its route
|
|
3942
|
+
* imports). Nothing could catch either, because nothing compared them.
|
|
3943
|
+
*
|
|
3944
|
+
* So the prose is gone and this is the only place the scaffold names a package.
|
|
3945
|
+
* `registry.test.ts` pins `deps.npm` to the imports of the route's own source, so
|
|
3946
|
+
* the line below cannot drift from the code printed above it either.
|
|
3947
|
+
*
|
|
3948
|
+
* `@kitn.ai/ui` leads and is deliberately in no integration's `deps`: it is the
|
|
3949
|
+
* kit, a dependency of every scaffold whatever the backend, so declaring it
|
|
3950
|
+
* per-integration would be eleven copies of one constant.
|
|
3951
|
+
*
|
|
3952
|
+
* Only what the emitted CODE imports is listed. The host a route runs on (Express,
|
|
3953
|
+
* an Angular SSR server, a Vite dev server) comes from the app template the
|
|
3954
|
+
* developer created, not from here.
|
|
3955
|
+
*/
|
|
3956
|
+
function installLines(integration: Integration): string[] {
|
|
3957
|
+
const lines = [
|
|
3958
|
+
`Install:`,
|
|
3959
|
+
` npm install ${['@kitn.ai/ui', ...integration.deps.npm].join(' ')}`,
|
|
3960
|
+
];
|
|
3961
|
+
if (integration.deps.pip.length > 0) {
|
|
3962
|
+
lines.push(
|
|
3963
|
+
` # ...and block (2) is Python — a separate service, with its own install:`,
|
|
3964
|
+
` pip install ${integration.deps.pip.join(' ')}`,
|
|
3965
|
+
);
|
|
3966
|
+
}
|
|
3967
|
+
return lines;
|
|
3968
|
+
}
|
|
3969
|
+
|
|
3970
|
+
/**
|
|
3971
|
+
* The two halves of the proxy decision, as the exact strings the emitted run note
|
|
3972
|
+
* carries. Exported so the guards in scaffold.test.ts can look for the claim the
|
|
3973
|
+
* scaffold really makes instead of restating it — a copied string is a guard that
|
|
3974
|
+
* goes green the day the wording changes.
|
|
3975
|
+
*/
|
|
3976
|
+
export const PROXY_REQUIRED_CLAIM = 'a server hop is REQUIRED';
|
|
3977
|
+
export const NO_PROXY_CLAIM = 'no server hop is required';
|
|
3978
|
+
|
|
3979
|
+
/**
|
|
3980
|
+
* Where this integration's key may live, DERIVED from the catalog's `keyExposure`.
|
|
3981
|
+
*
|
|
3982
|
+
* This is the field's whole point of existence (see `KeyExposure` in types.ts) and
|
|
3983
|
+
* until now nothing read it, which meant every one of the eleven values could have
|
|
3984
|
+
* been wrong with every test still green.
|
|
3985
|
+
*
|
|
3986
|
+
* `'frontend-safe'` is tested POSITIVELY and everything else — including an absent
|
|
3987
|
+
* flag, which the type still permits — falls through to the proxy branch. That
|
|
3988
|
+
* asymmetry is the safety property: declaring a proxy where none was needed costs
|
|
3989
|
+
* a server hop, and reading a missing flag as "safe" costs the key.
|
|
3990
|
+
*/
|
|
3991
|
+
function keyHandlingLines(integration: Integration, route: RouteChoice | undefined): string[] {
|
|
3992
|
+
if (integration.keyExposure === 'frontend-safe') {
|
|
3993
|
+
return [
|
|
3994
|
+
`Key handling: frontend-safe — ${NO_PROXY_CLAIM}.`,
|
|
3995
|
+
` Nothing here is secret and nothing needs a capability a browser lacks, so the`,
|
|
3996
|
+
` page may talk to this integration itself.`,
|
|
3997
|
+
...(route
|
|
3998
|
+
? [
|
|
3999
|
+
` The route in block (2) is a convenience — one origin, one place to log — and`,
|
|
4000
|
+
` not a requirement.`,
|
|
4001
|
+
]
|
|
4002
|
+
: []),
|
|
4003
|
+
];
|
|
4004
|
+
}
|
|
4005
|
+
|
|
4006
|
+
// Named rather than described: "OPENAI_API_KEY stays on the server" is a
|
|
4007
|
+
// sentence a developer can act on. `SECRET_ENV_VAR` is the schema's own pattern,
|
|
4008
|
+
// imported rather than restated, so this list and the refinement that rejects a
|
|
4009
|
+
// false `frontend-safe` cannot disagree about what counts as a secret.
|
|
4010
|
+
const secrets = integration.envVars.filter((name) => SECRET_ENV_VAR.test(name));
|
|
4011
|
+
return [
|
|
4012
|
+
`Key handling: needs-proxy — ${PROXY_REQUIRED_CLAIM}.`,
|
|
4013
|
+
...(secrets.length > 0
|
|
4014
|
+
? [
|
|
4015
|
+
` ${secrets.join(' and ')} ${secrets.length > 1 ? 'stay' : 'stays'} on the server: the front end POSTs to`,
|
|
4016
|
+
` /api/chat, and only the route reads ${secrets.length > 1 ? 'them' : 'it'}.`,
|
|
4017
|
+
` Do NOT re-export ${secrets.length > 1 ? 'them' : 'it'} through a client-bundle env var — a bundler INLINES`,
|
|
4018
|
+
` VITE_*, NEXT_PUBLIC_* and PUBLIC_* into the JavaScript the browser downloads,`,
|
|
4019
|
+
` so a key put there is published, not configured.`,
|
|
4020
|
+
]
|
|
4021
|
+
: [
|
|
4022
|
+
// Two integrations are here, for two different reasons, and the sentence
|
|
4023
|
+
// has to be true of both: `pi` spawns a local process, which a browser
|
|
4024
|
+
// cannot do at any price, and `mastra` points at an unauthenticated agent
|
|
4025
|
+
// endpoint that a public bundle must not be aimed at. Neither declares a
|
|
4026
|
+
// key, and neither is frontend-safe.
|
|
4027
|
+
` No API key is involved. What keeps this on a server is the route itself —`,
|
|
4028
|
+
` either a capability a browser does not have, or an endpoint a public bundle`,
|
|
4029
|
+
` must not be pointed at. Block (2) is not optional.`,
|
|
4030
|
+
]),
|
|
4031
|
+
];
|
|
4032
|
+
}
|
|
4033
|
+
|
|
1450
4034
|
// ── compose ───────────────────────────────────────────────────────────────────
|
|
1451
4035
|
|
|
4036
|
+
/**
|
|
4037
|
+
* The three labeled blocks the MCP tool returns: the surface, the backend route
|
|
4038
|
+
* and the run note.
|
|
4039
|
+
*
|
|
4040
|
+
* It owns NO rendering. `renderSurface` emits block (1) and `chooseRoute` picks
|
|
4041
|
+
* block (2); what is left here is the provenance header and the assembly. That
|
|
4042
|
+
* split is what lets `create-kai` reuse the renderer without inheriting the MCP's
|
|
4043
|
+
* output format, which is the reason this extraction exists.
|
|
4044
|
+
*
|
|
4045
|
+
* `preset` is provenance only — the archetype id and title, when the request came
|
|
4046
|
+
* in as `useCase`. It is printed in the header and NEVER reaches `renderSurface`,
|
|
4047
|
+
* so it cannot change a single byte of the emitted surface.
|
|
4048
|
+
*/
|
|
1452
4049
|
function compose(
|
|
1453
|
-
|
|
4050
|
+
components: readonly string[],
|
|
1454
4051
|
integration: Integration,
|
|
1455
4052
|
placement: string,
|
|
1456
4053
|
framework: string,
|
|
1457
4054
|
suggestions: string[],
|
|
1458
4055
|
audience?: string,
|
|
4056
|
+
preset?: { id: string; title: string },
|
|
1459
4057
|
): string {
|
|
1460
|
-
const
|
|
1461
|
-
|
|
1462
|
-
|
|
1463
|
-
|
|
4058
|
+
const frontend = renderSurface({
|
|
4059
|
+
framework,
|
|
4060
|
+
components,
|
|
4061
|
+
integration,
|
|
4062
|
+
placement,
|
|
4063
|
+
suggestions,
|
|
4064
|
+
audience,
|
|
4065
|
+
});
|
|
1464
4066
|
const isMock = integration.id === 'mock';
|
|
1465
|
-
// SCAF-8: compute the default model only for non-mock integrations that forward model.
|
|
1466
|
-
const defaultModel = isMock ? undefined : defaultModelFor(integration);
|
|
1467
|
-
const frontend = renderFrontend(framework, archetype, placement, audienceHint, suggestions, isMock, defaultModel);
|
|
1468
4067
|
const route = isMock ? undefined : chooseRoute(integration, framework);
|
|
1469
4068
|
|
|
1470
4069
|
const header = [
|
|
1471
|
-
`# AI/UI scaffold — ${
|
|
1472
|
-
`combo: ${
|
|
4070
|
+
`# AI/UI scaffold — ${preset ? preset.title : surfaceLabel(components)} × ${integration.title}`,
|
|
4071
|
+
`combo: ${preset ? preset.id : components.join('+')} × ${integration.id} × ${placement} × ${framework}`,
|
|
1473
4072
|
`stream: ${integration.streamMapping}`,
|
|
1474
4073
|
].join('\n');
|
|
1475
4074
|
|
|
@@ -1490,21 +4089,11 @@ function compose(
|
|
|
1490
4089
|
} else if (route) {
|
|
1491
4090
|
if (!route.exact) {
|
|
1492
4091
|
block2Parts.push(
|
|
1493
|
-
`# Note: ${integration.title} has no
|
|
4092
|
+
`# Note: ${integration.title} has no route for "${framework}". Emitting its native`,
|
|
1494
4093
|
`# ${route.runtime} route instead (matches the integration's ${integration.language} language).`,
|
|
4094
|
+
// For EVERY framework that cannot host it, not just react.
|
|
4095
|
+
...cannotHostWarning(integration, route, framework),
|
|
1495
4096
|
);
|
|
1496
|
-
// Honest warning: a Next.js/server route will NOT run inside a Vite SPA.
|
|
1497
|
-
if (framework === 'react') {
|
|
1498
|
-
block2Parts.push(
|
|
1499
|
-
`#`,
|
|
1500
|
-
`# WARNING: this is a Next.js route handler — it will NOT run in a Vite SPA`,
|
|
1501
|
-
`# (a Vite \`react\` app has no /api routes). To make the front-end above work, either:`,
|
|
1502
|
-
`# • use Next.js (framework: "next"), or`,
|
|
1503
|
-
`# • add a Vite dev-server middleware/proxy to a server, or`,
|
|
1504
|
-
`# • run a separate server (framework: "express" | "worker"), or`,
|
|
1505
|
-
`# • use integration: "mock" for a zero-config local stream (no backend, no key).`,
|
|
1506
|
-
);
|
|
1507
|
-
}
|
|
1508
4097
|
block2Parts.push(``);
|
|
1509
4098
|
} else {
|
|
1510
4099
|
block2Parts.push(`# Runtime: ${route.runtime}`, ``);
|
|
@@ -1521,25 +4110,57 @@ function compose(
|
|
|
1521
4110
|
const envLines = integration.envVars.length
|
|
1522
4111
|
? integration.envVars.map((v) => ` - ${v}`).join('\n')
|
|
1523
4112
|
: ' (none)';
|
|
4113
|
+
// The catalog's two machine-readable facts, emitted rather than described: what
|
|
4114
|
+
// to install comes from `deps`, and where the key may live comes from
|
|
4115
|
+
// `keyExposure`. `runNote` is prose ABOUT running it and no longer restates
|
|
4116
|
+
// either — see `installLines` for the drift that duplication had already caused.
|
|
1524
4117
|
const block3 = [
|
|
1525
4118
|
`=== (3) RUN NOTE ===`,
|
|
1526
4119
|
``,
|
|
1527
4120
|
integration.runNote,
|
|
1528
4121
|
``,
|
|
4122
|
+
...installLines(integration),
|
|
4123
|
+
``,
|
|
1529
4124
|
`Env vars to set:`,
|
|
1530
4125
|
envLines,
|
|
4126
|
+
``,
|
|
4127
|
+
...keyHandlingLines(integration, route),
|
|
1531
4128
|
].join('\n');
|
|
1532
4129
|
|
|
1533
4130
|
// SCAF-16: loading-options note — inform consumers about the two opt-in load modes
|
|
1534
4131
|
// (per-element tree-shaking + autoloader) without changing the default import above.
|
|
1535
4132
|
// Leads with "the default is right" rather than a size headline; the debug tool
|
|
1536
4133
|
// carries the full KB breakdown for developers who ask for it.
|
|
4134
|
+
// The default varies by framework, so describe what THIS scaffold actually emits:
|
|
4135
|
+
// every framework but `next` emits a top-level `import '@kitn.ai/ui/elements'`;
|
|
4136
|
+
// the next output loads the React wrappers through next/dynamic instead, and each
|
|
4137
|
+
// wrapper lazy-registers its own element on first client mount.
|
|
4138
|
+
const defaultLoadNote =
|
|
4139
|
+
framework === 'solid'
|
|
4140
|
+
? [
|
|
4141
|
+
`The scaffold emits NO \`import '@kitn.ai/ui/elements'\` — a Solid app renders`,
|
|
4142
|
+
`the SolidJS components straight from the root entry, so no custom element is`,
|
|
4143
|
+
`registered at all and your bundler already tree-shakes what you never import.`,
|
|
4144
|
+
`Leave it as is. The two modes below matter only if you ALSO put raw \`<kai-*>\``,
|
|
4145
|
+
`tags on the page (you do not need to):`,
|
|
4146
|
+
]
|
|
4147
|
+
: framework === 'next'
|
|
4148
|
+
? [
|
|
4149
|
+
`The scaffold emits NO \`import '@kitn.ai/ui/elements'\` — it loads the React`,
|
|
4150
|
+
`wrappers through next/dynamic, and each wrapper lazy-registers ITS element on`,
|
|
4151
|
+
`first client mount, so you already ship only the elements you use. Leave it as`,
|
|
4152
|
+
`is. Two other modes exist if you drop the wrappers for raw \`<kai-*>\` tags:`,
|
|
4153
|
+
]
|
|
4154
|
+
: [
|
|
4155
|
+
`The scaffold uses \`import '@kitn.ai/ui/elements'\` (register-all) — the right`,
|
|
4156
|
+
`default: it registers every kai-* element and is SSR-safe, so leave it as is.`,
|
|
4157
|
+
`Two opt-in modes load less if a page only ever uses a few elements:`,
|
|
4158
|
+
];
|
|
4159
|
+
|
|
1537
4160
|
const block4 = [
|
|
1538
4161
|
`=== LOADING OPTIONS ===`,
|
|
1539
4162
|
``,
|
|
1540
|
-
|
|
1541
|
-
`default: it registers every kai-* element and is SSR-safe, so leave it as is.`,
|
|
1542
|
-
`Two opt-in modes load less if a page only ever uses a few elements:`,
|
|
4163
|
+
...defaultLoadNote,
|
|
1543
4164
|
``,
|
|
1544
4165
|
` Per-element (bundler apps): import '@kitn.ai/ui/elements/<file>'`,
|
|
1545
4166
|
` Registers just that element; your bundler tree-shakes the rest away.`,
|
|
@@ -1558,7 +4179,11 @@ function compose(
|
|
|
1558
4179
|
// capture without leaving the scaffold. Does not change blocks 1–4.
|
|
1559
4180
|
const block5 = interactionPatternsBlock();
|
|
1560
4181
|
|
|
1561
|
-
|
|
4182
|
+
// Block (0) is emitted only where the framework genuinely needs setup before
|
|
4183
|
+
// block (1) will run — today that is vue's isCustomElement.
|
|
4184
|
+
const block0 = setupBlock(framework);
|
|
4185
|
+
|
|
4186
|
+
return [header, ...(block0 ? [block0] : []), block1, block2, block3, block4, block5].join('\n\n');
|
|
1562
4187
|
}
|
|
1563
4188
|
|
|
1564
4189
|
// ── SCAF-17: reusable interaction-pattern snippets ─────────────────────────────
|
|
@@ -1658,14 +4283,14 @@ function interactionPatternsBlock(): string {
|
|
|
1658
4283
|
`// const set = (fn) => { el.messages = fn(el.messages ?? []); };`,
|
|
1659
4284
|
``,
|
|
1660
4285
|
`// Low-level helpers from @kitn.ai/ui/state:`,
|
|
1661
|
-
`// import { appendMessage, updateMessage,
|
|
4286
|
+
`// import { appendMessage, updateMessage, appendText, createAssistantStream } from '@kitn.ai/ui/state';`,
|
|
1662
4287
|
``,
|
|
1663
4288
|
`// Streaming loop (framework-agnostic):`,
|
|
1664
4289
|
`// const stream = createAssistantStream(set);`,
|
|
1665
|
-
`// stream.appendText(chunk);
|
|
1666
|
-
`// stream.appendReasoning(chunk);
|
|
1667
|
-
`// stream.upsertTool(
|
|
1668
|
-
`// stream.done();
|
|
4290
|
+
`// stream.appendText(chunk); // text delta, appended to the trailing text part`,
|
|
4291
|
+
`// stream.appendReasoning(chunk); // reasoning delta`,
|
|
4292
|
+
`// stream.upsertTool(toolCallId, patch); // tool call delta (patch merges into the ToolPart)`,
|
|
4293
|
+
`// stream.done(); // seal the message`,
|
|
1669
4294
|
``,
|
|
1670
4295
|
`// One-liner for React (batteries-included):`,
|
|
1671
4296
|
`// import { useKaiChat } from '@kitn.ai/ui/react';`,
|
|
@@ -1710,13 +4335,20 @@ function rejectIntegration(id: string): string {
|
|
|
1710
4335
|
|
|
1711
4336
|
function rejectUseCase(id: string): string {
|
|
1712
4337
|
const valid = listArchetypes()
|
|
1713
|
-
.map((a) => `${a.id} (${a.title})`)
|
|
4338
|
+
.map((a) => `${a.id} (${a.title}: ${a.components.join(', ')})`)
|
|
1714
4339
|
.join(', ');
|
|
1715
4340
|
return [
|
|
1716
|
-
`Unknown useCase: "${id}"
|
|
4341
|
+
id ? `Unknown useCase: "${id}".` : `No surface given: pass either \`components\` or \`useCase\`.`,
|
|
1717
4342
|
``,
|
|
1718
|
-
`Valid useCases (
|
|
1719
|
-
|
|
4343
|
+
`Valid useCases (presets): ${valid}.`,
|
|
4344
|
+
``,
|
|
4345
|
+
// The presets are six points, not the space. A harness that only ever learns
|
|
4346
|
+
// the six ids will ask for the nearest one instead of the surface it wants,
|
|
4347
|
+
// so the rejection that teaches the id list is the right place to say so.
|
|
4348
|
+
`These are PRESETS over the real axis, which is \`components\`. To compose a surface no`,
|
|
4349
|
+
`preset names, pass the list directly, e.g. components: ["kai-chat", "kai-tool",`,
|
|
4350
|
+
`"kai-reasoning", "kai-artifact", "kai-resizable"] for a workspace that also renders its`,
|
|
4351
|
+
`tool calls. Pick a preset id or pass \`components\`, then call scaffold again.`,
|
|
1720
4352
|
].join('\n');
|
|
1721
4353
|
}
|
|
1722
4354
|
|
|
@@ -1725,18 +4357,33 @@ function rejectUseCase(id: string): string {
|
|
|
1725
4357
|
export const scaffold: Tool = {
|
|
1726
4358
|
name: 'scaffold',
|
|
1727
4359
|
description:
|
|
1728
|
-
'Scaffold a working AI/UI chat surface from
|
|
4360
|
+
'Scaffold a working AI/UI chat surface from: components (or a useCase preset) × integration × placement × framework. ' +
|
|
1729
4361
|
'Emits a copy-pasteable front-end (kai-* components wired with messages + kai-submit + starter suggestions), the backend ' +
|
|
1730
|
-
'route for the chosen framework, and a run note with env vars. Use integration: "mock" for a zero-config local preview.'
|
|
4362
|
+
'route for the chosen framework, and a run note with env vars. Use integration: "mock" for a zero-config local preview. ' +
|
|
4363
|
+
'Pass `components` to compose any feature set; `useCase` is a named preset over the same axis and the two cannot disagree.',
|
|
1731
4364
|
inputSchema: z.object({
|
|
1732
4365
|
// useCase + integration are dynamic catalog ids — kept as strings and
|
|
1733
4366
|
// validated against the registry in the handler (the handler is called
|
|
1734
4367
|
// directly in tests, bypassing this schema). Use component_reference / the
|
|
1735
4368
|
// catalogs to discover valid ids.
|
|
4369
|
+
//
|
|
4370
|
+
// `useCase` is OPTIONAL because `components` can carry the surface instead —
|
|
4371
|
+
// the archetypes are six points in the components space, not the space. A
|
|
4372
|
+
// request must still name one of the two, and the handler says so when it
|
|
4373
|
+
// names neither.
|
|
1736
4374
|
useCase: z
|
|
1737
4375
|
.string()
|
|
4376
|
+
.optional()
|
|
4377
|
+
.describe(
|
|
4378
|
+
'Archetype PRESET id, e.g. "drop-in-chat", "support-widget", "knowledge-base", "agentic", "workspace", "voice". ' +
|
|
4379
|
+
'Shorthand for the preset\'s `components`. Omit it and pass `components` to compose a surface no preset names.',
|
|
4380
|
+
),
|
|
4381
|
+
components: z
|
|
4382
|
+
.array(z.string())
|
|
4383
|
+
.optional()
|
|
1738
4384
|
.describe(
|
|
1739
|
-
'
|
|
4385
|
+
'The kai-* components this surface composes, e.g. ["kai-chat", "kai-tool", "kai-reasoning", "kai-artifact", "kai-resizable"]. ' +
|
|
4386
|
+
'The real axis: any combination is renderable, not just the six presets. Include "kai-chat". Wins over `useCase` when both are given.',
|
|
1740
4387
|
),
|
|
1741
4388
|
integration: z
|
|
1742
4389
|
.string()
|
|
@@ -1747,7 +4394,8 @@ export const scaffold: Tool = {
|
|
|
1747
4394
|
'Where the surface lives: full-page | side | docked-widget | inline.',
|
|
1748
4395
|
),
|
|
1749
4396
|
framework: Framework.describe(
|
|
1750
|
-
'Target front-end/back-end framework: html | react | next | vue | svelte | fastapi | express | worker | tanstack-start.'
|
|
4397
|
+
'Target front-end/back-end framework: html | react | next | vue | svelte | angular | solid | fastapi | express | worker | tanstack-start. ' +
|
|
4398
|
+
'Note "solid" emits the SolidJS components from the @kitn.ai/ui root entry, not <kai-*> elements — the kit is authored in Solid.',
|
|
1751
4399
|
),
|
|
1752
4400
|
suggestions: z
|
|
1753
4401
|
.array(z.string())
|
|
@@ -1762,7 +4410,6 @@ export const scaffold: Tool = {
|
|
|
1762
4410
|
.describe('Optional audience hint (tweaks the empty-state comment only).'),
|
|
1763
4411
|
}),
|
|
1764
4412
|
handler: async (args) => {
|
|
1765
|
-
const useCase = String(args.useCase ?? '');
|
|
1766
4413
|
const integrationId = String(args.integration ?? '');
|
|
1767
4414
|
const placement = String(args.placement ?? '');
|
|
1768
4415
|
const framework = String(args.framework ?? 'html');
|
|
@@ -1773,16 +4420,39 @@ export const scaffold: Tool = {
|
|
|
1773
4420
|
? args.suggestions.map(String)
|
|
1774
4421
|
: DEFAULT_SUGGESTIONS;
|
|
1775
4422
|
|
|
1776
|
-
//
|
|
1777
|
-
|
|
1778
|
-
|
|
4423
|
+
// The surface arrives one of two ways, and `components` wins: it is the axis,
|
|
4424
|
+
// and `useCase` is a preset over it. A caller that passes an explicit list has
|
|
4425
|
+
// said something more specific than a preset name can.
|
|
4426
|
+
const explicit = Array.isArray(args.components)
|
|
4427
|
+
? args.components.map(String).filter(Boolean)
|
|
4428
|
+
: undefined;
|
|
4429
|
+
|
|
4430
|
+
let components: readonly string[];
|
|
4431
|
+
let preset: { id: string; title: string } | undefined;
|
|
4432
|
+
let effectivePlacement = placement;
|
|
4433
|
+
|
|
4434
|
+
if (explicit && explicit.length > 0) {
|
|
4435
|
+
components = explicit;
|
|
4436
|
+
// No preset means no `defaultPlacement` to fall back to. Full-page is the
|
|
4437
|
+
// same default `renderSurface` applies, stated here so the header prints
|
|
4438
|
+
// the placement that was really used.
|
|
4439
|
+
effectivePlacement = placement || 'full-page';
|
|
4440
|
+
} else {
|
|
4441
|
+
const useCase = String(args.useCase ?? '');
|
|
4442
|
+
// Validate against the registry BEFORE composing — graceful, self-correcting text.
|
|
4443
|
+
const archetype = getArchetype(useCase);
|
|
4444
|
+
if (!archetype) return text(rejectUseCase(useCase));
|
|
4445
|
+
components = archetype.components;
|
|
4446
|
+
preset = { id: archetype.id, title: archetype.title };
|
|
4447
|
+
// Fall back to the archetype's default placement only if none was provided.
|
|
4448
|
+
effectivePlacement = placement || archetype.defaultPlacement;
|
|
4449
|
+
}
|
|
1779
4450
|
|
|
1780
4451
|
const integration = getIntegration(integrationId);
|
|
1781
4452
|
if (!integration) return text(rejectIntegration(integrationId));
|
|
1782
4453
|
|
|
1783
|
-
|
|
1784
|
-
|
|
1785
|
-
|
|
1786
|
-
return text(compose(archetype, integration, effectivePlacement, framework, suggestions, audience));
|
|
4454
|
+
return text(
|
|
4455
|
+
compose(components, integration, effectivePlacement, framework, suggestions, audience, preset),
|
|
4456
|
+
);
|
|
1787
4457
|
},
|
|
1788
4458
|
};
|