@kitn.ai/cli 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +58 -0
- package/bin/kai.js +160 -0
- package/bin/route.js +61 -0
- package/dist/assets/dev-C27Rnkul.js +668 -0
- package/dist/builder-page/assets/index-2uphF31h.js +72 -0
- package/dist/builder-page/assets/index-CMlatin-.css +1 -0
- package/dist/builder-page/index.html +20 -0
- package/dist/construct-cli.es.js +3128 -0
- package/dist/doctor.es.js +193 -0
- package/dist/theme-studio/assets/index-C4nWp3LE.js +538 -0
- package/dist/theme-studio/assets/index-CAlkHn6J.css +1 -0
- package/dist/theme-studio/index.html +17 -0
- package/package.json +74 -0
|
@@ -0,0 +1,3128 @@
|
|
|
1
|
+
import { existsSync, readFileSync, rmSync, mkdirSync, writeFileSync, readdirSync, renameSync, statSync, copyFileSync } from "node:fs";
|
|
2
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
3
|
+
import { join, dirname, relative, sep, resolve, basename } from "node:path";
|
|
4
|
+
import { z } from "zod";
|
|
5
|
+
import { createRequire } from "node:module";
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import { fileURLToPath } from "node:url";
|
|
8
|
+
const SAFE_SCHEMES = ["http:", "https:", "mailto:"];
|
|
9
|
+
function schemeOf(url) {
|
|
10
|
+
try {
|
|
11
|
+
return new URL(url, "http://_invalid_base").protocol;
|
|
12
|
+
} catch {
|
|
13
|
+
return void 0;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
function isSafeUrl(url) {
|
|
17
|
+
if (url === "") return false;
|
|
18
|
+
const scheme = schemeOf(url);
|
|
19
|
+
return scheme !== void 0 && SAFE_SCHEMES.includes(scheme);
|
|
20
|
+
}
|
|
21
|
+
const CHAT_MESSAGE_ACTIONS = ["copy", "like", "dislike", "regenerate", "edit", "speak"];
|
|
22
|
+
const BUTTON_VARIANT_NAMES = ["default", "ghost", "subtle", "outline", "destructive"];
|
|
23
|
+
const GROUPS = [
|
|
24
|
+
{
|
|
25
|
+
name: "Surfaces",
|
|
26
|
+
tokens: [
|
|
27
|
+
{ token: "--kai-color-background", label: "Background", hint: "App / chat surface" },
|
|
28
|
+
{ token: "--kai-color-foreground", label: "Foreground", hint: "Default text" },
|
|
29
|
+
{ token: "--kai-color-card", label: "Card", hint: "Bubbles, panels, cards" },
|
|
30
|
+
{ token: "--kai-color-card-foreground", label: "Card text", hint: "Text on cards" },
|
|
31
|
+
{ token: "--kai-color-popover", label: "Popover", hint: "Menus & popovers" },
|
|
32
|
+
{ token: "--kai-color-popover-foreground", label: "Popover text", hint: "Text in popovers" },
|
|
33
|
+
{ token: "--kai-color-sidebar", label: "Sidebar", hint: "Conversation sidebar" },
|
|
34
|
+
{ token: "--kai-color-surface", label: "Surface", hint: "Composer, chips, card headers" },
|
|
35
|
+
{ token: "--kai-color-surface-strong", label: "Surface strong", hint: "Raised / hover step of surface" },
|
|
36
|
+
{ token: "--kai-color-surface-sunken", label: "Surface sunken", hint: "Wells, recessed below surface" }
|
|
37
|
+
]
|
|
38
|
+
},
|
|
39
|
+
{
|
|
40
|
+
name: "Brand & actions",
|
|
41
|
+
tokens: [
|
|
42
|
+
{ token: "--kai-color-primary", label: "Primary", hint: "Buttons, accents, send" },
|
|
43
|
+
{ token: "--kai-color-primary-foreground", label: "On primary", hint: "Text on primary" },
|
|
44
|
+
{ token: "--kai-color-ring", label: "Focus ring", hint: "Keyboard-focus outline" },
|
|
45
|
+
{ token: "--kai-color-accent", label: "Accent", hint: "Hover / accent surface" },
|
|
46
|
+
{ token: "--kai-color-accent-foreground", label: "On accent", hint: "Text on accent" },
|
|
47
|
+
{ token: "--kai-color-secondary", label: "Secondary", hint: "Secondary surface" },
|
|
48
|
+
{ token: "--kai-color-secondary-foreground", label: "On secondary", hint: "Text on secondary" }
|
|
49
|
+
]
|
|
50
|
+
},
|
|
51
|
+
{
|
|
52
|
+
name: "Muted text",
|
|
53
|
+
tokens: [
|
|
54
|
+
{ token: "--kai-color-muted", label: "Muted", hint: "Subtle fills" },
|
|
55
|
+
{ token: "--kai-color-muted-foreground", label: "Muted text", hint: "Secondary text" }
|
|
56
|
+
]
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: "Inputs & borders",
|
|
60
|
+
tokens: [
|
|
61
|
+
{ token: "--kai-color-border", label: "Border", hint: "Dividers & card outlines" },
|
|
62
|
+
{ token: "--kai-color-input", label: "Input", hint: "Control edge: inputs, selects, checks" }
|
|
63
|
+
]
|
|
64
|
+
},
|
|
65
|
+
{
|
|
66
|
+
name: "Status",
|
|
67
|
+
tokens: [
|
|
68
|
+
{ token: "--kai-color-destructive", label: "Destructive", hint: "Danger / delete" },
|
|
69
|
+
{ token: "--kai-color-destructive-foreground", label: "On destructive", hint: "Text on danger" },
|
|
70
|
+
{ token: "--kai-color-destructive-text", label: "Destructive text", hint: "Error text — legible in both modes" },
|
|
71
|
+
{ token: "--kai-color-destructive-soft", label: "Destructive soft", hint: "Tinted danger callout" },
|
|
72
|
+
{ token: "--kai-color-success", label: "Success", hint: "Done / confirmed" },
|
|
73
|
+
{ token: "--kai-color-success-foreground", label: "On success", hint: "Text on success" },
|
|
74
|
+
{ token: "--kai-color-success-soft", label: "Success soft", hint: "Tinted success callout" },
|
|
75
|
+
{ token: "--kai-color-warning", label: "Warning", hint: "Caution / unsendable" },
|
|
76
|
+
{ token: "--kai-color-warning-foreground", label: "On warning", hint: "Text on warning" },
|
|
77
|
+
{ token: "--kai-color-warning-soft", label: "Warning soft", hint: "Tinted warning callout" },
|
|
78
|
+
{ token: "--kai-color-info", label: "Info", hint: "Informational toasts" },
|
|
79
|
+
{ token: "--kai-color-info-foreground", label: "On info", hint: "Text on info" },
|
|
80
|
+
{ token: "--kai-color-info-soft", label: "Info soft", hint: "Tinted info callout" }
|
|
81
|
+
]
|
|
82
|
+
},
|
|
83
|
+
{
|
|
84
|
+
name: "Interaction",
|
|
85
|
+
tokens: [
|
|
86
|
+
{ token: "--kai-color-hover", label: "Hover", hint: "Row / control hover fill" },
|
|
87
|
+
{ token: "--kai-color-selected", label: "Selected", hint: "Selected row fill" },
|
|
88
|
+
{ token: "--kai-color-unread", label: "Unread", hint: "Unread dot & badge" },
|
|
89
|
+
{ token: "--kai-color-highlight", label: "Highlight", hint: "Marked keywords (composer highlights)" },
|
|
90
|
+
{ token: "--kai-color-selection", label: "Selection", hint: "Selected-text background" },
|
|
91
|
+
{ token: "--kai-color-selection-foreground", label: "On selection", hint: "Selected-text colour" }
|
|
92
|
+
]
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
name: "Code & tools",
|
|
96
|
+
tokens: [
|
|
97
|
+
{ token: "--kai-color-code-foreground", label: "Code", hint: "Inline code accent" },
|
|
98
|
+
{ token: "--kai-color-tool-blue", label: "Tool blue", hint: "Tool / status chip" },
|
|
99
|
+
{ token: "--kai-color-tool-amber", label: "Tool amber", hint: "Tool / status chip" },
|
|
100
|
+
{ token: "--kai-color-tool-green", label: "Tool green", hint: "Tool / status chip" },
|
|
101
|
+
{ token: "--kai-color-tool-red", label: "Tool red", hint: "Tool / status chip" }
|
|
102
|
+
]
|
|
103
|
+
},
|
|
104
|
+
{
|
|
105
|
+
name: "Scrollbar",
|
|
106
|
+
tokens: [
|
|
107
|
+
{ token: "--kai-color-scrollbar-thumb", label: "Scrollbar", hint: "Scrollbar thumb" },
|
|
108
|
+
{ token: "--kai-color-scrollbar-thumb-hover", label: "Scrollbar hover", hint: "Thumb on hover" }
|
|
109
|
+
]
|
|
110
|
+
}
|
|
111
|
+
];
|
|
112
|
+
const ALL_TOKENS = GROUPS.flatMap((g) => g.tokens);
|
|
113
|
+
const TEXT_RUNGS = [
|
|
114
|
+
{ token: "--kai-text-micro", label: "Micro", hint: "Badges, pills, eyebrows", min: 0.5, max: 1 },
|
|
115
|
+
{ token: "--kai-text-caption", label: "Caption", hint: "Sub-counts, xs code", min: 0.5, max: 1.125 },
|
|
116
|
+
{ token: "--kai-text-meta", label: "Meta", hint: "Controls, toggles, switchers", min: 0.5, max: 1.25 },
|
|
117
|
+
{ token: "--kai-text-compact", label: "Compact", hint: "Dense chrome & code", min: 0.5625, max: 1.3125 },
|
|
118
|
+
{ token: "--kai-text-body", label: "Body (default)", hint: "Primary reading text", min: 0.625, max: 1.375 },
|
|
119
|
+
{ token: "--kai-text-title", label: "Title", hint: "Emphasis & headers", min: 0.75, max: 1.625 },
|
|
120
|
+
{ token: "--kai-text-lg", label: "Large", hint: "Section headings, lg prose", min: 0.875, max: 2 }
|
|
121
|
+
];
|
|
122
|
+
const EXTRA_TOKENS = [
|
|
123
|
+
"--kai-radius",
|
|
124
|
+
// Density: every spacing utility is `calc(var(--spacing) * N)`.
|
|
125
|
+
"--kai-density",
|
|
126
|
+
// Pill: `rounded-full` is a literal, so the pill family needs its own rung.
|
|
127
|
+
"--kai-radius-pill",
|
|
128
|
+
// Code: its own corner, not a rung of the radius ladder.
|
|
129
|
+
"--kai-code-radius",
|
|
130
|
+
// Elevation: one multiplier over every shadow rung (unitless, not rem).
|
|
131
|
+
"--kai-shadow-strength",
|
|
132
|
+
// Weights: one knob per rung.
|
|
133
|
+
"--kai-weight-normal",
|
|
134
|
+
"--kai-weight-medium",
|
|
135
|
+
"--kai-weight-semibold",
|
|
136
|
+
"--kai-weight-bold",
|
|
137
|
+
"--kai-font-base",
|
|
138
|
+
"--kai-font-code",
|
|
139
|
+
"--kai-tracking",
|
|
140
|
+
"--kai-shadow-color"
|
|
141
|
+
];
|
|
142
|
+
function studioTokens() {
|
|
143
|
+
return /* @__PURE__ */ new Set([
|
|
144
|
+
...ALL_TOKENS.map((t) => t.token),
|
|
145
|
+
...TEXT_RUNGS.map((r) => r.token),
|
|
146
|
+
...EXTRA_TOKENS
|
|
147
|
+
]);
|
|
148
|
+
}
|
|
149
|
+
const KNOWN_THEME_TOKENS = studioTokens();
|
|
150
|
+
const THEME_TOKEN_VALUE_MAX = 256;
|
|
151
|
+
function themeTokenValueProblem(value) {
|
|
152
|
+
if (value.length === 0) return "must not be empty";
|
|
153
|
+
if (value.length > THEME_TOKEN_VALUE_MAX)
|
|
154
|
+
return `too long (${value.length} > ${THEME_TOKEN_VALUE_MAX} characters)`;
|
|
155
|
+
if (!/^[\x20-\x7E]+$/.test(value)) return "must be printable ASCII with no line breaks";
|
|
156
|
+
if (/[{};\\]/.test(value)) return "must not contain braces, semicolons or backslashes";
|
|
157
|
+
if (value.includes("/*") || value.includes("*/"))
|
|
158
|
+
return "must not contain CSS comment sequences";
|
|
159
|
+
let depth = 0;
|
|
160
|
+
for (const ch of value) {
|
|
161
|
+
if (ch === "(") depth += 1;
|
|
162
|
+
else if (ch === ")") {
|
|
163
|
+
depth -= 1;
|
|
164
|
+
if (depth < 0) return "unbalanced parentheses";
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
if (depth !== 0) return "unbalanced parentheses";
|
|
168
|
+
return null;
|
|
169
|
+
}
|
|
170
|
+
const CONSTRUCT_SCHEMA_URL = "https://ui.kitn.ai/schemas/construct/v1.json";
|
|
171
|
+
const TAG_RE = /^[a-z][a-z0-9]*-[a-z0-9-]+$/;
|
|
172
|
+
const TriggerEntrySchema = z.object({
|
|
173
|
+
id: z.string().min(1),
|
|
174
|
+
label: z.string().min(1),
|
|
175
|
+
description: z.string().min(1).optional()
|
|
176
|
+
}).strict();
|
|
177
|
+
const TokenRecordSchema = z.record(z.string(), z.string());
|
|
178
|
+
const ThemeTokensSchema = z.object({
|
|
179
|
+
/** Light-mode `--kai-*` overrides (+ the studio's root-scope knobs). Set
|
|
180
|
+
* on the HOST via setProperty in the emitted facade. NOTE: with no
|
|
181
|
+
* paired `dark` entry a light value applies in BOTH modes — the same
|
|
182
|
+
* one-knob-both-modes semantics a consumer gets setting `--kai-*` on
|
|
183
|
+
* `:root` (theme.css's `.dark` block re-reads the same knob names). */
|
|
184
|
+
light: TokenRecordSchema.optional(),
|
|
185
|
+
/** Dark-mode `--kai-color-*` overrides. Emitted as a `.dark { }` rule in
|
|
186
|
+
* the shadow `<style>` (see codegen.ts's emitElement for the mechanism). */
|
|
187
|
+
dark: TokenRecordSchema.optional(),
|
|
188
|
+
/** The `--kai-radius` value, e.g. "0.6rem". */
|
|
189
|
+
radius: z.string().min(1).optional(),
|
|
190
|
+
/** The font knobs: `--kai-font-base` / `--kai-font-code`. */
|
|
191
|
+
fonts: TokenRecordSchema.optional()
|
|
192
|
+
}).strict().superRefine((tokens, ctx) => {
|
|
193
|
+
const checkRecord = (record, field, keyRule) => {
|
|
194
|
+
for (const [name, value] of Object.entries(record ?? {})) {
|
|
195
|
+
if (!KNOWN_THEME_TOKENS.has(name)) {
|
|
196
|
+
ctx.addIssue({
|
|
197
|
+
code: z.ZodIssueCode.custom,
|
|
198
|
+
path: [field, name],
|
|
199
|
+
message: `"${name}" is not a --kai-* knob the kit declares — see the theming guide for the token list`
|
|
200
|
+
});
|
|
201
|
+
} else if (keyRule && !name.startsWith(keyRule.prefix)) {
|
|
202
|
+
ctx.addIssue({
|
|
203
|
+
code: z.ZodIssueCode.custom,
|
|
204
|
+
path: [field, name],
|
|
205
|
+
message: `"${name}" is not valid under "${field}" — ${keyRule.reason}`
|
|
206
|
+
});
|
|
207
|
+
}
|
|
208
|
+
const problem = themeTokenValueProblem(value);
|
|
209
|
+
if (problem) {
|
|
210
|
+
ctx.addIssue({
|
|
211
|
+
code: z.ZodIssueCode.custom,
|
|
212
|
+
path: [field, name],
|
|
213
|
+
message: `value ${problem}`
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
};
|
|
218
|
+
checkRecord(tokens.light, "light");
|
|
219
|
+
checkRecord(tokens.dark, "dark", {
|
|
220
|
+
prefix: "--kai-color-",
|
|
221
|
+
reason: 'only --kai-color-* knobs have a dark-scope re-resolution in theme.css; a mode-less knob belongs in "light" (it applies in both modes)'
|
|
222
|
+
});
|
|
223
|
+
checkRecord(tokens.fonts, "fonts", {
|
|
224
|
+
prefix: "--kai-font-",
|
|
225
|
+
reason: "only --kai-font-* knobs belong here"
|
|
226
|
+
});
|
|
227
|
+
if (tokens.radius !== void 0) {
|
|
228
|
+
const problem = themeTokenValueProblem(tokens.radius);
|
|
229
|
+
if (problem) {
|
|
230
|
+
ctx.addIssue({
|
|
231
|
+
code: z.ZodIssueCode.custom,
|
|
232
|
+
path: ["radius"],
|
|
233
|
+
message: `value ${problem}`
|
|
234
|
+
});
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
});
|
|
238
|
+
const ProviderSchema = z.discriminatedUnion("mode", [
|
|
239
|
+
z.object({ mode: z.literal("mock") }).strict(),
|
|
240
|
+
z.object({
|
|
241
|
+
mode: z.literal("endpoint"),
|
|
242
|
+
/** The CONSUMER's chat route. Kit parses, consumer fetches. */
|
|
243
|
+
url: z.string().min(1),
|
|
244
|
+
wire: z.enum(["openai", "anthropic"])
|
|
245
|
+
}).strict()
|
|
246
|
+
]);
|
|
247
|
+
const ConstructSchema = z.object({
|
|
248
|
+
$schema: z.string().optional(),
|
|
249
|
+
/** The emitted tag: <acme-support>. Must satisfy customElements.define. */
|
|
250
|
+
name: z.string().regex(TAG_RE, 'must be a valid custom-element tag: lowercase, with a hyphen (e.g. "acme-support")'),
|
|
251
|
+
// Widened progressively: fullscreen/aside/split landed in Task 12, custom in Task 13.
|
|
252
|
+
layout: z.enum(["widget", "fullscreen", "aside", "split", "custom"]),
|
|
253
|
+
provider: ProviderSchema,
|
|
254
|
+
/** Plain (unsigned) identity passthrough — no signing/auth infra (owner
|
|
255
|
+
* ruling, 2026-08-26: signed JWT/HMAC identity is later additive
|
|
256
|
+
* vocabulary, not this field). TOP-LEVEL, not nested under `provider`:
|
|
257
|
+
* local history scoping (`capabilities.history.persistence: 'local'`)
|
|
258
|
+
* needs userId independent of provider mode — nesting it inside the
|
|
259
|
+
* provider union would make it silently inert for `mode: 'mock'` +
|
|
260
|
+
* local history, a real and common combination. Threaded as the
|
|
261
|
+
* `x-kai-user-id` header on every emitted fetch to the CONSUMER's own
|
|
262
|
+
* backend (the endpoint provider's chat POST, history's endpoint
|
|
263
|
+
* GET/PUT), and folded into the localStorage key for `local` history so
|
|
264
|
+
* different users on the same browser profile don't share one thread.
|
|
265
|
+
* Constrained to printable-ASCII (no CR/LF, no non-ISO-8859-1 code
|
|
266
|
+
* points) beyond the usual construct-authored-text escaping: this value
|
|
267
|
+
* reaches an HTTP HEADER VALUE, not just a JS string literal, and
|
|
268
|
+
* `fetch()` throws at RUNTIME on a header value containing CR/LF or
|
|
269
|
+
* outside ISO-8859-1 — a bad userId would surface as an opaque crash in
|
|
270
|
+
* the CONSUMER's generated app, not a construct-validation error. Reject
|
|
271
|
+
* loudly here instead, where the author gets a path + message. */
|
|
272
|
+
userId: z.string().min(1).regex(
|
|
273
|
+
/^[\x20-\x7E]+$/,
|
|
274
|
+
"must be printable ASCII with no line breaks (it becomes an HTTP header value)"
|
|
275
|
+
).optional(),
|
|
276
|
+
theme: z.object({
|
|
277
|
+
/** Any CSS color; becomes --kai-color-primary on the host. */
|
|
278
|
+
accent: z.string().optional(),
|
|
279
|
+
/** Any CSS color; becomes --kai-color-unread on the host (owner
|
|
280
|
+
* ruling, 2026-08-26 — the unread-indicator round). Same treatment
|
|
281
|
+
* as `accent` in every way that matters for safety: construct-
|
|
282
|
+
* authored/untrusted text, carried to the emitted facade via
|
|
283
|
+
* `ctx.element.style.setProperty('--kai-color-unread',
|
|
284
|
+
* JSON.stringify(...))` — never string-interpolated into CSS
|
|
285
|
+
* text — so it can never break out into a new declaration or rule
|
|
286
|
+
* (see emitElement's doc on why `accent` uses setProperty at all).
|
|
287
|
+
* Unlike `accent`, this has no paired -foreground to compute: the
|
|
288
|
+
* three surfaces that read --color-unread (the conversation-list
|
|
289
|
+
* row dot, the header toggle's badge, Dock's own closed-launcher
|
|
290
|
+
* badge) are all small filled dots with no text sitting ON them, so
|
|
291
|
+
* there is nothing to contrast-pair — the value is set and used
|
|
292
|
+
* as-is. */
|
|
293
|
+
unreadColor: z.string().optional(),
|
|
294
|
+
mode: z.enum(["light", "dark", "system"]).default("system"),
|
|
295
|
+
/** Full `--kai-*` palette persistence — see ThemeTokensSchema above.
|
|
296
|
+
* Precedence with `accent`/`unreadColor`: those emit first, tokens
|
|
297
|
+
* after, so a token naming the same knob (e.g. --kai-color-primary)
|
|
298
|
+
* WINS — the full palette is the finer-grained wish. */
|
|
299
|
+
tokens: ThemeTokensSchema.optional()
|
|
300
|
+
}).strict().optional(),
|
|
301
|
+
/** Construct-wide header chrome, valid on every layout (not layout-scoped
|
|
302
|
+
* like `widget`, and not a capability toggle — a header is a construct-
|
|
303
|
+
* wide fact, like `theme`). Rendered inside ChatThread's own built-in
|
|
304
|
+
* header bar; a logo/icon projects through the kit's EXISTING
|
|
305
|
+
* `header-start` named slot (`slots` vocabulary above), not a second
|
|
306
|
+
* image-prop convention here. */
|
|
307
|
+
header: z.object({
|
|
308
|
+
/** Rendered in ChatThread's built-in header bar (left side). Construct-
|
|
309
|
+
* authored/untrusted text, like theme.accent/provider.url — JSON.stringify'd
|
|
310
|
+
* at its one emit site, never a raw JSX attribute string. */
|
|
311
|
+
title: z.string().min(1).optional(),
|
|
312
|
+
/** Renders a theme-toggle Button in ChatThread's header-end region,
|
|
313
|
+
* flipping the host's `theme` attribute (the attribute
|
|
314
|
+
* defineWebComponent already owns) via the facade's ctx.element —
|
|
315
|
+
* codegen work, no new kit surface (B-10). */
|
|
316
|
+
themeToggle: z.boolean().optional(),
|
|
317
|
+
/** Header action buttons (label + kit Button variant), rendered in
|
|
318
|
+
* the header-end region. Vocabulary-never-logic: the construct
|
|
319
|
+
* cannot say what an action DOES, so each click dispatches a
|
|
320
|
+
* non-bubbling `kai-header-action` CustomEvent on the host with
|
|
321
|
+
* `detail: { label }` — the consumer's listening seam (B-10).
|
|
322
|
+
* `variant` derives from the kit Button's own list
|
|
323
|
+
* (BUTTON_VARIANT_NAMES — B-6a), never restated. `label` is
|
|
324
|
+
* construct-authored untrusted text, JSON.stringify'd at emit. */
|
|
325
|
+
actions: z.array(
|
|
326
|
+
z.object({
|
|
327
|
+
label: z.string().min(1),
|
|
328
|
+
variant: z.enum(BUTTON_VARIANT_NAMES).optional()
|
|
329
|
+
}).strict()
|
|
330
|
+
).min(1).optional()
|
|
331
|
+
}).strict().optional(),
|
|
332
|
+
/** Greeting shown while the thread is empty (no messages yet) — the
|
|
333
|
+
* proven "welcome screen" pattern (Intercom-class): title + optional
|
|
334
|
+
* description + optional icon above, with `capabilities.starters`'
|
|
335
|
+
* chips and the composer still rendering below (ChatThread's `empty`
|
|
336
|
+
* REPLACE slot only stands in for the empty MESSAGE LIST — see
|
|
337
|
+
* chat-thread.tsx's own doc comment on `empty` — so the chips are
|
|
338
|
+
* never lost). Construct-wide like `header`, not a capability: it's a
|
|
339
|
+
* fact about the empty state, not a toggleable affordance.
|
|
340
|
+
* `title`/`description` are construct-authored/untrusted text, like
|
|
341
|
+
* `header.title`/`theme.accent`/`provider.url` — JSON.stringify'd at
|
|
342
|
+
* their one emit site, never a raw JSX attribute string. `icon` is a
|
|
343
|
+
* URL reaching an `<img src>` sink in emitted code, exactly like
|
|
344
|
+
* `widget.launcherIcon` — same `isSafeUrl` policy, same superRefine
|
|
345
|
+
* shape, below. */
|
|
346
|
+
empty: z.object({
|
|
347
|
+
title: z.string().min(1),
|
|
348
|
+
description: z.string().min(1).optional(),
|
|
349
|
+
icon: z.string().min(1).optional()
|
|
350
|
+
}).strict().optional(),
|
|
351
|
+
/** Home screen (spec 2026-08-27, H-1..H-5): Intercom-style landing view behind
|
|
352
|
+
* a Home/Messages tab bar. PRESENCE of `home` enables the tab chrome (H-4) —
|
|
353
|
+
* no `enabled` boolean, like `header`/`empty`. Every sub-key optional;
|
|
354
|
+
* `home: {}` still means "tabs on, defaults". Never requires another
|
|
355
|
+
* capability (H-3): the recent card simply renders nothing without
|
|
356
|
+
* `capabilities.conversations` (the CLI warns — see cli.ts). All strings are
|
|
357
|
+
* construct-authored/untrusted: JSON.stringify'd at every emit site; hrefs
|
|
358
|
+
* and URL-shaped icons through isSafeUrl in superRefine below. */
|
|
359
|
+
home: z.object({
|
|
360
|
+
greeting: z.object({ title: z.string().min(1).optional(), subtitle: z.string().min(1).optional() }).strict().optional(),
|
|
361
|
+
recentConversation: z.literal(true).optional(),
|
|
362
|
+
newConversation: z.object({ label: z.string().min(1).optional() }).strict().optional(),
|
|
363
|
+
links: z.array(
|
|
364
|
+
z.object({
|
|
365
|
+
label: z.string().min(1),
|
|
366
|
+
href: z.string().min(1).optional(),
|
|
367
|
+
description: z.string().min(1).optional(),
|
|
368
|
+
/** renderIcon name (e.g. 'book-open') or a safe URL — the icon NAME
|
|
369
|
+
* list is renderIcon's own vocabulary, never restated here; unknown
|
|
370
|
+
* names warn loudly at DEV runtime. */
|
|
371
|
+
icon: z.string().min(1).optional()
|
|
372
|
+
}).strict()
|
|
373
|
+
).optional()
|
|
374
|
+
}).strict().optional(),
|
|
375
|
+
// Capability vocabulary, widened one field at a time by later tasks.
|
|
376
|
+
capabilities: z.object({
|
|
377
|
+
/** Starter prompts shown on the empty thread; clicking one sends it.
|
|
378
|
+
* 1-6 non-empty strings — construct-authored text, escaped like
|
|
379
|
+
* `theme.accent`/`provider.url` at every emit interpolation site. */
|
|
380
|
+
starters: z.array(z.string().min(1)).min(1).max(6).optional(),
|
|
381
|
+
/** Enables the paperclip attach affordance; accept is a non-empty
|
|
382
|
+
* list of media types/globs, e.g. ["image/*", "application/pdf"] —
|
|
383
|
+
* WHETHER stays with the construct author (this field), HOW stays
|
|
384
|
+
* with the kit (ChatThread's own attach/accept props, threaded
|
|
385
|
+
* through by codegen). */
|
|
386
|
+
attachments: z.object({
|
|
387
|
+
/** Accept-list of media types/globs, e.g. ["image/*", "application/pdf"]. */
|
|
388
|
+
accept: z.array(z.string().min(1)).min(1)
|
|
389
|
+
}).strict().optional(),
|
|
390
|
+
/** Conversation persistence. `none` (default, nothing emitted): the
|
|
391
|
+
* thread lives only in memory for the tab's lifetime. `local`:
|
|
392
|
+
* persisted to this browser's localStorage, keyed by the construct's
|
|
393
|
+
* tag — a mechanism decision (WHERE); what to retain and for how
|
|
394
|
+
* long stays an app decision (component-scope-boundary), so no
|
|
395
|
+
* retention count/quota lands here. `endpoint`: the CONSUMER's own
|
|
396
|
+
* thread route (GET returns ChatMessage[], PUT stores them) —
|
|
397
|
+
* requires `url`; `url` is rejected for any other persistence
|
|
398
|
+
* (superRefine below, both directions loud). */
|
|
399
|
+
history: z.object({
|
|
400
|
+
persistence: z.enum(["none", "local", "endpoint"]),
|
|
401
|
+
/** endpoint persistence only: the CONSUMER's thread routes (GET returns
|
|
402
|
+
* ChatMessage[], PUT stores them). Refined below. */
|
|
403
|
+
url: z.string().min(1).optional()
|
|
404
|
+
}).strict().optional(),
|
|
405
|
+
/** How the model's thinking (reasoning parts) renders. `'full'`
|
|
406
|
+
* (the default when omitted — see codegen.ts's emitReasoningProp)
|
|
407
|
+
* is the collapsible "Thinking" disclosure, shimmering while it
|
|
408
|
+
* streams. `'compact'` shows only a shimmer/typing loader while
|
|
409
|
+
* reasoning streams, with no expandable detail. `'off'` hides
|
|
410
|
+
* reasoning entirely. This is HOW an existing medium fact (the
|
|
411
|
+
* model's thinking) displays — the kit's call — so it maps straight
|
|
412
|
+
* onto ChatThread's own `reasoning` prop; there is no app-layer
|
|
413
|
+
* quota or retention decision hiding in it. Left `.optional()`
|
|
414
|
+
* rather than `.default('full')`, matching every sibling field in
|
|
415
|
+
* this object: a zod `.default()` here would make `reasoning`
|
|
416
|
+
* REQUIRED on the inferred input type (z.infer is the output type),
|
|
417
|
+
* breaking every `capabilities: {...}` object literal in this file
|
|
418
|
+
* and its tests that doesn't mention it. */
|
|
419
|
+
reasoning: z.enum(["full", "compact", "off"]).optional(),
|
|
420
|
+
/** Seeds the reasoning disclosure open AND keeps it tracking the
|
|
421
|
+
* stream (open while streaming, closes when it settles) — the
|
|
422
|
+
* pre-Task-19f `full` behavior. Default false/absent: the panel
|
|
423
|
+
* starts closed (just the "Thinking" shimmer chip) and only opens
|
|
424
|
+
* on click — the current default (owner ruling, 2026-08-26).
|
|
425
|
+
* Meaningless when `reasoning` is `'compact'` (no expandable
|
|
426
|
+
* content exists) or `'off'` (nothing renders); rejected on both,
|
|
427
|
+
* loudly, below. */
|
|
428
|
+
reasoningOpen: z.boolean().optional(),
|
|
429
|
+
/** Turns on the prior-conversations list (C-1..C-9 of the
|
|
430
|
+
* conversations design). `true` only — there is no `false` form,
|
|
431
|
+
* matching this schema's other presence-only capability flags.
|
|
432
|
+
* Requires `capabilities.history.persistence` to be `local` or
|
|
433
|
+
* `endpoint` (superRefine below, loud): a conversation list with
|
|
434
|
+
* nowhere to persist conversations is not a coherent construct.
|
|
435
|
+
* WHAT persists (this field, plus history) stays construct
|
|
436
|
+
* vocabulary; HOW it persists (the ConversationStore adapter,
|
|
437
|
+
* localStorage vs. a fetch endpoint) is codegen's call, never
|
|
438
|
+
* vocabulary here (C-3 — no transport-layer vocabulary). */
|
|
439
|
+
conversations: z.literal(true).optional(),
|
|
440
|
+
/** Role-scoped default action bars, threaded onto ChatThread's
|
|
441
|
+
* `userActions`/`assistantActions` props (B-3/B-7b). Ordered
|
|
442
|
+
* arrays; enum ids ONLY, read off the ONE const
|
|
443
|
+
* (CHAT_MESSAGE_ACTIONS — B-6): a CustomAction is an id the APP
|
|
444
|
+
* must handle, a construct has no app code, so emitting one is a
|
|
445
|
+
* dead affordance. Duplicates within one array rejected below
|
|
446
|
+
* (superRefine, the slots pattern). min(1): an empty list IS the
|
|
447
|
+
* absent key. */
|
|
448
|
+
messageActions: z.object({
|
|
449
|
+
user: z.array(z.enum(CHAT_MESSAGE_ACTIONS)).min(1).optional(),
|
|
450
|
+
assistant: z.array(z.enum(CHAT_MESSAGE_ACTIONS)).min(1).optional()
|
|
451
|
+
}).strict().optional(),
|
|
452
|
+
/** The citations STRIP (the `part="citations"` row consecutive
|
|
453
|
+
* `source` parts already collapse into — message.tsx). `strip:
|
|
454
|
+
* false` hides it (emits ChatThread's `hideSources`); `strip:
|
|
455
|
+
* true` or the key absent emits NOTHING — the row already renders,
|
|
456
|
+
* the kit default IS the on state, the same anchored-on-the-
|
|
457
|
+
* default convention as `reasoning: 'full'` (B-4). */
|
|
458
|
+
sources: z.object({
|
|
459
|
+
strip: z.boolean().optional()
|
|
460
|
+
}).strict().optional()
|
|
461
|
+
}).strict().optional(),
|
|
462
|
+
/** Named generative-UI card definitions the model can emit as tool calls,
|
|
463
|
+
* rendered in the thread. Top-level (not a capability): a card is
|
|
464
|
+
* something the construct's model can DO at any point once a card is
|
|
465
|
+
* declared, not a thread-affordance toggle like starters/attachments/
|
|
466
|
+
* reasoning. Reuses the kit's own card-tool projection end to end
|
|
467
|
+
* (`cardTools`/`toOpenAITools`/`toAnthropicTools`, `@kitn.ai/ui/schemas`)
|
|
468
|
+
* — never a second one authored here. `schema` is validated structurally
|
|
469
|
+
* only; deep card validation (incl. `x-kai-format` mask hints) is the
|
|
470
|
+
* kit's own card contract at render time. */
|
|
471
|
+
cards: z.array(
|
|
472
|
+
z.object({
|
|
473
|
+
/** Tool-facing card name, e.g. "refund_approval". */
|
|
474
|
+
name: z.string().regex(/^[a-z][a-z0-9_]*$/),
|
|
475
|
+
/** The kit's card schema JSON (incl. x-kai-format mask hints).
|
|
476
|
+
* Validated structurally here; deep card validation is the kit's
|
|
477
|
+
* own card contract at render time. */
|
|
478
|
+
schema: z.record(z.string(), z.unknown())
|
|
479
|
+
}).strict()
|
|
480
|
+
).min(1).optional(),
|
|
481
|
+
/** Named `<slot>` projection points the emitted web component exposes —
|
|
482
|
+
* the format's ONLY escape hatch (named slots, no code-in-JSON). Each
|
|
483
|
+
* name must be a valid HTML slot-attribute value AND a legible
|
|
484
|
+
* identifier: kebab-case, starting with a letter (the same shape as a
|
|
485
|
+
* CSS custom-ident) — `slot name="Header!"` is rejected, not sanitized,
|
|
486
|
+
* matching the format's loud-rejection discipline everywhere else.
|
|
487
|
+
* 1-8 entries, no duplicates (superRefine below — a regex alone can't
|
|
488
|
+
* see across array entries). `layout: 'custom'` requires at least one
|
|
489
|
+
* declared slot (superRefine below): `custom` IS the slots grain — a
|
|
490
|
+
* `custom` layout with nothing to project into is not meaningfully
|
|
491
|
+
* different from `fullscreen`. */
|
|
492
|
+
slots: z.array(z.string().regex(/^[a-z][a-z0-9-]*$/, "slot names must be kebab-case, starting with a letter")).min(1).max(8).optional(),
|
|
493
|
+
/** Layout-scoped FAB chrome, `layout: 'widget'` only (superRefine below).
|
|
494
|
+
* Purely additive sibling to `layout` (widen-never-restructure) — mirrors
|
|
495
|
+
* Dock's own props verbatim, threaded through by codegen's emitLayoutOpen. */
|
|
496
|
+
widget: z.object({
|
|
497
|
+
/** Which corner. Mirrors Dock's own DockPosition enum verbatim — not a
|
|
498
|
+
* new left/right binary. Omitted keeps Dock's own default (bottom-end). */
|
|
499
|
+
position: z.enum(["bottom-end", "bottom-start", "top-end", "top-start"]).optional(),
|
|
500
|
+
/** An image URL for the closed-state launcher glyph, replacing Dock's
|
|
501
|
+
* built-in chat-bubble icon. Construct-authored/untrusted text, like
|
|
502
|
+
* theme.accent and provider.url — escaped the same way at its one emit site. */
|
|
503
|
+
launcherIcon: z.string().min(1).optional(),
|
|
504
|
+
/** Seed Dock's open state at mount. Uncontrolled — never steals focus (Dock's
|
|
505
|
+
* own focus contract, dock.tsx). Omitted keeps Dock's own default (closed). */
|
|
506
|
+
defaultOpen: z.boolean().optional()
|
|
507
|
+
}).strict().optional(),
|
|
508
|
+
/** Layout-scoped aside geometry, `layout: 'aside'` only (superRefine
|
|
509
|
+
* below, mirroring `widget`'s scoping exactly). `position` picks the
|
|
510
|
+
* docked inline edge (default 'end', today's hardcoded behavior);
|
|
511
|
+
* `width` overrides codegen's 380px default. `width` is construct-
|
|
512
|
+
* authored untrusted text: it lands as a JSON.stringify'd VALUE inside
|
|
513
|
+
* the emitted Solid `style={{ }}` object — property assignment, the
|
|
514
|
+
* same no-CSS-text-interpolation guarantee as setProperty — never
|
|
515
|
+
* concatenated into a CSS string (B-2). */
|
|
516
|
+
aside: z.object({
|
|
517
|
+
position: z.enum(["start", "end"]).optional(),
|
|
518
|
+
width: z.string().min(1).optional()
|
|
519
|
+
}).strict().optional(),
|
|
520
|
+
/** Layout-scoped work-surface pane, `layout: 'split'` only (superRefine
|
|
521
|
+
* below, mirroring `widget`/`aside`). Fills the split's main region,
|
|
522
|
+
* which otherwise reserves a column and renders nothing — the defect
|
|
523
|
+
* this key exists to remove. Emitted as `<slot name="pane">` FALLBACK
|
|
524
|
+
* content, so a consumer projecting their own pane still WINS (native
|
|
525
|
+
* slot semantics: assigned nodes replace fallback).
|
|
526
|
+
*
|
|
527
|
+
* Backed by `components/work-surface/work-surface.tsx`'s `WorkSurface`, promoted from
|
|
528
|
+
* `stories/showcase/builder-workspace.stories.tsx` — the approved design AND a
|
|
529
|
+
* working implementation. Every key below is one real affordance that
|
|
530
|
+
* component ships; an affordance with no mechanism is not here.
|
|
531
|
+
*
|
|
532
|
+
* TOP-LEVEL, not a capability: it is layout chrome, the same class as
|
|
533
|
+
* `widget`/`aside`, and the placement is forced by the gate as well —
|
|
534
|
+
* `scripts/verify-construct.mjs` can only layout-scope TOP-LEVEL keys
|
|
535
|
+
* (`TOP_LEVEL_LAYOUT_SCOPE`); a capability valid only on `split` would
|
|
536
|
+
* make every non-split capability cell fail validation.
|
|
537
|
+
*
|
|
538
|
+
* `sandbox` is deliberately NOT exposed — the same reasoning
|
|
539
|
+
* `ArtifactCardData` already records: a surface someone else authored
|
|
540
|
+
* must not be able to widen its own sandbox. */
|
|
541
|
+
workSurface: z.object({
|
|
542
|
+
/** What the pane FRAMES it as. `'preview'` fills the canvas edge to
|
|
543
|
+
* edge (a browser preview); `'artifact'` centers the content in a
|
|
544
|
+
* bordered card on the muted backdrop (a framed artifact). Also
|
|
545
|
+
* picks the iframe's accessible title. It does NOT imply any
|
|
546
|
+
* chrome: every affordance below is stated explicitly, so what the
|
|
547
|
+
* builder panel shows and what the pane renders can never disagree. */
|
|
548
|
+
kind: z.enum(["artifact", "preview"]),
|
|
549
|
+
/** What the pane frames. REQUIRED — an optional url reproduces
|
|
550
|
+
* exactly the empty pane this round exists to remove. Reaches an
|
|
551
|
+
* iframe `src`, so isSafeUrl in superRefine below, the same shape
|
|
552
|
+
* as `widget.launcherIcon` / `empty.icon`. */
|
|
553
|
+
url: z.string().min(1),
|
|
554
|
+
/** What the Code tab frames. OPTIONAL even with `chrome.codeView` on:
|
|
555
|
+
* the tab then renders `WorkSurface`'s own empty state, which says
|
|
556
|
+
* what it reads and names this key (owner ruling, 2026-08-30). The
|
|
557
|
+
* coupling runs ONE way (superRefine below) — a source with no tab to
|
|
558
|
+
* show it is unreachable, so `codeUrl` alone is still rejected. Same
|
|
559
|
+
* url policy as `url`. */
|
|
560
|
+
codeUrl: z.string().min(1).optional(),
|
|
561
|
+
/** Per-affordance toolbar chrome. Each key is ONE affordance
|
|
562
|
+
* `WorkSurface` really ships; absent means OFF, the same
|
|
563
|
+
* off-by-default convention as every capability in this file. */
|
|
564
|
+
chrome: z.object({
|
|
565
|
+
/** Desktop/tablet/mobile canvas widths, scoping the PREVIEW
|
|
566
|
+
* branch only (never the Code view) — Lovable's own rule,
|
|
567
|
+
* carried through the story. */
|
|
568
|
+
deviceToggle: z.boolean().optional(),
|
|
569
|
+
/** The read-only address bar (lock icon + address text). */
|
|
570
|
+
urlBar: z.boolean().optional(),
|
|
571
|
+
/** The open-in-new-tab button. */
|
|
572
|
+
openInNewTab: z.boolean().optional(),
|
|
573
|
+
/** The expand toggle. Collapses the chat rail via
|
|
574
|
+
* WorkspaceShell's own controlled `startCollapsed` — NOT the
|
|
575
|
+
* kai-resizable maximize protocol, which WorkspaceShell does
|
|
576
|
+
* not carry (recorded in the story's own module comment). */
|
|
577
|
+
expand: z.boolean().optional(),
|
|
578
|
+
/** The Preview|Code segmented toggle. Stands alone — with no
|
|
579
|
+
* `codeUrl` the Code tab renders its empty state rather than
|
|
580
|
+
* refusing to validate. */
|
|
581
|
+
codeView: z.boolean().optional()
|
|
582
|
+
}).strict().optional()
|
|
583
|
+
}).strict().optional(),
|
|
584
|
+
/** Construct-wide shell chrome (10a): both members reuse REAL kit
|
|
585
|
+
* pieces — command.tsx's CommandList behind a codegen-emitted overlay
|
|
586
|
+
* (opened on Mod+K; entries DERIVE from what this construct enables —
|
|
587
|
+
* menu-honesty against dead entries), and the documented Dropdown+
|
|
588
|
+
* Avatar user-menu recipe. `name`/`plan` are construct-authored
|
|
589
|
+
* untrusted text, JSON.stringify'd at emit like every sibling.
|
|
590
|
+
* `commandPalette` is presence-only `z.literal(true)`, matching
|
|
591
|
+
* `conversations`' pattern. */
|
|
592
|
+
shell: z.object({
|
|
593
|
+
commandPalette: z.literal(true).optional(),
|
|
594
|
+
userMenu: z.object({
|
|
595
|
+
name: z.string().min(1),
|
|
596
|
+
plan: z.string().min(1).optional()
|
|
597
|
+
}).strict().optional()
|
|
598
|
+
}).strict().optional(),
|
|
599
|
+
/** Composer chrome — NOT a capability: chrome on the medium, like
|
|
600
|
+
* `header` (B-5). `triggers` maps onto ChatThread's real, shipped
|
|
601
|
+
* `triggers` prop: `slash` → `{ char: '/', kind: 'command', items }`,
|
|
602
|
+
* `mention` → `{ char: '@', kind: 'mention', items }` at emit. */
|
|
603
|
+
composer: z.object({
|
|
604
|
+
triggers: z.object({
|
|
605
|
+
slash: z.array(TriggerEntrySchema).min(1).optional(),
|
|
606
|
+
mention: z.array(TriggerEntrySchema).min(1).optional()
|
|
607
|
+
}).strict().optional()
|
|
608
|
+
}).strict().optional()
|
|
609
|
+
}).strict().superRefine((construct, ctx) => {
|
|
610
|
+
for (const rule of CROSS_FIELD_RULES) rule.check(construct, ctx);
|
|
611
|
+
});
|
|
612
|
+
const CROSS_FIELD_RULES = [
|
|
613
|
+
{
|
|
614
|
+
id: "slots-unique",
|
|
615
|
+
paths: ["slots"],
|
|
616
|
+
check: (construct, ctx) => {
|
|
617
|
+
if (!construct.slots) return;
|
|
618
|
+
const seen = /* @__PURE__ */ new Set();
|
|
619
|
+
construct.slots.forEach((name, i) => {
|
|
620
|
+
if (seen.has(name)) {
|
|
621
|
+
ctx.addIssue({
|
|
622
|
+
code: z.ZodIssueCode.custom,
|
|
623
|
+
path: ["slots", i],
|
|
624
|
+
message: `duplicate slot name "${name}"`
|
|
625
|
+
});
|
|
626
|
+
}
|
|
627
|
+
seen.add(name);
|
|
628
|
+
});
|
|
629
|
+
}
|
|
630
|
+
},
|
|
631
|
+
{
|
|
632
|
+
id: "custom-layout-needs-slots",
|
|
633
|
+
paths: ["layout", "slots"],
|
|
634
|
+
check: (construct, ctx) => {
|
|
635
|
+
if (construct.layout === "custom" && (!construct.slots || construct.slots.length === 0)) {
|
|
636
|
+
ctx.addIssue({
|
|
637
|
+
code: z.ZodIssueCode.custom,
|
|
638
|
+
path: ["slots"],
|
|
639
|
+
message: '"custom" layout requires at least one declared slot — custom IS the slots grain'
|
|
640
|
+
});
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
},
|
|
644
|
+
{
|
|
645
|
+
id: "split-pane-slot-collision",
|
|
646
|
+
paths: ["layout", "slots"],
|
|
647
|
+
check: (construct, ctx) => {
|
|
648
|
+
if (construct.layout === "split" && construct.slots) {
|
|
649
|
+
const i = construct.slots.indexOf("pane");
|
|
650
|
+
if (i !== -1) {
|
|
651
|
+
ctx.addIssue({
|
|
652
|
+
code: z.ZodIssueCode.custom,
|
|
653
|
+
path: ["slots", i],
|
|
654
|
+
message: `"pane" collides with the "split" layout's own fixed <slot name="pane"> — choose a different slot name`
|
|
655
|
+
});
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
},
|
|
660
|
+
{
|
|
661
|
+
id: "widget-layout-scope",
|
|
662
|
+
paths: ["layout", "widget"],
|
|
663
|
+
check: (construct, ctx) => {
|
|
664
|
+
if (construct.widget && construct.layout !== "widget") {
|
|
665
|
+
ctx.addIssue({
|
|
666
|
+
code: z.ZodIssueCode.custom,
|
|
667
|
+
path: ["widget"],
|
|
668
|
+
message: '"widget" is only valid on layout: "widget"'
|
|
669
|
+
});
|
|
670
|
+
}
|
|
671
|
+
}
|
|
672
|
+
},
|
|
673
|
+
{
|
|
674
|
+
id: "aside-layout-scope",
|
|
675
|
+
paths: ["layout", "aside"],
|
|
676
|
+
check: (construct, ctx) => {
|
|
677
|
+
if (construct.aside && construct.layout !== "aside") {
|
|
678
|
+
ctx.addIssue({
|
|
679
|
+
code: z.ZodIssueCode.custom,
|
|
680
|
+
path: ["aside"],
|
|
681
|
+
message: '"aside" is only valid on layout: "aside"'
|
|
682
|
+
});
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
},
|
|
686
|
+
{
|
|
687
|
+
id: "message-actions-unique",
|
|
688
|
+
paths: ["capabilities.messageActions.user", "capabilities.messageActions.assistant"],
|
|
689
|
+
check: (construct, ctx) => {
|
|
690
|
+
const messageActions = construct.capabilities?.messageActions;
|
|
691
|
+
if (!messageActions) return;
|
|
692
|
+
for (const role of ["user", "assistant"]) {
|
|
693
|
+
const list = messageActions[role];
|
|
694
|
+
if (!list) continue;
|
|
695
|
+
const seen = /* @__PURE__ */ new Set();
|
|
696
|
+
list.forEach((id, i) => {
|
|
697
|
+
if (seen.has(id)) {
|
|
698
|
+
ctx.addIssue({
|
|
699
|
+
code: z.ZodIssueCode.custom,
|
|
700
|
+
path: ["capabilities", "messageActions", role, i],
|
|
701
|
+
message: `duplicate action id "${id}"`
|
|
702
|
+
});
|
|
703
|
+
}
|
|
704
|
+
seen.add(id);
|
|
705
|
+
});
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
},
|
|
709
|
+
{
|
|
710
|
+
id: "launcher-icon-url",
|
|
711
|
+
paths: ["widget.launcherIcon"],
|
|
712
|
+
check: (construct, ctx) => {
|
|
713
|
+
if (construct.widget?.launcherIcon && !isSafeUrl(construct.widget.launcherIcon)) {
|
|
714
|
+
ctx.addIssue({
|
|
715
|
+
code: z.ZodIssueCode.custom,
|
|
716
|
+
path: ["widget", "launcherIcon"],
|
|
717
|
+
message: "launcherIcon must be an http(s)/mailto or relative URL — no javascript:/data: schemes"
|
|
718
|
+
});
|
|
719
|
+
}
|
|
720
|
+
}
|
|
721
|
+
},
|
|
722
|
+
{
|
|
723
|
+
id: "empty-icon-url",
|
|
724
|
+
paths: ["empty.icon"],
|
|
725
|
+
check: (construct, ctx) => {
|
|
726
|
+
if (construct.empty?.icon && !isSafeUrl(construct.empty.icon)) {
|
|
727
|
+
ctx.addIssue({
|
|
728
|
+
code: z.ZodIssueCode.custom,
|
|
729
|
+
path: ["empty", "icon"],
|
|
730
|
+
message: "icon must be an http(s)/mailto or relative URL — no javascript:/data: schemes"
|
|
731
|
+
});
|
|
732
|
+
}
|
|
733
|
+
}
|
|
734
|
+
},
|
|
735
|
+
{
|
|
736
|
+
id: "reasoning-open-scope",
|
|
737
|
+
paths: ["capabilities.reasoning", "capabilities.reasoningOpen"],
|
|
738
|
+
check: (construct, ctx) => {
|
|
739
|
+
const reasoning = construct.capabilities?.reasoning;
|
|
740
|
+
const reasoningOpen = construct.capabilities?.reasoningOpen;
|
|
741
|
+
if (reasoningOpen !== void 0 && (reasoning === "compact" || reasoning === "off")) {
|
|
742
|
+
ctx.addIssue({
|
|
743
|
+
code: z.ZodIssueCode.custom,
|
|
744
|
+
path: ["capabilities", "reasoningOpen"],
|
|
745
|
+
message: '"reasoningOpen" only applies when reasoning is "full" or omitted — "compact"/"off" have no disclosure to open'
|
|
746
|
+
});
|
|
747
|
+
}
|
|
748
|
+
}
|
|
749
|
+
},
|
|
750
|
+
{
|
|
751
|
+
id: "conversations-need-history",
|
|
752
|
+
paths: ["capabilities.conversations", "capabilities.history"],
|
|
753
|
+
check: (construct, ctx) => {
|
|
754
|
+
const history = construct.capabilities?.history;
|
|
755
|
+
if (construct.capabilities?.conversations && (!history || history.persistence === "none")) {
|
|
756
|
+
ctx.addIssue({
|
|
757
|
+
code: z.ZodIssueCode.custom,
|
|
758
|
+
path: ["capabilities", "conversations"],
|
|
759
|
+
message: '"conversations" requires capabilities.history.persistence to be "local" or "endpoint" — a conversation list needs somewhere to persist conversations'
|
|
760
|
+
});
|
|
761
|
+
}
|
|
762
|
+
}
|
|
763
|
+
},
|
|
764
|
+
{
|
|
765
|
+
id: "home-link-urls",
|
|
766
|
+
paths: ["home.links"],
|
|
767
|
+
check: (construct, ctx) => {
|
|
768
|
+
const URL_SHAPED = /^[a-zA-Z][a-zA-Z0-9+.-]*:|^\/\//;
|
|
769
|
+
for (const [i, link] of (construct.home?.links ?? []).entries()) {
|
|
770
|
+
if (link.href && !isSafeUrl(link.href)) {
|
|
771
|
+
ctx.addIssue({
|
|
772
|
+
code: z.ZodIssueCode.custom,
|
|
773
|
+
path: ["home", "links", i, "href"],
|
|
774
|
+
message: "href must be an http(s)/mailto or relative URL — no javascript:/data: schemes"
|
|
775
|
+
});
|
|
776
|
+
}
|
|
777
|
+
if (link.icon && URL_SHAPED.test(link.icon) && !isSafeUrl(link.icon)) {
|
|
778
|
+
ctx.addIssue({
|
|
779
|
+
code: z.ZodIssueCode.custom,
|
|
780
|
+
path: ["home", "links", i, "icon"],
|
|
781
|
+
message: "icon must be a kit icon name or an http(s)/relative URL — no javascript:/data: schemes"
|
|
782
|
+
});
|
|
783
|
+
}
|
|
784
|
+
}
|
|
785
|
+
}
|
|
786
|
+
},
|
|
787
|
+
{
|
|
788
|
+
id: "history-endpoint-url",
|
|
789
|
+
paths: ["capabilities.history.persistence", "capabilities.history.url"],
|
|
790
|
+
check: (construct, ctx) => {
|
|
791
|
+
const history = construct.capabilities?.history;
|
|
792
|
+
if (!history) return;
|
|
793
|
+
if (history.persistence === "endpoint" && !history.url) {
|
|
794
|
+
ctx.addIssue({
|
|
795
|
+
code: z.ZodIssueCode.custom,
|
|
796
|
+
path: ["capabilities", "history", "url"],
|
|
797
|
+
message: '"endpoint" persistence requires a url'
|
|
798
|
+
});
|
|
799
|
+
}
|
|
800
|
+
if (history.persistence !== "endpoint" && history.url !== void 0) {
|
|
801
|
+
ctx.addIssue({
|
|
802
|
+
code: z.ZodIssueCode.custom,
|
|
803
|
+
path: ["capabilities", "history", "url"],
|
|
804
|
+
message: 'url is only valid with "endpoint" persistence'
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
}
|
|
808
|
+
},
|
|
809
|
+
{
|
|
810
|
+
id: "work-surface-layout-scope",
|
|
811
|
+
paths: ["layout", "workSurface"],
|
|
812
|
+
check: (construct, ctx) => {
|
|
813
|
+
if (construct.workSurface && construct.layout !== "split") {
|
|
814
|
+
ctx.addIssue({
|
|
815
|
+
code: z.ZodIssueCode.custom,
|
|
816
|
+
path: ["workSurface"],
|
|
817
|
+
message: '"workSurface" is only valid on layout: "split"'
|
|
818
|
+
});
|
|
819
|
+
}
|
|
820
|
+
}
|
|
821
|
+
},
|
|
822
|
+
{
|
|
823
|
+
id: "work-surface-url",
|
|
824
|
+
paths: ["workSurface.url"],
|
|
825
|
+
check: (construct, ctx) => {
|
|
826
|
+
if (construct.workSurface && !isSafeUrl(construct.workSurface.url)) {
|
|
827
|
+
ctx.addIssue({
|
|
828
|
+
code: z.ZodIssueCode.custom,
|
|
829
|
+
path: ["workSurface", "url"],
|
|
830
|
+
message: "url must be an http(s)/mailto or relative URL — no javascript:/data: schemes"
|
|
831
|
+
});
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
},
|
|
835
|
+
{
|
|
836
|
+
id: "work-surface-code-url",
|
|
837
|
+
paths: ["workSurface.codeUrl"],
|
|
838
|
+
check: (construct, ctx) => {
|
|
839
|
+
const codeUrl = construct.workSurface?.codeUrl;
|
|
840
|
+
if (codeUrl && !isSafeUrl(codeUrl)) {
|
|
841
|
+
ctx.addIssue({
|
|
842
|
+
code: z.ZodIssueCode.custom,
|
|
843
|
+
path: ["workSurface", "codeUrl"],
|
|
844
|
+
message: "codeUrl must be an http(s)/mailto or relative URL — no javascript:/data: schemes"
|
|
845
|
+
});
|
|
846
|
+
}
|
|
847
|
+
}
|
|
848
|
+
},
|
|
849
|
+
{
|
|
850
|
+
id: "work-surface-code-view",
|
|
851
|
+
paths: ["workSurface.codeUrl", "workSurface.chrome.codeView"],
|
|
852
|
+
check: (construct, ctx) => {
|
|
853
|
+
const ws = construct.workSurface;
|
|
854
|
+
if (!ws) return;
|
|
855
|
+
if (ws.codeUrl && !ws.chrome?.codeView) {
|
|
856
|
+
ctx.addIssue({
|
|
857
|
+
code: z.ZodIssueCode.custom,
|
|
858
|
+
path: ["workSurface", "codeUrl"],
|
|
859
|
+
message: 'codeUrl is only valid with "chrome.codeView": true'
|
|
860
|
+
});
|
|
861
|
+
}
|
|
862
|
+
}
|
|
863
|
+
}
|
|
864
|
+
];
|
|
865
|
+
function validateConstruct(input) {
|
|
866
|
+
const parsed = ConstructSchema.safeParse(input);
|
|
867
|
+
if (parsed.success) return { ok: true, construct: parsed.data };
|
|
868
|
+
return {
|
|
869
|
+
ok: false,
|
|
870
|
+
problems: parsed.error.issues.flatMap((issue) => {
|
|
871
|
+
if (issue.code === "unrecognized_keys") {
|
|
872
|
+
return issue.keys.map((key) => ({
|
|
873
|
+
path: [...issue.path.map(String), key].join("."),
|
|
874
|
+
message: `"${key}" is not construct vocabulary`
|
|
875
|
+
}));
|
|
876
|
+
}
|
|
877
|
+
return [
|
|
878
|
+
{
|
|
879
|
+
path: issue.path.map(String).join("."),
|
|
880
|
+
message: issue.message
|
|
881
|
+
}
|
|
882
|
+
];
|
|
883
|
+
})
|
|
884
|
+
};
|
|
885
|
+
}
|
|
886
|
+
const IDENTITY = { id: "identity", paths: ["name"] };
|
|
887
|
+
const THEME = { id: "theme", paths: ["theme.accent", "theme.mode", "theme.unreadColor"] };
|
|
888
|
+
const HEADER = { id: "header", paths: ["header.title"] };
|
|
889
|
+
const HEADER_CHROME = {
|
|
890
|
+
id: "header",
|
|
891
|
+
paths: ["header.title", "header.themeToggle", "header.actions"],
|
|
892
|
+
hints: {
|
|
893
|
+
"header.actions": "Each button dispatches `kai-header-action` for your app to handle — nothing happens until you listen."
|
|
894
|
+
}
|
|
895
|
+
};
|
|
896
|
+
const EMPTY = { id: "empty", paths: ["empty.title", "empty.description", "empty.icon"] };
|
|
897
|
+
const HOME = { id: "home", paths: ["home"] };
|
|
898
|
+
const CAPABILITIES = {
|
|
899
|
+
id: "capabilities",
|
|
900
|
+
paths: [
|
|
901
|
+
"capabilities.starters",
|
|
902
|
+
"capabilities.attachments",
|
|
903
|
+
"capabilities.history",
|
|
904
|
+
"capabilities.conversations",
|
|
905
|
+
"capabilities.reasoning",
|
|
906
|
+
"capabilities.reasoningOpen"
|
|
907
|
+
],
|
|
908
|
+
hints: {
|
|
909
|
+
"capabilities.history": "Endpoint needs a thread route you host. Local keeps history in this browser — no backend, nothing metered.",
|
|
910
|
+
"capabilities.reasoningOpen": "Off by owner ruling (2026-08-26): the thinking panel starts closed and opens on click."
|
|
911
|
+
}
|
|
912
|
+
};
|
|
913
|
+
const MESSAGE_ACTIONS = {
|
|
914
|
+
id: "messageActions",
|
|
915
|
+
paths: ["capabilities.messageActions.user", "capabilities.messageActions.assistant"]
|
|
916
|
+
};
|
|
917
|
+
const SOURCES = { id: "sources", paths: ["capabilities.sources.strip"] };
|
|
918
|
+
const WIDGET_CHROME = {
|
|
919
|
+
id: "widget",
|
|
920
|
+
paths: ["widget.position", "widget.launcherIcon", "widget.defaultOpen"]
|
|
921
|
+
};
|
|
922
|
+
const ASIDE = { id: "aside", paths: ["aside.position", "aside.width"] };
|
|
923
|
+
const WORK_SURFACE = {
|
|
924
|
+
id: "workSurface",
|
|
925
|
+
paths: [
|
|
926
|
+
"workSurface.kind",
|
|
927
|
+
"workSurface.url",
|
|
928
|
+
"workSurface.codeUrl",
|
|
929
|
+
"workSurface.chrome.deviceToggle",
|
|
930
|
+
"workSurface.chrome.urlBar",
|
|
931
|
+
"workSurface.chrome.openInNewTab",
|
|
932
|
+
"workSurface.chrome.expand",
|
|
933
|
+
"workSurface.chrome.codeView"
|
|
934
|
+
],
|
|
935
|
+
hints: {
|
|
936
|
+
// Both hints rewritten 2026-08-30 with the one-way coupling: the toggle no
|
|
937
|
+
// longer needs a URL to be switched on, so the old "leave it blank and the
|
|
938
|
+
// tab stays hidden" was describing a rule that no longer exists.
|
|
939
|
+
"workSurface.codeUrl": "The Code tab reads source from this URL. Leave it blank and the tab says so — it never frames a missing page.",
|
|
940
|
+
"workSurface.chrome.codeView": "Shows the Preview|Code toggle. Fine to switch on before you have a Code URL."
|
|
941
|
+
}
|
|
942
|
+
};
|
|
943
|
+
const COMPOSER_TRIGGERS = {
|
|
944
|
+
id: "composerTriggers",
|
|
945
|
+
paths: ["composer.triggers.slash", "composer.triggers.mention"]
|
|
946
|
+
};
|
|
947
|
+
const SHELL = { id: "shell", paths: ["shell.commandPalette", "shell.userMenu"] };
|
|
948
|
+
const CARDS = {
|
|
949
|
+
id: "cards",
|
|
950
|
+
paths: ["cards"],
|
|
951
|
+
// Rewritten with S-1's scripted mocks: the old "the mock provider never
|
|
952
|
+
// emits one" stopped being true when mockScriptFor started scripting a
|
|
953
|
+
// `kai_<card>` call for any construct that declares a card.
|
|
954
|
+
hints: { cards: "Cards arrive as tool calls from a model. Declare one and the mock scripts a call to it, so it renders keylessly." }
|
|
955
|
+
};
|
|
956
|
+
const PROVIDER = {
|
|
957
|
+
id: "provider",
|
|
958
|
+
paths: ["provider"],
|
|
959
|
+
hints: { provider: "Endpoint needs your own chat route. Mock streams locally, with no key and no bill." }
|
|
960
|
+
};
|
|
961
|
+
const widgetStarter = {
|
|
962
|
+
$schema: CONSTRUCT_SCHEMA_URL,
|
|
963
|
+
name: "support-widget",
|
|
964
|
+
layout: "widget",
|
|
965
|
+
provider: { mode: "mock" },
|
|
966
|
+
header: { title: "Support" },
|
|
967
|
+
theme: { mode: "system" },
|
|
968
|
+
empty: {
|
|
969
|
+
title: "Hi, we're here to help",
|
|
970
|
+
description: "Ask us about orders, refunds, and more."
|
|
971
|
+
},
|
|
972
|
+
home: {
|
|
973
|
+
greeting: { title: "How can we help? 👋", subtitle: "Orders, refunds, anything." },
|
|
974
|
+
recentConversation: true,
|
|
975
|
+
links: [
|
|
976
|
+
{ label: "Help center", href: "https://ui.kitn.ai", description: "Guides and FAQs", icon: "book-open" }
|
|
977
|
+
]
|
|
978
|
+
},
|
|
979
|
+
// States the kit default loudly (the anchored-on-the-default convention,
|
|
980
|
+
// same as research's sources.strip) so the template's chrome fact is
|
|
981
|
+
// visible/editable in its own JSON.
|
|
982
|
+
widget: { position: "bottom-end" },
|
|
983
|
+
capabilities: {
|
|
984
|
+
starters: ["Where's my order?", "Request a refund"],
|
|
985
|
+
attachments: { accept: ["image/*", "application/pdf"] },
|
|
986
|
+
history: { persistence: "local" },
|
|
987
|
+
conversations: true,
|
|
988
|
+
// Stated, not implied — the anchored-on-the-default convention (B-4), so
|
|
989
|
+
// the fact is visible and editable in the template's own JSON.
|
|
990
|
+
reasoning: "full",
|
|
991
|
+
// The owner's A3 default matrix (builder-message-actions.tsx).
|
|
992
|
+
messageActions: { user: ["edit"], assistant: ["copy", "like", "dislike"] }
|
|
993
|
+
}
|
|
994
|
+
};
|
|
995
|
+
const inAppAssistantStarter = {
|
|
996
|
+
$schema: CONSTRUCT_SCHEMA_URL,
|
|
997
|
+
name: "in-app-assistant",
|
|
998
|
+
layout: "aside",
|
|
999
|
+
provider: { mode: "mock" },
|
|
1000
|
+
header: { title: "Assistant", themeToggle: true },
|
|
1001
|
+
// Dark-by-default (owner ruling, dark round): every buildable starter
|
|
1002
|
+
// EXCEPT widget ships mode: 'dark' — widget is embedded in a host site and
|
|
1003
|
+
// follows IT, not its own preference (T-3: this is registry data, not a
|
|
1004
|
+
// schema default; 'system' stays the schema's own default for anyone
|
|
1005
|
+
// hand-authoring a construct).
|
|
1006
|
+
theme: { mode: "dark" },
|
|
1007
|
+
// codegen's own defaults, stated so the geometry is visible/editable.
|
|
1008
|
+
aside: { position: "end", width: "380px" },
|
|
1009
|
+
empty: {
|
|
1010
|
+
title: "What can I help with?",
|
|
1011
|
+
description: "Ask about this page, or anything else."
|
|
1012
|
+
},
|
|
1013
|
+
composer: {
|
|
1014
|
+
triggers: {
|
|
1015
|
+
slash: [
|
|
1016
|
+
{ id: "summarize", label: "summarize", description: "Summarize the thread so far" },
|
|
1017
|
+
{ id: "explain", label: "explain", description: "Explain the current page" }
|
|
1018
|
+
],
|
|
1019
|
+
mention: [
|
|
1020
|
+
{ id: "docs", label: "docs", description: "Search the documentation" },
|
|
1021
|
+
{ id: "support", label: "support", description: "Hand off to a person" }
|
|
1022
|
+
]
|
|
1023
|
+
}
|
|
1024
|
+
},
|
|
1025
|
+
capabilities: {
|
|
1026
|
+
starters: ["Deploy payments to production", "Check the canary status"],
|
|
1027
|
+
attachments: { accept: ["image/*", "application/pdf"] },
|
|
1028
|
+
history: { persistence: "local" },
|
|
1029
|
+
conversations: true,
|
|
1030
|
+
reasoning: "full",
|
|
1031
|
+
messageActions: { user: ["edit"], assistant: ["copy", "like", "dislike"] }
|
|
1032
|
+
}
|
|
1033
|
+
};
|
|
1034
|
+
const assistantStarter = {
|
|
1035
|
+
$schema: CONSTRUCT_SCHEMA_URL,
|
|
1036
|
+
name: "daily-assistant",
|
|
1037
|
+
layout: "fullscreen",
|
|
1038
|
+
provider: { mode: "mock" },
|
|
1039
|
+
header: { title: "Assistant", themeToggle: true },
|
|
1040
|
+
// Dark-by-default (owner ruling, dark round) — see inAppAssistantStarter's
|
|
1041
|
+
// note above.
|
|
1042
|
+
theme: { mode: "dark" },
|
|
1043
|
+
shell: { commandPalette: true, userMenu: { name: "Ada", plan: "Pro" } },
|
|
1044
|
+
empty: {
|
|
1045
|
+
title: "What can I help with?",
|
|
1046
|
+
description: "Ask anything, or start from a suggestion below."
|
|
1047
|
+
},
|
|
1048
|
+
capabilities: {
|
|
1049
|
+
starters: ["Draft the Q3 board update", "Summarize a document", "Compare two options"],
|
|
1050
|
+
attachments: { accept: ["image/*", "application/pdf"] },
|
|
1051
|
+
history: { persistence: "local" },
|
|
1052
|
+
conversations: true,
|
|
1053
|
+
reasoning: "full",
|
|
1054
|
+
messageActions: { user: ["edit"], assistant: ["copy", "like", "dislike"] }
|
|
1055
|
+
}
|
|
1056
|
+
};
|
|
1057
|
+
const researchStarter = {
|
|
1058
|
+
$schema: CONSTRUCT_SCHEMA_URL,
|
|
1059
|
+
name: "research-assistant",
|
|
1060
|
+
layout: "fullscreen",
|
|
1061
|
+
provider: { mode: "mock" },
|
|
1062
|
+
header: { title: "Research", themeToggle: true },
|
|
1063
|
+
// Dark-by-default (owner ruling, dark round) — see inAppAssistantStarter's
|
|
1064
|
+
// note above.
|
|
1065
|
+
theme: { mode: "dark" },
|
|
1066
|
+
empty: {
|
|
1067
|
+
title: "What do you want to know?",
|
|
1068
|
+
description: "Answers come back with their sources attached."
|
|
1069
|
+
},
|
|
1070
|
+
capabilities: {
|
|
1071
|
+
starters: ["How does the wire adapter work?", "What are message parts?"],
|
|
1072
|
+
attachments: { accept: ["application/pdf"] },
|
|
1073
|
+
history: { persistence: "local" },
|
|
1074
|
+
conversations: true,
|
|
1075
|
+
reasoning: "full",
|
|
1076
|
+
// The template's defining fact, stated even though it matches the emit
|
|
1077
|
+
// default (B-4): the row already renders; strip: true is the visible
|
|
1078
|
+
// switch this template exists around.
|
|
1079
|
+
sources: { strip: true },
|
|
1080
|
+
// "assistant-style actions" (B-13) — the owner's A3 default matrix
|
|
1081
|
+
// (builder-message-actions.tsx: user Edit on; assistant
|
|
1082
|
+
// Copy/Like/Dislike on).
|
|
1083
|
+
messageActions: { user: ["edit"], assistant: ["copy", "like", "dislike"] }
|
|
1084
|
+
}
|
|
1085
|
+
};
|
|
1086
|
+
const workspaceTriggers = {
|
|
1087
|
+
triggers: {
|
|
1088
|
+
slash: [
|
|
1089
|
+
{ id: "summarize", label: "summarize", description: "Summarize the thread so far" },
|
|
1090
|
+
{ id: "translate", label: "translate", description: "Translate the last message" }
|
|
1091
|
+
],
|
|
1092
|
+
mention: [
|
|
1093
|
+
{ id: "researcher", label: "researcher", description: "Hands off to the research agent" },
|
|
1094
|
+
{ id: "coder", label: "coder", description: "Hands off to the coding agent" }
|
|
1095
|
+
]
|
|
1096
|
+
}
|
|
1097
|
+
};
|
|
1098
|
+
const workspaceBase = {
|
|
1099
|
+
$schema: CONSTRUCT_SCHEMA_URL,
|
|
1100
|
+
name: "build-workspace",
|
|
1101
|
+
layout: "split",
|
|
1102
|
+
provider: { mode: "mock" },
|
|
1103
|
+
header: {
|
|
1104
|
+
title: "Workspace",
|
|
1105
|
+
themeToggle: true,
|
|
1106
|
+
// The story's Share/Deploy rows, mapped onto the kit Button's real
|
|
1107
|
+
// variant names (B-6a's enum): 'secondary' → outline, 'primary' → default.
|
|
1108
|
+
actions: [
|
|
1109
|
+
{ label: "Share", variant: "outline" },
|
|
1110
|
+
{ label: "Deploy", variant: "default" }
|
|
1111
|
+
]
|
|
1112
|
+
},
|
|
1113
|
+
// Dark-by-default (owner ruling, dark round) — see inAppAssistantStarter's
|
|
1114
|
+
// note above. Both variants below spread ...workspaceBase without
|
|
1115
|
+
// overriding theme, so this covers artifactPreview and appPreview too.
|
|
1116
|
+
theme: { mode: "dark" },
|
|
1117
|
+
empty: {
|
|
1118
|
+
title: "What should we build?",
|
|
1119
|
+
description: "Describe it, and it takes shape in the work surface beside this chat."
|
|
1120
|
+
},
|
|
1121
|
+
// The template's whole point, and the reason this round exists: a split
|
|
1122
|
+
// layout with no workSurface previews as a chat beside an empty column.
|
|
1123
|
+
// Every chrome key is STATED, never left to a default — what the builder
|
|
1124
|
+
// panel shows and what the pane renders can then never disagree.
|
|
1125
|
+
workSurface: {
|
|
1126
|
+
kind: "artifact",
|
|
1127
|
+
url: "/work-surface.html",
|
|
1128
|
+
chrome: { deviceToggle: false, urlBar: false, openInNewTab: false, expand: true, codeView: false }
|
|
1129
|
+
},
|
|
1130
|
+
shell: { commandPalette: true, userMenu: { name: "Ada", plan: "Pro" } },
|
|
1131
|
+
composer: workspaceTriggers,
|
|
1132
|
+
capabilities: {
|
|
1133
|
+
starters: ["Build a pricing table", "Add a dark mode toggle"],
|
|
1134
|
+
attachments: { accept: ["image/*"] },
|
|
1135
|
+
history: { persistence: "local" },
|
|
1136
|
+
conversations: true,
|
|
1137
|
+
reasoning: "full",
|
|
1138
|
+
messageActions: { user: ["edit"], assistant: ["copy", "like", "dislike"] }
|
|
1139
|
+
}
|
|
1140
|
+
};
|
|
1141
|
+
const workspaceArtifactPreview = {
|
|
1142
|
+
...workspaceBase,
|
|
1143
|
+
name: "artifact-workspace",
|
|
1144
|
+
// A clean framed surface: one expand control, no browser chrome. The
|
|
1145
|
+
// difference from appPreview below is what the two variant CARDS promise,
|
|
1146
|
+
// and until 2026-08-30 the two starters delivered none of it.
|
|
1147
|
+
// `codeView` stays OFF here on the owner's ruling: an artifact pane frames a
|
|
1148
|
+
// finished thing, not a source tree, so a Code tab beside it would be
|
|
1149
|
+
// chrome the variant does not claim. appPreview below is the one that does.
|
|
1150
|
+
workSurface: {
|
|
1151
|
+
kind: "artifact",
|
|
1152
|
+
url: "/work-surface.html",
|
|
1153
|
+
chrome: { deviceToggle: false, urlBar: false, openInNewTab: false, expand: true, codeView: false }
|
|
1154
|
+
}
|
|
1155
|
+
};
|
|
1156
|
+
const workspaceAppPreview = {
|
|
1157
|
+
...workspaceBase,
|
|
1158
|
+
name: "app-workspace",
|
|
1159
|
+
// Full browser chrome: device toggle, address bar, open-in-new-tab, expand,
|
|
1160
|
+
// and the Preview|Code toggle. `codeView: true` with no `codeUrl` is valid
|
|
1161
|
+
// vocabulary (owner ruling, 2026-08-30) and it is what this variant needs:
|
|
1162
|
+
// the app-preview surface it is modeled on shows both tabs, so shipping the
|
|
1163
|
+
// toggle off meant nobody ever saw it. With no source pointed at it the tab
|
|
1164
|
+
// renders WorkSurface's own empty state naming `workSurface.codeUrl` — an
|
|
1165
|
+
// honest "nothing here yet", never a 404. No `codeUrl` is set because there
|
|
1166
|
+
// is no honest offline file to point at; the placeholder codegen emits is
|
|
1167
|
+
// the PREVIEW's page, not source.
|
|
1168
|
+
workSurface: {
|
|
1169
|
+
kind: "preview",
|
|
1170
|
+
url: "/work-surface.html",
|
|
1171
|
+
chrome: { deviceToggle: true, urlBar: true, openInNewTab: true, expand: true, codeView: true }
|
|
1172
|
+
},
|
|
1173
|
+
capabilities: {
|
|
1174
|
+
...workspaceBase.capabilities,
|
|
1175
|
+
starters: ["Build a landing page for a coffee shop", "Make the hero work on mobile"]
|
|
1176
|
+
}
|
|
1177
|
+
};
|
|
1178
|
+
const TEMPLATES = [
|
|
1179
|
+
{
|
|
1180
|
+
id: "widget",
|
|
1181
|
+
name: "Support widget",
|
|
1182
|
+
description: "A floating chat that lives in the corner of your site.",
|
|
1183
|
+
availability: "buildable",
|
|
1184
|
+
starter: widgetStarter,
|
|
1185
|
+
controls: [IDENTITY, THEME, HEADER, EMPTY, HOME, CAPABILITIES, MESSAGE_ACTIONS, WIDGET_CHROME, PROVIDER]
|
|
1186
|
+
},
|
|
1187
|
+
{
|
|
1188
|
+
id: "inAppAssistant",
|
|
1189
|
+
name: "In-app assistant",
|
|
1190
|
+
description: "An assistant docked inside your existing app.",
|
|
1191
|
+
availability: "buildable",
|
|
1192
|
+
starter: inAppAssistantStarter,
|
|
1193
|
+
// Design-parity fix wave (2026-08-29 audit): the story's In-app
|
|
1194
|
+
// assistant panel shows Composer triggers, Message actions and a
|
|
1195
|
+
// read-only Cards list, none of which were wired here even though the
|
|
1196
|
+
// vocabulary and the panel machinery both already exist (proven working
|
|
1197
|
+
// on Workspace/Assistant/Research). Voice/Reveal-mode/Rail-placement
|
|
1198
|
+
// stay absent — those are the story's own labeled preview-only,
|
|
1199
|
+
// T-5-deferred fields with no construct vocabulary at all.
|
|
1200
|
+
controls: [IDENTITY, THEME, HEADER_CHROME, EMPTY, ASIDE, COMPOSER_TRIGGERS, CAPABILITIES, MESSAGE_ACTIONS, CARDS, PROVIDER]
|
|
1201
|
+
},
|
|
1202
|
+
{
|
|
1203
|
+
id: "assistant",
|
|
1204
|
+
name: "Assistant",
|
|
1205
|
+
description: "A full-page assistant with a history of past conversations.",
|
|
1206
|
+
availability: "buildable",
|
|
1207
|
+
starter: assistantStarter,
|
|
1208
|
+
// Design-parity fix wave: the story's Assistant panel shows an App
|
|
1209
|
+
// chrome / Shell section (Command palette toggle, User menu) that
|
|
1210
|
+
// wasn't wired here — same already-working vocabulary as Workspace's
|
|
1211
|
+
// SHELL section.
|
|
1212
|
+
controls: [IDENTITY, THEME, HEADER_CHROME, SHELL, EMPTY, CAPABILITIES, MESSAGE_ACTIONS, PROVIDER]
|
|
1213
|
+
},
|
|
1214
|
+
{
|
|
1215
|
+
id: "research",
|
|
1216
|
+
name: "Research",
|
|
1217
|
+
description: "Search-first answers with cited sources.",
|
|
1218
|
+
availability: "buildable",
|
|
1219
|
+
starter: researchStarter,
|
|
1220
|
+
controls: [IDENTITY, THEME, HEADER_CHROME, EMPTY, CAPABILITIES, SOURCES, MESSAGE_ACTIONS, PROVIDER]
|
|
1221
|
+
},
|
|
1222
|
+
{
|
|
1223
|
+
id: "workspace",
|
|
1224
|
+
name: "Workspace",
|
|
1225
|
+
description: "Chat drives a live work surface: previews, code, and artifacts build beside the conversation.",
|
|
1226
|
+
availability: "buildable",
|
|
1227
|
+
starter: workspaceBase,
|
|
1228
|
+
variants: [
|
|
1229
|
+
{
|
|
1230
|
+
id: "artifactPreview",
|
|
1231
|
+
name: "Artifact preview beside chat",
|
|
1232
|
+
description: "A code or rendered-output pane grows beside the conversation as you build.",
|
|
1233
|
+
starter: workspaceArtifactPreview
|
|
1234
|
+
},
|
|
1235
|
+
{
|
|
1236
|
+
id: "appPreview",
|
|
1237
|
+
name: "App preview with device toggles",
|
|
1238
|
+
description: "A full browser-chrome preview of the running app, with desktop, tablet, and mobile views.",
|
|
1239
|
+
starter: workspaceAppPreview
|
|
1240
|
+
}
|
|
1241
|
+
],
|
|
1242
|
+
controls: [
|
|
1243
|
+
IDENTITY,
|
|
1244
|
+
THEME,
|
|
1245
|
+
HEADER_CHROME,
|
|
1246
|
+
WORK_SURFACE,
|
|
1247
|
+
SHELL,
|
|
1248
|
+
COMPOSER_TRIGGERS,
|
|
1249
|
+
CAPABILITIES,
|
|
1250
|
+
MESSAGE_ACTIONS,
|
|
1251
|
+
EMPTY,
|
|
1252
|
+
PROVIDER
|
|
1253
|
+
]
|
|
1254
|
+
},
|
|
1255
|
+
{
|
|
1256
|
+
id: "voice",
|
|
1257
|
+
name: "Voice",
|
|
1258
|
+
description: "A voice-first assistant you talk to, push-to-talk and all.",
|
|
1259
|
+
availability: "story-only"
|
|
1260
|
+
}
|
|
1261
|
+
];
|
|
1262
|
+
function buildableTemplates() {
|
|
1263
|
+
return TEMPLATES.filter((t) => t.availability === "buildable");
|
|
1264
|
+
}
|
|
1265
|
+
function templateById(id) {
|
|
1266
|
+
return TEMPLATES.find((t) => t.id === id);
|
|
1267
|
+
}
|
|
1268
|
+
function inferTemplateId(c) {
|
|
1269
|
+
switch (c.layout) {
|
|
1270
|
+
case "widget":
|
|
1271
|
+
return "widget";
|
|
1272
|
+
case "aside":
|
|
1273
|
+
return "inAppAssistant";
|
|
1274
|
+
case "split":
|
|
1275
|
+
return "workspace";
|
|
1276
|
+
case "fullscreen":
|
|
1277
|
+
return c.capabilities?.sources ? "research" : "assistant";
|
|
1278
|
+
default:
|
|
1279
|
+
return void 0;
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
const DOCS = "https://ui.kitn.ai";
|
|
1283
|
+
function gatesOf(c) {
|
|
1284
|
+
return {
|
|
1285
|
+
// 'full' and 'compact' both render; only 'off' hides reasoning. Absent
|
|
1286
|
+
// means the kit default (full), so absent includes it too.
|
|
1287
|
+
reasoning: c.capabilities?.reasoning !== "off",
|
|
1288
|
+
// `strip: false` emits hideSources — scripting citations nobody can see
|
|
1289
|
+
// would be a silent lie, so the gate follows the visible switch.
|
|
1290
|
+
sources: c.capabilities?.sources !== void 0 && c.capabilities.sources.strip !== false,
|
|
1291
|
+
cardName: c.cards?.[0]?.name
|
|
1292
|
+
};
|
|
1293
|
+
}
|
|
1294
|
+
function turn(g, t) {
|
|
1295
|
+
const out = {};
|
|
1296
|
+
if (g.reasoning && t.reasoning !== void 0) out.reasoning = t.reasoning;
|
|
1297
|
+
if (t.text !== void 0) out.text = t.text;
|
|
1298
|
+
if (g.sources && t.sources !== void 0) out.sources = t.sources;
|
|
1299
|
+
if (t.toolCalls !== void 0) out.toolCalls = t.toolCalls;
|
|
1300
|
+
return out;
|
|
1301
|
+
}
|
|
1302
|
+
function cardCall(name) {
|
|
1303
|
+
return { name: `kai_${name}`, arguments: {} };
|
|
1304
|
+
}
|
|
1305
|
+
const CITE_WIRE = {
|
|
1306
|
+
url: `${DOCS}/guides/recipes/wire-adapter/`,
|
|
1307
|
+
title: "The wire adapter — AI/UI docs",
|
|
1308
|
+
snippet: "The kit parses, the consumer fetches: readOpenAIStream and readAnthropicStream turn provider SSE into message parts."
|
|
1309
|
+
};
|
|
1310
|
+
const CITE_PARTS = {
|
|
1311
|
+
url: `${DOCS}/guides/state-and-hooks/`,
|
|
1312
|
+
title: "State and hooks — AI/UI docs",
|
|
1313
|
+
snippet: "A message is an ordered list of parts: text, reasoning, tool, card, source, file."
|
|
1314
|
+
};
|
|
1315
|
+
const CITE_THEME = {
|
|
1316
|
+
url: `${DOCS}/guides/theming/`,
|
|
1317
|
+
title: "Theming — AI/UI docs"
|
|
1318
|
+
};
|
|
1319
|
+
function widgetScript(g) {
|
|
1320
|
+
const replies = [
|
|
1321
|
+
turn(g, {
|
|
1322
|
+
reasoning: "An order question. Look the order up before answering — guessing a delivery date is worse than a short wait.",
|
|
1323
|
+
text: "Let me pull up that order.",
|
|
1324
|
+
toolCalls: [{ name: "lookup_order", arguments: { order: "KAI-1042" } }]
|
|
1325
|
+
}),
|
|
1326
|
+
turn(g, {
|
|
1327
|
+
text: "Order KAI-1042 shipped with DHL and should arrive Thursday. (I'm a local mock — no provider was contacted — but a real model's tool call renders exactly like the row above.)"
|
|
1328
|
+
}),
|
|
1329
|
+
turn(
|
|
1330
|
+
g,
|
|
1331
|
+
g.cardName ? {
|
|
1332
|
+
reasoning: "A refund request. Collect the details on a card and let them confirm — never assume the amount.",
|
|
1333
|
+
text: "I can start that refund. Check the details below and confirm.",
|
|
1334
|
+
toolCalls: [cardCall(g.cardName)]
|
|
1335
|
+
} : { text: "Anything else? Still the mock: swap the provider seam for your endpoint and this handler keeps its exact shape." }
|
|
1336
|
+
)
|
|
1337
|
+
];
|
|
1338
|
+
return {
|
|
1339
|
+
replies,
|
|
1340
|
+
toolOutputs: {
|
|
1341
|
+
lookup_order: { order: "KAI-1042", status: "shipped", carrier: "DHL", eta: "Thursday" }
|
|
1342
|
+
}
|
|
1343
|
+
};
|
|
1344
|
+
}
|
|
1345
|
+
function inAppAssistantScript(g) {
|
|
1346
|
+
const replies = [
|
|
1347
|
+
turn(g, {
|
|
1348
|
+
reasoning: "A question about the current page. Search the docs before answering from memory.",
|
|
1349
|
+
text: "Checking the docs for that.",
|
|
1350
|
+
toolCalls: [{ name: "search_docs", arguments: { query: "deploy checklist" } }]
|
|
1351
|
+
}),
|
|
1352
|
+
turn(g, {
|
|
1353
|
+
text: "Found it: the deploy checklist wants a green canary before promoting. (Local mock, no provider contacted — the tool row above streamed through the kit's real parser.)",
|
|
1354
|
+
sources: [CITE_PARTS]
|
|
1355
|
+
}),
|
|
1356
|
+
turn(
|
|
1357
|
+
g,
|
|
1358
|
+
g.cardName ? {
|
|
1359
|
+
reasoning: "This needs explicit confirmation — put a card in the thread instead of assuming.",
|
|
1360
|
+
text: "Confirm the details below and I will take it from there.",
|
|
1361
|
+
toolCalls: [cardCall(g.cardName)]
|
|
1362
|
+
} : {
|
|
1363
|
+
reasoning: "Follow-up. Keep it short.",
|
|
1364
|
+
text: "Anything else on this page? Swap the mock for your endpoint and this handler does not change shape."
|
|
1365
|
+
}
|
|
1366
|
+
)
|
|
1367
|
+
];
|
|
1368
|
+
return {
|
|
1369
|
+
replies,
|
|
1370
|
+
toolOutputs: {
|
|
1371
|
+
search_docs: { matches: 3, top: "Deploy checklist — promote only on a green canary." }
|
|
1372
|
+
}
|
|
1373
|
+
};
|
|
1374
|
+
}
|
|
1375
|
+
function assistantScript(g) {
|
|
1376
|
+
const replies = [
|
|
1377
|
+
turn(g, {
|
|
1378
|
+
reasoning: "A drafting request. Sketch the structure first, then write — a board update wants numbers before narrative.",
|
|
1379
|
+
text: "Here is a first pass at the Q3 update: revenue, retention, and the two launches, in that order. (I'm a local mock — no provider, no key — streaming through the kit's real parser.)"
|
|
1380
|
+
}),
|
|
1381
|
+
turn(g, {
|
|
1382
|
+
reasoning: "Summarizing means reading first. Fetch the document, then compress.",
|
|
1383
|
+
text: "Give me a second to read the document.",
|
|
1384
|
+
toolCalls: [{ name: "read_document", arguments: { name: "q3-metrics.pdf" } }]
|
|
1385
|
+
}),
|
|
1386
|
+
turn(
|
|
1387
|
+
g,
|
|
1388
|
+
g.cardName ? {
|
|
1389
|
+
text: "One decision left — confirm below.",
|
|
1390
|
+
toolCalls: [cardCall(g.cardName)]
|
|
1391
|
+
} : {
|
|
1392
|
+
text: "Summary: revenue up 12%, retention flat, both launches on schedule. Still the mock — swap the seam for a real backend and nothing else changes."
|
|
1393
|
+
}
|
|
1394
|
+
)
|
|
1395
|
+
];
|
|
1396
|
+
return {
|
|
1397
|
+
replies,
|
|
1398
|
+
toolOutputs: {
|
|
1399
|
+
read_document: { pages: 14, headline: "Revenue up 12% QoQ; retention flat." }
|
|
1400
|
+
}
|
|
1401
|
+
};
|
|
1402
|
+
}
|
|
1403
|
+
function researchScript(g) {
|
|
1404
|
+
const replies = [
|
|
1405
|
+
turn(g, {
|
|
1406
|
+
reasoning: "A research question. Search first, then answer with the sources attached — an uncited claim is not an answer here.",
|
|
1407
|
+
text: "Searching for that now.",
|
|
1408
|
+
toolCalls: [{ name: "web_search", arguments: { query: "how does the wire adapter work" } }]
|
|
1409
|
+
}),
|
|
1410
|
+
turn(g, {
|
|
1411
|
+
reasoning: "Three of the results agree. Cite the two strongest and quote the load-bearing sentence.",
|
|
1412
|
+
text: "The wire adapter parses provider SSE into message parts — the kit parses, your app fetches [1]. Each streamed part lands in an ordered list on the message, which is what the thread renders [2]. (I'm a local mock, so these citations are scripted — but they render through the exact path a real model's take.)",
|
|
1413
|
+
sources: [CITE_WIRE, CITE_PARTS]
|
|
1414
|
+
}),
|
|
1415
|
+
turn(g, {
|
|
1416
|
+
reasoning: "Follow-up on theming. One source is enough.",
|
|
1417
|
+
text: "Theming rides CSS custom properties on the host element — restyle without touching the shadow DOM [1].",
|
|
1418
|
+
sources: [CITE_THEME]
|
|
1419
|
+
})
|
|
1420
|
+
];
|
|
1421
|
+
return {
|
|
1422
|
+
replies,
|
|
1423
|
+
toolOutputs: {
|
|
1424
|
+
web_search: { results: 12, top: "ui.kitn.ai — Wire adapters" }
|
|
1425
|
+
}
|
|
1426
|
+
};
|
|
1427
|
+
}
|
|
1428
|
+
function workspaceScript(g) {
|
|
1429
|
+
const replies = [
|
|
1430
|
+
turn(g, {
|
|
1431
|
+
reasoning: "A build request. Scaffold the smallest version that renders, apply it to the work surface, then iterate on feedback.",
|
|
1432
|
+
text: "First pass coming up — watch the work surface beside this chat. (I'm a local mock: the preview is a placeholder page, but a real build loop's tool calls render exactly like this.)",
|
|
1433
|
+
toolCalls: [{ name: "apply_to_work_surface", arguments: { file: "work-surface.html", change: "scaffold the page" } }]
|
|
1434
|
+
}),
|
|
1435
|
+
turn(g, {
|
|
1436
|
+
reasoning: "Revision. Keep the structure, adjust the section they named.",
|
|
1437
|
+
text: "Revised — the hero now stacks on narrow screens. Tell me what to change next.",
|
|
1438
|
+
toolCalls: [{ name: "apply_to_work_surface", arguments: { file: "work-surface.html", change: "stack the hero on mobile" } }]
|
|
1439
|
+
}),
|
|
1440
|
+
turn(
|
|
1441
|
+
g,
|
|
1442
|
+
g.cardName ? {
|
|
1443
|
+
text: "Ready to ship? Confirm below.",
|
|
1444
|
+
toolCalls: [cardCall(g.cardName)]
|
|
1445
|
+
} : {
|
|
1446
|
+
text: "Done for this round. Swap the mock seam for your endpoint and the loop keeps this exact shape."
|
|
1447
|
+
}
|
|
1448
|
+
)
|
|
1449
|
+
];
|
|
1450
|
+
return {
|
|
1451
|
+
replies,
|
|
1452
|
+
toolOutputs: {
|
|
1453
|
+
apply_to_work_surface: { file: "work-surface.html", status: "applied" }
|
|
1454
|
+
}
|
|
1455
|
+
};
|
|
1456
|
+
}
|
|
1457
|
+
function genericScript(g) {
|
|
1458
|
+
const replies = [
|
|
1459
|
+
turn(g, {
|
|
1460
|
+
reasoning: "Answer plainly, and show every content type this construct enables while doing it.",
|
|
1461
|
+
text: "Hi! I'm a local mock — no backend, no key, no provider contacted — streaming through the same parser a real model would.",
|
|
1462
|
+
sources: [CITE_PARTS]
|
|
1463
|
+
}),
|
|
1464
|
+
turn(g, {
|
|
1465
|
+
reasoning: "Demonstrate the tool path: announce a call, let the app settle it.",
|
|
1466
|
+
text: "Here is a tool call, parsed and settled through the real path.",
|
|
1467
|
+
toolCalls: [{ name: "demo_tool", arguments: { note: "scripted by the mock" } }]
|
|
1468
|
+
}),
|
|
1469
|
+
turn(
|
|
1470
|
+
g,
|
|
1471
|
+
g.cardName ? { text: "And a card — confirm below.", toolCalls: [cardCall(g.cardName)] } : { text: "Swap `createMockResponder` for a fetch to your endpoint and this handler keeps its exact shape." }
|
|
1472
|
+
)
|
|
1473
|
+
];
|
|
1474
|
+
return {
|
|
1475
|
+
replies,
|
|
1476
|
+
toolOutputs: { demo_tool: { ok: true, note: "scripted by the mock" } }
|
|
1477
|
+
};
|
|
1478
|
+
}
|
|
1479
|
+
function mockScriptFor(c) {
|
|
1480
|
+
const g = gatesOf(c);
|
|
1481
|
+
const script = (() => {
|
|
1482
|
+
switch (inferTemplateId(c)) {
|
|
1483
|
+
case "widget":
|
|
1484
|
+
return widgetScript(g);
|
|
1485
|
+
case "inAppAssistant":
|
|
1486
|
+
return inAppAssistantScript(g);
|
|
1487
|
+
case "assistant":
|
|
1488
|
+
return assistantScript(g);
|
|
1489
|
+
case "research":
|
|
1490
|
+
return researchScript(g);
|
|
1491
|
+
case "workspace":
|
|
1492
|
+
return workspaceScript(g);
|
|
1493
|
+
default:
|
|
1494
|
+
return genericScript(g);
|
|
1495
|
+
}
|
|
1496
|
+
})();
|
|
1497
|
+
const announced = new Set(
|
|
1498
|
+
script.replies.flatMap(
|
|
1499
|
+
(r) => typeof r === "string" ? [] : (r.toolCalls ?? []).map((t) => t.name)
|
|
1500
|
+
)
|
|
1501
|
+
);
|
|
1502
|
+
const toolOutputs = Object.fromEntries(
|
|
1503
|
+
Object.entries(script.toolOutputs).filter(([name]) => announced.has(name))
|
|
1504
|
+
);
|
|
1505
|
+
return { replies: script.replies, toolOutputs };
|
|
1506
|
+
}
|
|
1507
|
+
function kitVersion() {
|
|
1508
|
+
const require2 = createRequire(import.meta.url);
|
|
1509
|
+
const pkg = require2("@kitn.ai/ui/package.json");
|
|
1510
|
+
return pkg.version;
|
|
1511
|
+
}
|
|
1512
|
+
const themeMode = (c) => c.theme?.mode === "light" ? "light" : c.theme?.mode === "dark" ? "dark" : "auto";
|
|
1513
|
+
function srgbChannelToLinear(c) {
|
|
1514
|
+
const cs = c / 255;
|
|
1515
|
+
return cs <= 0.03928 ? cs / 12.92 : Math.pow((cs + 0.055) / 1.055, 2.4);
|
|
1516
|
+
}
|
|
1517
|
+
function relativeLuminance(r, g, b) {
|
|
1518
|
+
return 0.2126 * srgbChannelToLinear(r) + 0.7152 * srgbChannelToLinear(g) + 0.0722 * srgbChannelToLinear(b);
|
|
1519
|
+
}
|
|
1520
|
+
function hslToRgb(h, s, l) {
|
|
1521
|
+
const hue = (h % 360 + 360) % 360;
|
|
1522
|
+
const chroma = (1 - Math.abs(2 * l - 1)) * s;
|
|
1523
|
+
const x = chroma * (1 - Math.abs(hue / 60 % 2 - 1));
|
|
1524
|
+
const m = l - chroma / 2;
|
|
1525
|
+
const [r1, g1, b1] = hue < 60 ? [chroma, x, 0] : hue < 120 ? [x, chroma, 0] : hue < 180 ? [0, chroma, x] : hue < 240 ? [0, x, chroma] : hue < 300 ? [x, 0, chroma] : [chroma, 0, x];
|
|
1526
|
+
return [Math.round((r1 + m) * 255), Math.round((g1 + m) * 255), Math.round((b1 + m) * 255)];
|
|
1527
|
+
}
|
|
1528
|
+
function parseAccentRgb(accent) {
|
|
1529
|
+
const s = accent.trim();
|
|
1530
|
+
const hex3 = /^#([0-9a-fA-F]{3})$/.exec(s);
|
|
1531
|
+
if (hex3) {
|
|
1532
|
+
const [r, g, b] = hex3[1].split("").map((ch) => parseInt(ch + ch, 16));
|
|
1533
|
+
return [r, g, b];
|
|
1534
|
+
}
|
|
1535
|
+
const hex6 = /^#([0-9a-fA-F]{6})([0-9a-fA-F]{2})?$/.exec(s);
|
|
1536
|
+
if (hex6) {
|
|
1537
|
+
const hex = hex6[1];
|
|
1538
|
+
return [parseInt(hex.slice(0, 2), 16), parseInt(hex.slice(2, 4), 16), parseInt(hex.slice(4, 6), 16)];
|
|
1539
|
+
}
|
|
1540
|
+
const rgbFn = /^rgba?\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
|
|
1541
|
+
if (rgbFn) {
|
|
1542
|
+
const [r, g, b] = [rgbFn[1], rgbFn[2], rgbFn[3]].map(Number);
|
|
1543
|
+
return [r, g, b].every((v) => v >= 0 && v <= 255) ? [r, g, b] : null;
|
|
1544
|
+
}
|
|
1545
|
+
const hslFn = /^hsla?\(\s*([\d.]+)\s*,\s*([\d.]+)%\s*,\s*([\d.]+)%\s*(?:,\s*[\d.]+\s*)?\)$/.exec(s);
|
|
1546
|
+
if (hslFn) {
|
|
1547
|
+
return hslToRgb(Number(hslFn[1]), Number(hslFn[2]) / 100, Number(hslFn[3]) / 100);
|
|
1548
|
+
}
|
|
1549
|
+
return null;
|
|
1550
|
+
}
|
|
1551
|
+
function resolveContrastForeground(accent) {
|
|
1552
|
+
const rgb = parseAccentRgb(accent);
|
|
1553
|
+
if (!rgb) return null;
|
|
1554
|
+
const luminance = relativeLuminance(...rgb);
|
|
1555
|
+
return luminance <= 0.5 ? "#ffffff" : "#000000";
|
|
1556
|
+
}
|
|
1557
|
+
function commentSafe(text) {
|
|
1558
|
+
return text.replace(/[\r\n]/g, " ").replace(/\*\//g, "* /");
|
|
1559
|
+
}
|
|
1560
|
+
const CONTRAST_COLOR_SUPPORTS = "@supports (color: contrast-color(red))";
|
|
1561
|
+
function accentContrastNotice(construct) {
|
|
1562
|
+
const effective = construct.theme?.tokens?.light?.["--kai-color-primary"] ?? construct.theme?.accent;
|
|
1563
|
+
if (!effective || resolveContrastForeground(effective) !== null) return null;
|
|
1564
|
+
return `accent '${commentSafe(effective)}' not parseable for contrast; foreground left at theme default in browsers without CSS contrast-color() support`;
|
|
1565
|
+
}
|
|
1566
|
+
function generateProject(construct, opts = {}) {
|
|
1567
|
+
const uiSpec = opts.uiSpec ?? `^${kitVersion()}`;
|
|
1568
|
+
const files = [
|
|
1569
|
+
{ path: "package.json", code: emitPackageJson(construct, uiSpec) },
|
|
1570
|
+
{ path: "tsconfig.json", code: emitTsconfig() },
|
|
1571
|
+
{ path: "vite.config.ts", code: emitViteDev() },
|
|
1572
|
+
{ path: "vite.config.lib.ts", code: emitViteLib(construct) },
|
|
1573
|
+
{ path: "index.html", code: emitIndexHtml(construct) },
|
|
1574
|
+
{ path: "src/element.tsx", code: emitElement(construct) },
|
|
1575
|
+
{ path: "src/App.tsx", code: emitApp(construct) }
|
|
1576
|
+
];
|
|
1577
|
+
if (construct.cards) files.push({ path: "src/cards.ts", code: emitCardsRegistry(construct.cards) });
|
|
1578
|
+
const ws = workSurfaceOf(construct);
|
|
1579
|
+
if (ws && workSurfaceUrlIsRelative(ws.url)) {
|
|
1580
|
+
files.push({ path: WORK_SURFACE_PAGE, code: emitWorkSurfacePage(construct) });
|
|
1581
|
+
}
|
|
1582
|
+
return files;
|
|
1583
|
+
}
|
|
1584
|
+
function emitCardsRegistry(cards) {
|
|
1585
|
+
const entries = cards.map((card) => ` ${card.name}: ${JSON.stringify(card.schema, null, 2).split("\n").join("\n ")},`).join("\n");
|
|
1586
|
+
return `// src/cards.ts — the construct's card registry, verbatim from the construct.
|
|
1587
|
+
// Tool definitions for YOUR backend derive from this same object via
|
|
1588
|
+
// @kitn.ai/ui/schemas (cardTools / toOpenAITools / toAnthropicTools) — one
|
|
1589
|
+
// projection, shared with the kit.
|
|
1590
|
+
export const cards = {
|
|
1591
|
+
${entries}
|
|
1592
|
+
} as const;
|
|
1593
|
+
`;
|
|
1594
|
+
}
|
|
1595
|
+
function emitCardComponentImport(c) {
|
|
1596
|
+
return c.cards ? ", BUILTIN_CARD_COMPONENTS" : "";
|
|
1597
|
+
}
|
|
1598
|
+
function emitCardsImport(c) {
|
|
1599
|
+
if (!c.cards) return "";
|
|
1600
|
+
const toolsImport = c.provider.mode === "endpoint" ? c.provider.wire === "openai" ? ", toOpenAITools" : ", toAnthropicTools" : "";
|
|
1601
|
+
return `import { cards } from './cards';
|
|
1602
|
+
// Generative-UI cards, v1: every declared card renders as the kit's own
|
|
1603
|
+
// schema-driven FORM (BUILTIN_CARD_COMPONENTS.form, components/form/form.tsx) — it
|
|
1604
|
+
// walks the card's JSON Schema into real input fields, honoring
|
|
1605
|
+
// x-kai-format/x-kai-mask/x-kai-mask-guide hints itself. ChatThread's own
|
|
1606
|
+
// MessageBody already matches \`part.type === 'card'\` in its part rendering and
|
|
1607
|
+
// draws it with the kit's own \`CardRenderer\` (components/card/card-renderer.tsx),
|
|
1608
|
+
// which picks the component from \`cardTypes\` (below) by envelope.type — so
|
|
1609
|
+
// there is nothing to hand-compose beyond that one map. Turning a model's tool
|
|
1610
|
+
// call into that renderable part is \`cardFromToolCall\` (the inverse of
|
|
1611
|
+
// \`cardTools\`), applied once per settled turn below; its data is then replaced
|
|
1612
|
+
// with the DECLARED card schema (not the model's call arguments) — the fields
|
|
1613
|
+
// on screen are the construct's own vocabulary, not whatever shape a model
|
|
1614
|
+
// happened to send.
|
|
1615
|
+
//
|
|
1616
|
+
// UPDATE (CD-1, owner ruling 2026-08-26, Task 19g): the field SHAPE (title/
|
|
1617
|
+
// type/widget/validation) still comes from the construct's own declared
|
|
1618
|
+
// schema, never the model's — that part is unchanged. But discarding the
|
|
1619
|
+
// model's call arguments wholesale also threw away any VALUE it wanted to
|
|
1620
|
+
// pre-fill, breaking "model proposes, user confirms" (kai_refund_approval
|
|
1621
|
+
// {amount:50} rendered an empty form). So the model's args are now
|
|
1622
|
+
// shallow-merged onto the declared schema's field \`default\`s below
|
|
1623
|
+
// (mergeToolArgsIntoFormDefaults) before the card is added — see
|
|
1624
|
+
// emitApplyCardTools.
|
|
1625
|
+
import { cardFromToolCall${toolsImport} } from '@kitn.ai/ui/schemas';
|
|
1626
|
+
|
|
1627
|
+
// Every declared card name routes to the SAME form renderer — cardFromToolCall
|
|
1628
|
+
// makes envelope.type equal the card's own name (kai_refund_approval ->
|
|
1629
|
+
// 'refund_approval'), and CardRenderer resolves a type's component from this
|
|
1630
|
+
// map.
|
|
1631
|
+
//
|
|
1632
|
+
// Deliberately NOT also wiring ChatThread's \`cardSchemas\` prop to this
|
|
1633
|
+
// registry below. That prop validates envelope.data AGAINST the named schema,
|
|
1634
|
+
// and this card's data IS \`cards[name]\` itself (see emitApplyCardTools) — the
|
|
1635
|
+
// construct's declared field schema, not values shaped like it. Wiring it as
|
|
1636
|
+
// its own validator asks "does this FormDefinition itself have an \`amount\`
|
|
1637
|
+
// key" and a well-formed FormDefinition never does, so every card would
|
|
1638
|
+
// render the HARD validation-failure fallback instead of the form (caught
|
|
1639
|
+
// live: eject + kai dev showed exactly that "(root).amount: required"
|
|
1640
|
+
// failure before this comment existed). The construct's own schema.ts
|
|
1641
|
+
// already checks \`cards\` structurally at validate time; there is nothing
|
|
1642
|
+
// left for a second, self-referential check here to catch.
|
|
1643
|
+
const cardTypes = Object.fromEntries(Object.keys(cards).map((name) => [name, BUILTIN_CARD_COMPONENTS.form] as const));
|
|
1644
|
+
|
|
1645
|
+
// CD-1 (owner ruling 2026-08-26, Task 19g): shallow-merge a model's card
|
|
1646
|
+
// tool-call args onto a DECLARED form schema's field defaults — "model
|
|
1647
|
+
// proposes, user confirms". Only top-level keys the args AND the schema both
|
|
1648
|
+
// name are touched; the schema's own field shape (title/type/widget/
|
|
1649
|
+
// validation) is never altered, and a key the model sent that isn't a
|
|
1650
|
+
// declared field is ignored (the construct's vocabulary wins, not the
|
|
1651
|
+
// model's). One level deep only — a nested object field's own defaults are
|
|
1652
|
+
// not recursed into; no evidence of need yet (vocabulary-on-evidence).
|
|
1653
|
+
function mergeToolArgsIntoFormDefaults(
|
|
1654
|
+
schema: Record<string, unknown> & { properties?: Record<string, unknown> },
|
|
1655
|
+
args: Record<string, unknown>,
|
|
1656
|
+
): Record<string, unknown> {
|
|
1657
|
+
const declared = schema.properties;
|
|
1658
|
+
if (!declared || typeof declared !== 'object') return schema;
|
|
1659
|
+
const patched: Record<string, unknown> = {};
|
|
1660
|
+
for (const [key, value] of Object.entries(args)) {
|
|
1661
|
+
if (!(key in declared)) continue;
|
|
1662
|
+
patched[key] = { ...(declared[key] as Record<string, unknown>), default: value };
|
|
1663
|
+
}
|
|
1664
|
+
return { ...schema, properties: { ...schema.properties, ...patched } };
|
|
1665
|
+
}`;
|
|
1666
|
+
}
|
|
1667
|
+
function emitCardTypesProp(c) {
|
|
1668
|
+
return c.cards ? " cardTypes={cardTypes}" : "";
|
|
1669
|
+
}
|
|
1670
|
+
function emitSettleMockTools(hasOutputs) {
|
|
1671
|
+
if (!hasOutputs) return "";
|
|
1672
|
+
return `
|
|
1673
|
+
for (const part of chat.messages().find((m) => m.id === stream.id)?.parts ?? []) {
|
|
1674
|
+
if (part.type !== 'tool' || part.tool.state !== 'input-available' || !part.tool.toolCallId) continue;
|
|
1675
|
+
const output = MOCK_TOOL_OUTPUTS[part.tool.type];
|
|
1676
|
+
if (output) stream.upsertTool(part.tool.toolCallId, { state: 'output-available', output });
|
|
1677
|
+
}`;
|
|
1678
|
+
}
|
|
1679
|
+
function emitApplyCardTools(c) {
|
|
1680
|
+
if (!c.cards) return "";
|
|
1681
|
+
return `
|
|
1682
|
+
for (const part of chat.messages().find((m) => m.id === stream.id)?.parts ?? []) {
|
|
1683
|
+
if (part.type !== 'tool' || part.tool.state !== 'input-available') continue;
|
|
1684
|
+
const card = cardFromToolCall(part.tool.type, part.tool.input, { id: part.tool.toolCallId ?? crypto.randomUUID() });
|
|
1685
|
+
if (card && card.type in cards) {
|
|
1686
|
+
const declared = cards[card.type as keyof typeof cards];
|
|
1687
|
+
const args = card.data as Record<string, unknown> | undefined;
|
|
1688
|
+
const merged = args && typeof args === 'object'
|
|
1689
|
+
? mergeToolArgsIntoFormDefaults(declared, args)
|
|
1690
|
+
: declared;
|
|
1691
|
+
stream.addCard({ ...card, data: merged });
|
|
1692
|
+
}
|
|
1693
|
+
}`;
|
|
1694
|
+
}
|
|
1695
|
+
function emitToolsField(c) {
|
|
1696
|
+
if (!c.cards || c.provider.mode !== "endpoint") return "";
|
|
1697
|
+
const toolsFn = c.provider.wire === "openai" ? "toOpenAITools" : "toAnthropicTools";
|
|
1698
|
+
return `, tools: ${toolsFn}(cards)`;
|
|
1699
|
+
}
|
|
1700
|
+
function emitPackageJson(c, uiSpec) {
|
|
1701
|
+
return `${JSON.stringify(
|
|
1702
|
+
{
|
|
1703
|
+
name: c.name,
|
|
1704
|
+
private: true,
|
|
1705
|
+
type: "module",
|
|
1706
|
+
scripts: {
|
|
1707
|
+
dev: "vite",
|
|
1708
|
+
build: "vite build --config vite.config.lib.ts",
|
|
1709
|
+
typecheck: "tsc --noEmit"
|
|
1710
|
+
},
|
|
1711
|
+
dependencies: {
|
|
1712
|
+
"@kitn.ai/ui": uiSpec,
|
|
1713
|
+
"solid-js": "^1.9.0"
|
|
1714
|
+
},
|
|
1715
|
+
devDependencies: {
|
|
1716
|
+
typescript: "^5.6.0",
|
|
1717
|
+
vite: "^6.0.0",
|
|
1718
|
+
"vite-plugin-solid": "^2.11.0"
|
|
1719
|
+
}
|
|
1720
|
+
},
|
|
1721
|
+
null,
|
|
1722
|
+
2
|
|
1723
|
+
)}
|
|
1724
|
+
`;
|
|
1725
|
+
}
|
|
1726
|
+
function emitTsconfig() {
|
|
1727
|
+
return `${JSON.stringify(
|
|
1728
|
+
{
|
|
1729
|
+
compilerOptions: {
|
|
1730
|
+
target: "ES2022",
|
|
1731
|
+
module: "ESNext",
|
|
1732
|
+
moduleResolution: "bundler",
|
|
1733
|
+
jsx: "preserve",
|
|
1734
|
+
jsxImportSource: "solid-js",
|
|
1735
|
+
strict: true,
|
|
1736
|
+
noUnusedLocals: true,
|
|
1737
|
+
skipLibCheck: true,
|
|
1738
|
+
types: ["vite/client"]
|
|
1739
|
+
},
|
|
1740
|
+
include: ["src"]
|
|
1741
|
+
},
|
|
1742
|
+
null,
|
|
1743
|
+
2
|
|
1744
|
+
)}
|
|
1745
|
+
`;
|
|
1746
|
+
}
|
|
1747
|
+
function emitViteDev() {
|
|
1748
|
+
return `import { defineConfig } from 'vite';
|
|
1749
|
+
import solid from 'vite-plugin-solid';
|
|
1750
|
+
|
|
1751
|
+
export default defineConfig({ plugins: [solid()] });
|
|
1752
|
+
`;
|
|
1753
|
+
}
|
|
1754
|
+
function emitViteLib(c) {
|
|
1755
|
+
return `import { defineConfig } from 'vite';
|
|
1756
|
+
import solid from 'vite-plugin-solid';
|
|
1757
|
+
|
|
1758
|
+
// kai compile: ONE self-registering .js. Everything is inlined (no externals):
|
|
1759
|
+
// the consumer installs nothing but this output.
|
|
1760
|
+
export default defineConfig({
|
|
1761
|
+
plugins: [solid()],
|
|
1762
|
+
build: {
|
|
1763
|
+
lib: { entry: 'src/element.tsx', formats: ['es'], fileName: () => '${c.name}.js' },
|
|
1764
|
+
},
|
|
1765
|
+
});
|
|
1766
|
+
`;
|
|
1767
|
+
}
|
|
1768
|
+
function emitIndexHtml(c) {
|
|
1769
|
+
const hint = c.layout === "widget" ? `
|
|
1770
|
+
<p style="position: fixed; top: 50%; left: 50%; transform: translate(-50%, -50%); margin: 0; color: #94a3b8; font: 14px system-ui, sans-serif; text-align: center; max-width: 28rem; padding: 0 1rem;">This blank page stands in for your site. The chat widget is in the bottom-right corner.</p>` : "";
|
|
1771
|
+
const slotDemo = (c.slots ?? []).map(
|
|
1772
|
+
(name) => `
|
|
1773
|
+
<div slot="${name}" style="padding: 0.5rem 1rem; font: 13px system-ui, sans-serif; color: #64748b;">Projected into slot "${name}" — replace with your own markup.</div>`
|
|
1774
|
+
).join("");
|
|
1775
|
+
const body = slotDemo ? `
|
|
1776
|
+
<${c.name}>${slotDemo}
|
|
1777
|
+
</${c.name}>` : `
|
|
1778
|
+
<${c.name}></${c.name}>`;
|
|
1779
|
+
return `<!doctype html>
|
|
1780
|
+
<html lang="en">
|
|
1781
|
+
<head>
|
|
1782
|
+
<meta charset="utf-8" />
|
|
1783
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
1784
|
+
<title>${c.name} — construct preview</title>
|
|
1785
|
+
</head>
|
|
1786
|
+
<body style="margin: 0;">${hint}${body}
|
|
1787
|
+
<script type="module" src="/src/element.tsx"><\/script>
|
|
1788
|
+
</body>
|
|
1789
|
+
</html>
|
|
1790
|
+
`;
|
|
1791
|
+
}
|
|
1792
|
+
function emitDarkTokensCss(entries) {
|
|
1793
|
+
const lines = entries.map(([name, value]) => {
|
|
1794
|
+
if (!KNOWN_THEME_TOKENS.has(name) || !name.startsWith("--kai-color-")) {
|
|
1795
|
+
throw new Error(
|
|
1796
|
+
`construct codegen: dark theme token "${name}" is not a declared --kai-color-* knob — was this construct validated?`
|
|
1797
|
+
);
|
|
1798
|
+
}
|
|
1799
|
+
const problem = themeTokenValueProblem(value);
|
|
1800
|
+
if (problem) {
|
|
1801
|
+
throw new Error(
|
|
1802
|
+
`construct codegen: dark theme token "${name}" has an unsafe value (${problem}) — was this construct validated?`
|
|
1803
|
+
);
|
|
1804
|
+
}
|
|
1805
|
+
return ` ${name}: ${value};`;
|
|
1806
|
+
});
|
|
1807
|
+
return `.dark {
|
|
1808
|
+
${lines.join("\n")}
|
|
1809
|
+
}`;
|
|
1810
|
+
}
|
|
1811
|
+
function emitElement(c) {
|
|
1812
|
+
const accent = c.theme?.accent;
|
|
1813
|
+
const unreadColor = c.theme?.unreadColor;
|
|
1814
|
+
const tokens = c.theme?.tokens;
|
|
1815
|
+
const hostTokenEntries = [
|
|
1816
|
+
...Object.entries(tokens?.light ?? {}),
|
|
1817
|
+
...tokens?.radius ? [["--kai-radius", tokens.radius]] : [],
|
|
1818
|
+
...Object.entries(tokens?.fonts ?? {})
|
|
1819
|
+
];
|
|
1820
|
+
const darkTokenEntries = Object.entries(tokens?.dark ?? {});
|
|
1821
|
+
const usesCtx = !!accent || !!unreadColor || hostTokenEntries.length > 0 || needsHost(c);
|
|
1822
|
+
const appJsx = needsHost(c) ? "<App host={ctx.element} />" : "<App />";
|
|
1823
|
+
if (!accent && !unreadColor && hostTokenEntries.length === 0 && darkTokenEntries.length === 0) {
|
|
1824
|
+
const facade2 = usesCtx ? `(_props, ctx) => {
|
|
1825
|
+
return ${appJsx};
|
|
1826
|
+
}` : "() => <App />";
|
|
1827
|
+
return `import { defineWebComponent } from '@kitn.ai/ui/define';
|
|
1828
|
+
import { App } from './App';
|
|
1829
|
+
|
|
1830
|
+
// The one facade. Interior stays pure Solid (no nested element registrations);
|
|
1831
|
+
// the kit CSS is injected into the shadow root by defineWebComponent itself.
|
|
1832
|
+
defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade2});
|
|
1833
|
+
`;
|
|
1834
|
+
}
|
|
1835
|
+
const setPropertyLines = [
|
|
1836
|
+
...accent ? [` ctx.element.style.setProperty('--kai-color-primary', ${JSON.stringify(accent)});`] : [],
|
|
1837
|
+
...unreadColor ? [` ctx.element.style.setProperty('--kai-color-unread', ${JSON.stringify(unreadColor)});`] : [],
|
|
1838
|
+
...hostTokenEntries.length ? [
|
|
1839
|
+
" // theme.tokens: after accent/unreadColor on purpose — same knob, the token wins.",
|
|
1840
|
+
...hostTokenEntries.map(
|
|
1841
|
+
([name, value]) => ` ctx.element.style.setProperty(${JSON.stringify(name)}, ${JSON.stringify(value)});`
|
|
1842
|
+
)
|
|
1843
|
+
] : []
|
|
1844
|
+
];
|
|
1845
|
+
const styleParts = [];
|
|
1846
|
+
const effectivePrimary = tokens?.light?.["--kai-color-primary"] ?? accent;
|
|
1847
|
+
if (effectivePrimary) {
|
|
1848
|
+
const foreground = resolveContrastForeground(effectivePrimary);
|
|
1849
|
+
const foregroundCss = foreground !== null ? `:host { --kai-color-primary-foreground: ${foreground}; }
|
|
1850
|
+
` : (
|
|
1851
|
+
// Not guessed at: an unparseable accent (var(), a named color, a
|
|
1852
|
+
// color-mix()/oklch() call, …) leaves NO base declaration, so the
|
|
1853
|
+
// kit's own theme default stands — except in a browser new enough to
|
|
1854
|
+
// resolve contrast-color() itself, where the @supports block below
|
|
1855
|
+
// still gets it right natively.
|
|
1856
|
+
`/* NOTICE: accent '${commentSafe(effectivePrimary)}' not parseable for contrast at generation time; the paired foreground falls back to the theme default in browsers without CSS contrast-color() support. */
|
|
1857
|
+
`
|
|
1858
|
+
);
|
|
1859
|
+
styleParts.push(
|
|
1860
|
+
foregroundCss + `${CONTRAST_COLOR_SUPPORTS} {
|
|
1861
|
+
:host { --kai-color-primary-foreground: contrast-color(var(--kai-color-primary)); }
|
|
1862
|
+
}`
|
|
1863
|
+
);
|
|
1864
|
+
}
|
|
1865
|
+
if (darkTokenEntries.length) styleParts.push(emitDarkTokensCss(darkTokenEntries));
|
|
1866
|
+
const styleText = styleParts.join("\n");
|
|
1867
|
+
const setPropertyBlock = setPropertyLines.length ? `
|
|
1868
|
+
${setPropertyLines.join("\n")}` : "";
|
|
1869
|
+
const facade = styleText ? `(${usesCtx ? "_props, ctx" : ""}) => {${setPropertyBlock}
|
|
1870
|
+
return (
|
|
1871
|
+
<>
|
|
1872
|
+
<style>{${JSON.stringify(styleText)}}</style>
|
|
1873
|
+
${appJsx}
|
|
1874
|
+
</>
|
|
1875
|
+
);
|
|
1876
|
+
}` : (
|
|
1877
|
+
// No CSS text needed (no accent, no dark tokens): setProperty calls only —
|
|
1878
|
+
// e.g. an unreadColor-only or light-tokens-only construct.
|
|
1879
|
+
`(_props, ctx) => {${setPropertyBlock}
|
|
1880
|
+
return ${appJsx};
|
|
1881
|
+
}`
|
|
1882
|
+
);
|
|
1883
|
+
return `import { defineWebComponent } from '@kitn.ai/ui/define';
|
|
1884
|
+
import { App } from './App';
|
|
1885
|
+
|
|
1886
|
+
// The one facade. Interior stays pure Solid (no nested element registrations);
|
|
1887
|
+
// the kit CSS is injected into the shadow root by defineWebComponent itself.
|
|
1888
|
+
defineWebComponent('${c.name}', { theme: '${themeMode(c)}' as 'light' | 'dark' | 'auto' }, ${facade});
|
|
1889
|
+
`;
|
|
1890
|
+
}
|
|
1891
|
+
function emitApp(c) {
|
|
1892
|
+
if (c.layout === "custom") return emitCustomApp(c);
|
|
1893
|
+
return `${emitSolidJsImports(c)}import { ChatThread, createKaiChat${emitLayoutImport(c)}${emitCardComponentImport(c)}${emitEmptyComponentImport(c)}${emitChromeImports(c)} } from '@kitn.ai/ui/solid';
|
|
1894
|
+
import type { AttachmentData${emitHistoryTypeImport(c)}${emitConversationsResetTypeImport(c)} } from '@kitn.ai/ui/solid';
|
|
1895
|
+
${emitProviderImports(c)}
|
|
1896
|
+
${emitCardsImport(c)}
|
|
1897
|
+
${emitConversationsImport(c)}
|
|
1898
|
+
|
|
1899
|
+
${emitProviderSetup(c)}
|
|
1900
|
+
${emitHistorySetup(c)}
|
|
1901
|
+
|
|
1902
|
+
// ChatThread is the kit's own MOST-INTEGRATED chat surface — the same
|
|
1903
|
+
// composition <kai-chat>'s facade renders (src/web-components/chat/chat.tsx). It owns
|
|
1904
|
+
// the message list, the composer (padding, focus ring, the send button) and
|
|
1905
|
+
// their layout AS ONE UNIT, so nothing here re-derives spacing, alignment or
|
|
1906
|
+
// focus styling by hand: every prior version of this file that hand-composed
|
|
1907
|
+
// Thread + PromptInput + Button was restating layout the kit already owns,
|
|
1908
|
+
// and every visual defect the owner hit (flush composer, a clipped focus
|
|
1909
|
+
// ring) traced back to that restatement. Composing ChatThread directly
|
|
1910
|
+
// leaves NOTHING here to restate it with.
|
|
1911
|
+
//
|
|
1912
|
+
// Capability gating (format rule: an undeclared capability's affordance must
|
|
1913
|
+
// be OFF). The construct schema carries ONE capability field so far
|
|
1914
|
+
// (capabilities.starters, Task 8) — every other affordance below is gated to
|
|
1915
|
+
// "off" unconditionally, not per-construct, until there's a field to gate ON.
|
|
1916
|
+
// - webSearch / voice: real ChatThreadProps booleans, default OFF when
|
|
1917
|
+
// omitted — set to \`false\` explicitly rather than left implicit, so the
|
|
1918
|
+
// gating decision is visible in the emitted source, not just inferred
|
|
1919
|
+
// from an absent prop.
|
|
1920
|
+
// - suggestions: ChatThread ALREADY owns starter prompts end to end — its
|
|
1921
|
+
// own \`suggestions\` prop renders the chips, hides them once
|
|
1922
|
+
// \`messages\` is non-empty, and (default \`suggestionMode="submit"\`)
|
|
1923
|
+
// calls \`onSubmit\` with the clicked text exactly like a typed submit.
|
|
1924
|
+
// So capabilities.starters threads straight into that prop; there is
|
|
1925
|
+
// nothing to hand-compose. Omitted (undefined) when no starters are
|
|
1926
|
+
// declared, same off-by-default effect as the booleans above.
|
|
1927
|
+
// - models: omitted (undefined) — no model switcher; no capabilities field yet.
|
|
1928
|
+
// - attachments (the paperclip): gated via ChatThread's \`attach\`/\`accept\`
|
|
1929
|
+
// props (kit gap closed — ChatThread forwards both to DefaultPromptInput,
|
|
1930
|
+
// mirroring webSearch/voice). ChatThread ALREADY owns the whole
|
|
1931
|
+
// round-trip end to end — the paperclip button, staged previews, staging
|
|
1932
|
+
// each file as a data URI (never a blob object URL; see
|
|
1933
|
+
// AttachmentData.url's doc in primitives/attachment-types.ts), and
|
|
1934
|
+
// handing the staged list back via onSubmit's \`attachments\` — and its
|
|
1935
|
+
// Message component ALREADY groups consecutive file parts into one
|
|
1936
|
+
// attachment row (message.tsx). So there is nothing to hand-compose
|
|
1937
|
+
// here, same lesson as suggestions above: hand-rolling a second picker or
|
|
1938
|
+
// a second file-part renderer would restate what ChatThread/Message
|
|
1939
|
+
// already own. capabilities.attachments threads straight into
|
|
1940
|
+
// attach/accept; the only App.tsx-owned piece is folding the picked
|
|
1941
|
+
// attachments into the outgoing message's parts at the submit site
|
|
1942
|
+
// (see emitProviderSetup) since createKaiChat's own append/streamAssistant
|
|
1943
|
+
// ops don't do that folding themselves.
|
|
1944
|
+
// - reasoning: gated via ChatThread's own \`reasoning\` prop (kit gap closed
|
|
1945
|
+
// — ChatThread forwards it to every MessageBody as \`reasoningMode\`,
|
|
1946
|
+
// mirroring attach/accept). \`'full'\` is both the schema default and
|
|
1947
|
+
// ChatThread's own default, so it and an absent field emit no prop at
|
|
1948
|
+
// all — the SAME off-by-default convention as every other capability
|
|
1949
|
+
// here, just anchored on the medium's existing default instead of an
|
|
1950
|
+
// "off" value, since a reasoning disclosure is normal chat behavior, not
|
|
1951
|
+
// an opt-in affordance like the paperclip or a starter chip.
|
|
1952
|
+
// - empty (the welcome-screen greeting, Task 14): gated via ChatThread's
|
|
1953
|
+
// own \`emptyContent\` prop, plain JSX rendered in the SAME shadow tree
|
|
1954
|
+
// this file's App already composes ChatThread inside of (see
|
|
1955
|
+
// emitEmptyContentProp's own doc for why that boundary needs no Portal
|
|
1956
|
+
// at all). \`capabilities.starters\`' chips and the composer still render
|
|
1957
|
+
// underneath it: ChatThread's own doc comment on \`emptyContent\` is
|
|
1958
|
+
// explicit that it replaces only the empty MESSAGE LIST.
|
|
1959
|
+
// - the widget close control (owner feedback on the live demo): a declared
|
|
1960
|
+
// \`header.title\` on a \`widget\` layout gets its close button threaded
|
|
1961
|
+
// into ChatThread's own header row via \`headerEndContent\`, wired back to
|
|
1962
|
+
// Dock's \`controllerRef\` seam through a local closure — see
|
|
1963
|
+
// emitDockCloseVar/emitHeaderEndContentProp's docs. No header means no
|
|
1964
|
+
// row for it to sit in, so that case is untouched and Dock's own built-in
|
|
1965
|
+
// mobile X keeps covering it.
|
|
1966
|
+
// - conversations (Task 5): gated via ChatThread's own \`conversations\`/
|
|
1967
|
+
// \`store\` props (kit-owned end to end — the prior-conversations list,
|
|
1968
|
+
// list/load/save, autosave on every \`chat.messages()\` change). Requires
|
|
1969
|
+
// capabilities.history persistence \`local\` or \`endpoint\` (schema
|
|
1970
|
+
// superRefine, C-4) and SUBSUMES this file's hand-rolled history effect
|
|
1971
|
+
// when on — see emitHistorySetup's own doc for the persistence-ownership
|
|
1972
|
+
// decision: the store prop is the ONLY persistence mechanism emitted,
|
|
1973
|
+
// never both. ChatThread never mutates \`messages\` itself, so
|
|
1974
|
+
// \`onConversationLoad\` is ALSO wired here (\`chat.setMessages(() =>
|
|
1975
|
+
// messages)\`) — without it, select/new/mount-restore all update
|
|
1976
|
+
// ChatThread's own internal view/list state while the rendered thread
|
|
1977
|
+
// never changes (see emitConversationsProps's own doc for the Task 6
|
|
1978
|
+
// live-browser bug this fixes).
|
|
1979
|
+
// - conversations + widget (owner follow-up): closing the widget while its
|
|
1980
|
+
// list view is open must not leave it there for the next open — see
|
|
1981
|
+
// widgetHasConversationsChrome/emitDockOnOpenChangeProp's docs. Wired only
|
|
1982
|
+
// for \`widget\`, the one layout with something that closes/reopens at all.
|
|
1983
|
+
${emitChromeComment(c)}export function App(${needsHost(c) ? "props: { host: HTMLElement }" : ""}) {
|
|
1984
|
+
${emitToggleThemeVar(c, " ")}${emitDockCloseVar(c, " ")}${emitChatControllerVar(c, " ")}${emitConversationsSignalsVar(c, " ")}${emitShellPaletteVars(c, " ")}${emitPaneProbeVar(c, " ")}${emitWorkSurfaceVars(c, " ")}${emitHeaderActionDispatchVar(c, " ")} return (
|
|
1985
|
+
${hasShellPalette(c) ? " <>\n" : ""}${emitLayoutOpen(c)}${emitSlots(c.slots, " ")} <ChatThread messages={chat.messages()} loading={chat.loading()} placeholder="Ask anything" onSubmit={submit} webSearch={false} voice={false}${emitHeaderProp(c)}${emitHeaderEndContentProp(c)}${emitAttachProps(c)}${emitStartersProp(c)}${emitReasoningProp(c)}${emitReasoningOpenProp(c)}${emitMessageActionsProps(c)}${emitHideSourcesProp(c)}${emitTriggersProp(c)}${emitEmptyContentProp(c)}${emitCardTypesProp(c)}${emitHomeProp(c)}${emitConversationsProps(c)}${emitChatControllerRefProp(c)}${emitChatThreadUnreadProps(c)} />
|
|
1986
|
+
${emitLayoutClose(c)}${emitShellPaletteOverlay(c)}${hasShellPalette(c) ? " </>\n" : ""} );
|
|
1987
|
+
}
|
|
1988
|
+
`;
|
|
1989
|
+
}
|
|
1990
|
+
function emitCustomApp(c) {
|
|
1991
|
+
const slots = c.slots ?? [];
|
|
1992
|
+
const [headerSlot, ...restSlots] = slots;
|
|
1993
|
+
const history = c.capabilities?.history;
|
|
1994
|
+
const solidJsNames = ["createSignal", ...history && history.persistence !== "none" ? ["createEffect"] : []];
|
|
1995
|
+
return `import { ${solidJsNames.join(", ")} } from 'solid-js';
|
|
1996
|
+
import { Thread, PromptInput, PromptInputTextarea, PromptInputActions, Button, createKaiChat${emitCardComponentImport(c)} } from '@kitn.ai/ui/solid';
|
|
1997
|
+
import type { AttachmentData${emitHistoryTypeImport(c)} } from '@kitn.ai/ui/solid';
|
|
1998
|
+
${emitProviderImports(c)}
|
|
1999
|
+
${emitCardsImport(c)}
|
|
2000
|
+
|
|
2001
|
+
${emitProviderSetup(c)}
|
|
2002
|
+
${emitHistorySetup(c)}
|
|
2003
|
+
|
|
2004
|
+
// layout: custom — minimal chrome, no ChatThread/Dock/PaneGroup. The bare
|
|
2005
|
+
// spine (Thread + PromptInput) plus the declared slots, positioned by hand so
|
|
2006
|
+
// YOU own the surrounding DOM. Capabilities beyond the spine (starters,
|
|
2007
|
+
// attachments, reasoning display-mode, reasoningOpen, header.title, empty,
|
|
2008
|
+
// conversations, header.themeToggle/actions, composer.triggers, shell) are
|
|
2009
|
+
// NOT wired here in v1 — this file is the eject artifact; add them the
|
|
2010
|
+
// way ChatThread composes them (components/chat/chat-thread.tsx in the kit's own
|
|
2011
|
+
// source) if this construct needs them on a custom layout.
|
|
2012
|
+
export function App() {
|
|
2013
|
+
const [value, setValue] = createSignal('');
|
|
2014
|
+
|
|
2015
|
+
const handleSubmit = () => {
|
|
2016
|
+
const text = value();
|
|
2017
|
+
if (!text.trim() || chat.loading()) return;
|
|
2018
|
+
setValue('');
|
|
2019
|
+
void submit({ value: text, attachments: [] });
|
|
2020
|
+
};
|
|
2021
|
+
|
|
2022
|
+
return (
|
|
2023
|
+
<div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>
|
|
2024
|
+
${emitSlots(headerSlot ? [headerSlot] : void 0, " ")} <Thread messages={chat.messages()} loading={chat.loading()} class="min-h-0 flex-1"${emitCardTypesProp(c)} />
|
|
2025
|
+
{/* Slot placement rule: first declared slot above the thread, every other
|
|
2026
|
+
declared slot below the composer, in declaration order. Reorder by
|
|
2027
|
+
ejecting — this is the whole grain dimmer. */}
|
|
2028
|
+
<PromptInput value={value()} onValueChange={setValue} isLoading={chat.loading()} onSubmit={handleSubmit}>
|
|
2029
|
+
<PromptInputTextarea placeholder="Ask anything" />
|
|
2030
|
+
<PromptInputActions>
|
|
2031
|
+
<Button onClick={handleSubmit}>Send</Button>
|
|
2032
|
+
</PromptInputActions>
|
|
2033
|
+
</PromptInput>
|
|
2034
|
+
${emitSlots(restSlots, " ")} </div>
|
|
2035
|
+
);
|
|
2036
|
+
}
|
|
2037
|
+
`;
|
|
2038
|
+
}
|
|
2039
|
+
function emitHeaderProp(c) {
|
|
2040
|
+
const title = c.header?.title;
|
|
2041
|
+
if (!title) return "";
|
|
2042
|
+
return ` chatTitle={${JSON.stringify(title)}}`;
|
|
2043
|
+
}
|
|
2044
|
+
function emitEmptyContentProp(c) {
|
|
2045
|
+
const empty = c.empty;
|
|
2046
|
+
if (!empty) return "";
|
|
2047
|
+
const title = `<EmptyTitle>{${JSON.stringify(empty.title)}}</EmptyTitle>`;
|
|
2048
|
+
const icon = empty.icon ? `<EmptyMedia><img src={${JSON.stringify(empty.icon)}} alt="" style={{ width: '40px', height: '40px', 'border-radius': '9999px' }} /></EmptyMedia>` : "";
|
|
2049
|
+
const description = empty.description ? `<EmptyDescription>{${JSON.stringify(empty.description)}}</EmptyDescription>` : "";
|
|
2050
|
+
return ` emptyContent={<Empty><EmptyHeader>${icon}${title}${description}</EmptyHeader></Empty>}`;
|
|
2051
|
+
}
|
|
2052
|
+
function emitEmptyComponentImport(c) {
|
|
2053
|
+
if (!c.empty) return "";
|
|
2054
|
+
let names = ", Empty, EmptyHeader, EmptyTitle";
|
|
2055
|
+
if (c.empty.icon) names += ", EmptyMedia";
|
|
2056
|
+
if (c.empty.description) names += ", EmptyDescription";
|
|
2057
|
+
return names;
|
|
2058
|
+
}
|
|
2059
|
+
function widgetHasHeaderClose(c) {
|
|
2060
|
+
return c.layout === "widget" && !!c.header?.title;
|
|
2061
|
+
}
|
|
2062
|
+
function widgetHasConversationsChrome(c) {
|
|
2063
|
+
return c.layout === "widget" && (!!c.capabilities?.conversations || !!c.home);
|
|
2064
|
+
}
|
|
2065
|
+
function hasShellPalette(c) {
|
|
2066
|
+
return c.layout !== "custom" && c.shell?.commandPalette === true;
|
|
2067
|
+
}
|
|
2068
|
+
function emitShellPaletteVars(c, indent) {
|
|
2069
|
+
if (!hasShellPalette(c)) return "";
|
|
2070
|
+
const entries = [
|
|
2071
|
+
`{ id: 'focus-composer', label: 'Focus composer' }`,
|
|
2072
|
+
...c.capabilities?.conversations ? [`{ id: 'new-conversation', label: 'New conversation' }`] : [],
|
|
2073
|
+
...c.header?.themeToggle ? [`{ id: 'toggle-theme', label: 'Toggle theme' }`] : []
|
|
2074
|
+
];
|
|
2075
|
+
const newConversationLine = c.capabilities?.conversations ? `
|
|
2076
|
+
${indent} if (id === 'new-conversation') chatController?.startNewConversation();` : "";
|
|
2077
|
+
const toggleThemeLine = c.header?.themeToggle ? `
|
|
2078
|
+
${indent} if (id === 'toggle-theme') toggleTheme();` : "";
|
|
2079
|
+
return `${indent}// Command palette (shell.commandPalette): Mod+K toggles; Escape/backdrop
|
|
2080
|
+
${indent}// close; entries DERIVE from what this construct enables — no dead rows.
|
|
2081
|
+
${indent}const [paletteOpen, setPaletteOpen] = createSignal(false);
|
|
2082
|
+
${indent}const [paletteQuery, setPaletteQuery] = createSignal('');
|
|
2083
|
+
${indent}const PALETTE_COMMANDS = [${entries.join(", ")}];
|
|
2084
|
+
${indent}const paletteGroups = () => {
|
|
2085
|
+
${indent} const q = paletteQuery().trim().toLowerCase();
|
|
2086
|
+
${indent} const items = q ? PALETTE_COMMANDS.filter((i) => i.label.toLowerCase().includes(q)) : PALETTE_COMMANDS;
|
|
2087
|
+
${indent} return [{ items }];
|
|
2088
|
+
${indent}};
|
|
2089
|
+
${indent}const runPaletteCommand = (id: string) => {
|
|
2090
|
+
${indent} setPaletteOpen(false);
|
|
2091
|
+
${indent} if (id === 'focus-composer') chatController?.focus();${newConversationLine}${toggleThemeLine}
|
|
2092
|
+
${indent}};
|
|
2093
|
+
${indent}onMount(() => {
|
|
2094
|
+
${indent} const onKey = (e: KeyboardEvent) => {
|
|
2095
|
+
${indent} if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'k') { e.preventDefault(); setPaletteOpen((v) => !v); }
|
|
2096
|
+
${indent} if (e.key === 'Escape') setPaletteOpen(false);
|
|
2097
|
+
${indent} };
|
|
2098
|
+
${indent} window.addEventListener('keydown', onKey);
|
|
2099
|
+
${indent} onCleanup(() => window.removeEventListener('keydown', onKey));
|
|
2100
|
+
${indent}});
|
|
2101
|
+
`;
|
|
2102
|
+
}
|
|
2103
|
+
function emitShellPaletteOverlay(c) {
|
|
2104
|
+
if (!hasShellPalette(c)) return "";
|
|
2105
|
+
return ` <Show when={paletteOpen()}>
|
|
2106
|
+
<div style={{ position: 'fixed', inset: '0', 'z-index': '50', display: 'flex', 'align-items': 'flex-start', 'justify-content': 'center', 'padding-block-start': '14vh', background: 'rgb(0 0 0 / 0.5)' }} onClick={() => setPaletteOpen(false)}>
|
|
2107
|
+
<div style={{ width: '100%', 'max-width': '32rem', overflow: 'hidden', 'border-radius': '0.75rem', border: '1px solid var(--color-border)', background: 'var(--color-popover)' }} onClick={(e) => e.stopPropagation()}>
|
|
2108
|
+
<Input value={paletteQuery()} onValueInput={setPaletteQuery} placeholder="Search commands..." autofocus />
|
|
2109
|
+
<CommandList groups={paletteGroups()} onSelect={runPaletteCommand} />
|
|
2110
|
+
</div>
|
|
2111
|
+
</div>
|
|
2112
|
+
</Show>
|
|
2113
|
+
`;
|
|
2114
|
+
}
|
|
2115
|
+
function emitChatControllerVar(c, indent) {
|
|
2116
|
+
return widgetHasConversationsChrome(c) || hasShellPalette(c) ? `${indent}let chatController: ChatThreadController | undefined;
|
|
2117
|
+
` : "";
|
|
2118
|
+
}
|
|
2119
|
+
function emitChatControllerRefProp(c) {
|
|
2120
|
+
return widgetHasConversationsChrome(c) || hasShellPalette(c) ? " controllerRef={(api) => (chatController = api)}" : "";
|
|
2121
|
+
}
|
|
2122
|
+
function emitDockOnOpenChangeProp(c) {
|
|
2123
|
+
return widgetHasConversationsChrome(c) ? " onOpenChange={(open) => { setDockOpen(open); if (!open) chatController?.closeConversationsList(); }}" : "";
|
|
2124
|
+
}
|
|
2125
|
+
function emitConversationsResetTypeImport(c) {
|
|
2126
|
+
return widgetHasConversationsChrome(c) || hasShellPalette(c) ? ", ChatThreadController" : "";
|
|
2127
|
+
}
|
|
2128
|
+
function emitConversationsSignalsVar(c, indent) {
|
|
2129
|
+
if (!widgetHasConversationsChrome(c)) return "";
|
|
2130
|
+
const w = c.layout === "widget" ? c.widget : void 0;
|
|
2131
|
+
const defaultOpen = w?.defaultOpen === true ? "true" : "false";
|
|
2132
|
+
return `${indent}const [dockOpen, setDockOpen] = createSignal(${defaultOpen});
|
|
2133
|
+
${indent}const [anyUnread, setAnyUnread] = createSignal(false);
|
|
2134
|
+
`;
|
|
2135
|
+
}
|
|
2136
|
+
function emitChatThreadUnreadProps(c) {
|
|
2137
|
+
return widgetHasConversationsChrome(c) ? " hostOpen={dockOpen()} onUnreadChange={setAnyUnread}" : "";
|
|
2138
|
+
}
|
|
2139
|
+
function emitDockUnreadProp(c) {
|
|
2140
|
+
return widgetHasConversationsChrome(c) ? " unread={anyUnread()}" : "";
|
|
2141
|
+
}
|
|
2142
|
+
function emitDockCloseVar(c, indent) {
|
|
2143
|
+
return widgetHasHeaderClose(c) ? `${indent}let dockClose: (() => void) | undefined;
|
|
2144
|
+
` : "";
|
|
2145
|
+
}
|
|
2146
|
+
function emitDockControllerRef(c) {
|
|
2147
|
+
return widgetHasHeaderClose(c) ? " controllerRef={(api) => (dockClose = () => api.setOpen(false))}" : "";
|
|
2148
|
+
}
|
|
2149
|
+
function emitDockHideClose(c) {
|
|
2150
|
+
return widgetHasHeaderClose(c) ? " hideClose={true}" : "";
|
|
2151
|
+
}
|
|
2152
|
+
function emitToggleThemeVar(c, indent) {
|
|
2153
|
+
if (c.layout === "custom" || !c.header?.themeToggle) return "";
|
|
2154
|
+
if (!hasAppHeader(c)) {
|
|
2155
|
+
return `${indent}const toggleTheme = () => props.host.setAttribute('theme', props.host.getAttribute('theme') === 'dark' ? 'light' : 'dark');
|
|
2156
|
+
`;
|
|
2157
|
+
}
|
|
2158
|
+
return `${indent}// header.themeToggle -> the host's own 'theme' (the one defineWebComponent
|
|
2159
|
+
${indent}// owns). AppHeader's toggle is icon-only and shows the mode you would switch
|
|
2160
|
+
${indent}// TO, so it needs the RESOLVED mode, and resolving it takes both reads:
|
|
2161
|
+
${indent}// - the PROPERTY, not just the attribute. This construct declares its mode
|
|
2162
|
+
${indent}// through defineWebComponent's prop DEFAULT, so a themed element can carry
|
|
2163
|
+
${indent}// no 'theme' attribute at all — reading only the attribute reported
|
|
2164
|
+
${indent}// "light" on a dark app and drew the wrong icon (caught in the live
|
|
2165
|
+
${indent}// builder, not in a test).
|
|
2166
|
+
${indent}// - matchMedia for 'auto' (and for nothing set), which is the only thing
|
|
2167
|
+
${indent}// that can answer "follow the system".
|
|
2168
|
+
${indent}const resolveDark = () => {
|
|
2169
|
+
${indent} const mode = (props.host as HTMLElement & { theme?: string }).theme ?? props.host.getAttribute('theme');
|
|
2170
|
+
${indent} if (mode === 'dark') return true;
|
|
2171
|
+
${indent} if (mode === 'light') return false;
|
|
2172
|
+
${indent} return typeof matchMedia === 'function' && matchMedia('(prefers-color-scheme: dark)').matches;
|
|
2173
|
+
${indent}};
|
|
2174
|
+
${indent}const [themeDark, setThemeDark] = createSignal(resolveDark());
|
|
2175
|
+
${indent}const toggleTheme = () => {
|
|
2176
|
+
${indent} const next = !themeDark();
|
|
2177
|
+
${indent} props.host.setAttribute('theme', next ? 'dark' : 'light');
|
|
2178
|
+
${indent} setThemeDark(next);
|
|
2179
|
+
${indent}};
|
|
2180
|
+
`;
|
|
2181
|
+
}
|
|
2182
|
+
function hasHeaderActionsChrome(c) {
|
|
2183
|
+
return c.layout !== "custom" && !!c.header?.actions?.length;
|
|
2184
|
+
}
|
|
2185
|
+
function hasThemeToggleChrome(c) {
|
|
2186
|
+
return c.layout !== "custom" && c.header?.themeToggle === true;
|
|
2187
|
+
}
|
|
2188
|
+
function hasUserMenuChrome(c) {
|
|
2189
|
+
return c.layout !== "custom" && !!c.shell?.userMenu;
|
|
2190
|
+
}
|
|
2191
|
+
function hasAppHeader(c) {
|
|
2192
|
+
return c.layout === "split" && (!!c.header?.title || hasThemeToggleChrome(c) || hasHeaderActionsChrome(c) || hasUserMenuChrome(c) || hasShellPalette(c));
|
|
2193
|
+
}
|
|
2194
|
+
function inHeaderEndRow(c) {
|
|
2195
|
+
return !hasAppHeader(c);
|
|
2196
|
+
}
|
|
2197
|
+
function emitHeaderActionDispatchVar(c, indent) {
|
|
2198
|
+
if (!hasHeaderActionsChrome(c)) return "";
|
|
2199
|
+
return `${indent}// Each header action dispatches 'kai-header-action' on the host and nothing
|
|
2200
|
+
${indent}// here handles it — that is the consumer's seam, by design (vocabulary
|
|
2201
|
+
${indent}// never logic). The DEV line below is a one-time reminder per label, NOT a
|
|
2202
|
+
${indent}// listener check: the DOM cannot report whether a listener exists.
|
|
2203
|
+
${indent}const warnedHeaderActions = new Set<string>();
|
|
2204
|
+
${indent}const dispatchHeaderAction = (label: string) => {
|
|
2205
|
+
${indent} if (import.meta.env.DEV && !warnedHeaderActions.has(label)) {
|
|
2206
|
+
${indent} warnedHeaderActions.add(label);
|
|
2207
|
+
${indent} console.warn(\`[${c.name}] header action "\${label}" dispatched 'kai-header-action' on the host. Nothing happens until your app listens: el.addEventListener('kai-header-action', (e) => …).\`);
|
|
2208
|
+
${indent} }
|
|
2209
|
+
${indent} props.host.dispatchEvent(new CustomEvent('kai-header-action', { detail: { label } }));
|
|
2210
|
+
${indent}};
|
|
2211
|
+
`;
|
|
2212
|
+
}
|
|
2213
|
+
function emitHeaderEndContentProp(c) {
|
|
2214
|
+
const pieces = [];
|
|
2215
|
+
if (hasHeaderActionsChrome(c) && inHeaderEndRow(c)) {
|
|
2216
|
+
for (const a of c.header.actions) {
|
|
2217
|
+
const variant = a.variant ? ` variant="${a.variant}"` : "";
|
|
2218
|
+
pieces.push(
|
|
2219
|
+
`<Button${variant} size="sm" onClick={() => dispatchHeaderAction(${JSON.stringify(a.label)})}>{${JSON.stringify(a.label)}}</Button>`
|
|
2220
|
+
);
|
|
2221
|
+
}
|
|
2222
|
+
}
|
|
2223
|
+
if (hasThemeToggleChrome(c) && inHeaderEndRow(c)) {
|
|
2224
|
+
pieces.push(`<Button variant="ghost" size="sm" aria-label="Toggle theme" onClick={toggleTheme}>Theme</Button>`);
|
|
2225
|
+
}
|
|
2226
|
+
if (hasUserMenuChrome(c) && inHeaderEndRow(c)) {
|
|
2227
|
+
const m = c.shell.userMenu;
|
|
2228
|
+
const menuLabel = JSON.stringify(`${m.name}${m.plan ? ` — ${m.plan}` : ""} account menu`);
|
|
2229
|
+
const initials = JSON.stringify(m.name.slice(0, 2).toUpperCase());
|
|
2230
|
+
const item = (id, label) => `<DropdownItem onSelect={() => props.host.dispatchEvent(new CustomEvent('kai-user-menu', { detail: { item: '${id}' } }))}>${label}</DropdownItem>`;
|
|
2231
|
+
pieces.push(
|
|
2232
|
+
`<Dropdown><DropdownTrigger aria-label={${menuLabel}}><Avatar fallback={${initials}} size="sm" /></DropdownTrigger><DropdownContent>${item("settings", "Settings")}${item("help", "Get help")}<DropdownSeparator />${item("log-out", "Log out")}</DropdownContent></Dropdown>`
|
|
2233
|
+
);
|
|
2234
|
+
}
|
|
2235
|
+
if (widgetHasHeaderClose(c)) {
|
|
2236
|
+
pieces.push(
|
|
2237
|
+
`<Button variant="ghost" size="icon-sm" aria-label="Close ${c.name}" onClick={() => dockClose?.()}><DockCloseGlyph /></Button>`
|
|
2238
|
+
);
|
|
2239
|
+
}
|
|
2240
|
+
if (pieces.length === 0) return "";
|
|
2241
|
+
const content = pieces.length > 1 ? `<>${pieces.join("")}</>` : pieces[0];
|
|
2242
|
+
return ` headerEndContent={${content}}`;
|
|
2243
|
+
}
|
|
2244
|
+
function emitChromeImports(c) {
|
|
2245
|
+
let names = "";
|
|
2246
|
+
if (inHeaderEndRow(c) && (hasHeaderActionsChrome(c) || hasThemeToggleChrome(c)) || widgetHasHeaderClose(c)) {
|
|
2247
|
+
names += ", Button";
|
|
2248
|
+
}
|
|
2249
|
+
if (hasUserMenuChrome(c) && inHeaderEndRow(c)) {
|
|
2250
|
+
names += ", Dropdown, DropdownTrigger, DropdownContent, DropdownItem, DropdownSeparator, Avatar";
|
|
2251
|
+
}
|
|
2252
|
+
if (hasAppHeader(c)) names += ", AppHeader";
|
|
2253
|
+
if (hasShellPalette(c)) names += ", CommandList, Input";
|
|
2254
|
+
if (widgetHasHeaderClose(c)) names += ", DockCloseGlyph";
|
|
2255
|
+
return names;
|
|
2256
|
+
}
|
|
2257
|
+
function emitAppHeader(c, indent) {
|
|
2258
|
+
if (!hasAppHeader(c)) return "";
|
|
2259
|
+
let out = `${indent}<AppHeader
|
|
2260
|
+
`;
|
|
2261
|
+
if (c.header?.title) out += `${indent} title={${JSON.stringify(c.header.title)}}
|
|
2262
|
+
`;
|
|
2263
|
+
if (hasShellPalette(c)) {
|
|
2264
|
+
out += `${indent} showSearch={true}
|
|
2265
|
+
${indent} onSearch={() => setPaletteOpen(true)}
|
|
2266
|
+
`;
|
|
2267
|
+
}
|
|
2268
|
+
if (hasThemeToggleChrome(c)) {
|
|
2269
|
+
out += `${indent} showThemeToggle={true}
|
|
2270
|
+
${indent} dark={themeDark()}
|
|
2271
|
+
${indent} onToggleDark={toggleTheme}
|
|
2272
|
+
`;
|
|
2273
|
+
}
|
|
2274
|
+
if (hasHeaderActionsChrome(c)) {
|
|
2275
|
+
out += `${indent} actions={${JSON.stringify(c.header.actions)}}
|
|
2276
|
+
${indent} onActionSelect={(action) => dispatchHeaderAction(action.label)}
|
|
2277
|
+
`;
|
|
2278
|
+
}
|
|
2279
|
+
if (hasUserMenuChrome(c)) {
|
|
2280
|
+
out += `${indent} user={${JSON.stringify(c.shell.userMenu)}}
|
|
2281
|
+
${indent} onUserMenuSelect={(item) => props.host.dispatchEvent(new CustomEvent('kai-user-menu', { detail: { item } }))}
|
|
2282
|
+
`;
|
|
2283
|
+
}
|
|
2284
|
+
return `${out}${indent}/>
|
|
2285
|
+
`;
|
|
2286
|
+
}
|
|
2287
|
+
function emitChromeComment(c) {
|
|
2288
|
+
const lines = [];
|
|
2289
|
+
if (hasHeaderActionsChrome(c)) {
|
|
2290
|
+
lines.push("// header.actions dispatch 'kai-header-action' on the host, detail: { label }.");
|
|
2291
|
+
}
|
|
2292
|
+
if (hasUserMenuChrome(c)) {
|
|
2293
|
+
lines.push("// shell.userMenu dispatches 'kai-user-menu' on the host, detail: { item }.");
|
|
2294
|
+
}
|
|
2295
|
+
if (hasShellPalette(c)) {
|
|
2296
|
+
lines.push("// shell.commandPalette: Mod+K opens the command palette; entries derive from");
|
|
2297
|
+
lines.push("// what this construct enables (menu-honesty — no dead rows).");
|
|
2298
|
+
}
|
|
2299
|
+
return lines.length ? `${lines.join("\n")}
|
|
2300
|
+
` : "";
|
|
2301
|
+
}
|
|
2302
|
+
function emitMessageActionsProps(c) {
|
|
2303
|
+
const actions = c.capabilities?.messageActions;
|
|
2304
|
+
if (!actions) return "";
|
|
2305
|
+
let out = "";
|
|
2306
|
+
if (actions.user) out += ` userActions={${JSON.stringify(actions.user)}}`;
|
|
2307
|
+
if (actions.assistant) out += ` assistantActions={${JSON.stringify(actions.assistant)}}`;
|
|
2308
|
+
return out;
|
|
2309
|
+
}
|
|
2310
|
+
function emitHideSourcesProp(c) {
|
|
2311
|
+
return c.capabilities?.sources?.strip === false ? " hideSources={true}" : "";
|
|
2312
|
+
}
|
|
2313
|
+
function emitTriggersProp(c) {
|
|
2314
|
+
const triggers = c.composer?.triggers;
|
|
2315
|
+
if (!triggers) return "";
|
|
2316
|
+
const defs = [
|
|
2317
|
+
...triggers.slash ? [{ char: "/", kind: "command", items: triggers.slash }] : [],
|
|
2318
|
+
...triggers.mention ? [{ char: "@", kind: "mention", items: triggers.mention }] : []
|
|
2319
|
+
];
|
|
2320
|
+
if (defs.length === 0) return "";
|
|
2321
|
+
return ` triggers={${JSON.stringify(defs)}}`;
|
|
2322
|
+
}
|
|
2323
|
+
function emitAttachProps(c) {
|
|
2324
|
+
const attachments = c.capabilities?.attachments;
|
|
2325
|
+
if (!attachments) return " attach={false}";
|
|
2326
|
+
return ` attach={true} accept={${JSON.stringify(attachments.accept.join(","))}}`;
|
|
2327
|
+
}
|
|
2328
|
+
function emitStartersProp(c) {
|
|
2329
|
+
const starters = c.capabilities?.starters;
|
|
2330
|
+
if (!starters || starters.length === 0) return "";
|
|
2331
|
+
return ` suggestions={${JSON.stringify(starters)}}`;
|
|
2332
|
+
}
|
|
2333
|
+
function emitReasoningProp(c) {
|
|
2334
|
+
const reasoning = c.capabilities?.reasoning;
|
|
2335
|
+
if (!reasoning || reasoning === "full") return "";
|
|
2336
|
+
return ` reasoning="${reasoning}"`;
|
|
2337
|
+
}
|
|
2338
|
+
function emitReasoningOpenProp(c) {
|
|
2339
|
+
return c.capabilities?.reasoningOpen === true ? " reasoningOpen={true}" : "";
|
|
2340
|
+
}
|
|
2341
|
+
function emitHomeProp(c) {
|
|
2342
|
+
if (!c.home) return "";
|
|
2343
|
+
return ` home={${JSON.stringify(c.home)}}`;
|
|
2344
|
+
}
|
|
2345
|
+
function emitConversationsProps(c) {
|
|
2346
|
+
if (!c.capabilities?.conversations) return "";
|
|
2347
|
+
const history = c.capabilities.history;
|
|
2348
|
+
const storeCall = history?.persistence === "endpoint" ? `fetchStore(${JSON.stringify(history.url)}${c.userId ? `, ${JSON.stringify(c.userId)}` : ""})` : `localStorageStore('${c.name}'${c.userId ? `, ${JSON.stringify(c.userId)}` : ""})`;
|
|
2349
|
+
return ` conversations={true} store={${storeCall}} onConversationLoad={(messages) => chat.setMessages(() => messages)}`;
|
|
2350
|
+
}
|
|
2351
|
+
function emitConversationsImport(c) {
|
|
2352
|
+
if (!c.capabilities?.conversations) return "";
|
|
2353
|
+
const name = c.capabilities.history?.persistence === "endpoint" ? "fetchStore" : "localStorageStore";
|
|
2354
|
+
return `import { ${name} } from '@kitn.ai/ui/solid';`;
|
|
2355
|
+
}
|
|
2356
|
+
function needsCreateEffect(c) {
|
|
2357
|
+
const history = c.capabilities?.history;
|
|
2358
|
+
return !!history && history.persistence !== "none" && !(c.layout !== "custom" && c.capabilities?.conversations);
|
|
2359
|
+
}
|
|
2360
|
+
function emitSolidJsImports(c) {
|
|
2361
|
+
const names = [];
|
|
2362
|
+
if (needsCreateEffect(c)) names.push("createEffect");
|
|
2363
|
+
if (widgetHasConversationsChrome(c) || hasShellPalette(c) || splitNeedsPaneProbe(c) || workSurfaceOf(c)?.chrome?.expand || // the app header's resolved-dark signal (emitToggleThemeVar)
|
|
2364
|
+
hasAppHeader(c) && hasThemeToggleChrome(c)) {
|
|
2365
|
+
names.push("createSignal");
|
|
2366
|
+
}
|
|
2367
|
+
if (hasShellPalette(c)) names.push("Show");
|
|
2368
|
+
if (hasShellPalette(c) || splitNeedsPaneProbe(c)) names.push("onMount", "onCleanup");
|
|
2369
|
+
if (names.length === 0) return "";
|
|
2370
|
+
return `import { ${names.join(", ")} } from 'solid-js';
|
|
2371
|
+
`;
|
|
2372
|
+
}
|
|
2373
|
+
function emitHistoryTypeImport(c) {
|
|
2374
|
+
const history = c.capabilities?.history;
|
|
2375
|
+
if (!history || history.persistence === "none" || c.layout !== "custom" && c.capabilities?.conversations) return "";
|
|
2376
|
+
return ", ChatMessage";
|
|
2377
|
+
}
|
|
2378
|
+
function emitUserIdHeaderEntry(c) {
|
|
2379
|
+
return c.userId ? `, 'x-kai-user-id': ${JSON.stringify(c.userId)}` : "";
|
|
2380
|
+
}
|
|
2381
|
+
function emitHistorySetup(c) {
|
|
2382
|
+
const history = c.capabilities?.history;
|
|
2383
|
+
if (!history || history.persistence === "none") return "";
|
|
2384
|
+
if (c.layout !== "custom" && c.capabilities?.conversations) return "";
|
|
2385
|
+
if (history.persistence === "local") {
|
|
2386
|
+
const key = JSON.stringify(c.userId ? `kai:${c.name}:${c.userId}:thread` : `kai:${c.name}:thread`);
|
|
2387
|
+
return `
|
|
2388
|
+
// History: persisted locally in this browser, keyed by the element tag. What to
|
|
2389
|
+
// retain and for how long is an app decision — clear the key to reset.
|
|
2390
|
+
const THREAD_KEY = ${key};
|
|
2391
|
+
try {
|
|
2392
|
+
const saved = localStorage.getItem(THREAD_KEY);
|
|
2393
|
+
if (saved) {
|
|
2394
|
+
const parsed: unknown = JSON.parse(saved);
|
|
2395
|
+
if (Array.isArray(parsed)) {
|
|
2396
|
+
chat.setMessages(() => parsed as ChatMessage[]);
|
|
2397
|
+
} else {
|
|
2398
|
+
console.warn(\`[\${THREAD_KEY}] stored history was not an array; ignoring and starting fresh\`);
|
|
2399
|
+
}
|
|
2400
|
+
}
|
|
2401
|
+
} catch { /* storage unavailable or corrupt: run in-memory */ }
|
|
2402
|
+
createEffect(() => {
|
|
2403
|
+
try {
|
|
2404
|
+
localStorage.setItem(THREAD_KEY, JSON.stringify(chat.messages()));
|
|
2405
|
+
} catch { /* storage unavailable: run in-memory */ }
|
|
2406
|
+
});
|
|
2407
|
+
`;
|
|
2408
|
+
}
|
|
2409
|
+
const url = JSON.stringify(history.url);
|
|
2410
|
+
return `
|
|
2411
|
+
// History: persisted to your endpoint (GET on mount, PUT on every change) —
|
|
2412
|
+
// the kit PARSES, this app FETCHES; your route owns the storage and what to
|
|
2413
|
+
// retain and for how long. \`hydrated\` guards the mount-load from immediately
|
|
2414
|
+
// PUTting back what it just loaded, but still flips on a FAILED load — one
|
|
2415
|
+
// offline/CORS/DNS blip degrades to "start fresh, keep saving", not
|
|
2416
|
+
// "never save again".
|
|
2417
|
+
let hydrated = false;
|
|
2418
|
+
(async () => {
|
|
2419
|
+
try {
|
|
2420
|
+
const r = await fetch(${url}${c.userId ? `, { headers: { 'x-kai-user-id': ${JSON.stringify(c.userId)} } }` : ""});
|
|
2421
|
+
const saved: unknown = r.ok ? await r.json() : [];
|
|
2422
|
+
if (Array.isArray(saved)) {
|
|
2423
|
+
chat.setMessages(() => saved as ChatMessage[]);
|
|
2424
|
+
} else {
|
|
2425
|
+
console.warn('history endpoint returned a non-array body; ignoring and starting fresh');
|
|
2426
|
+
}
|
|
2427
|
+
} catch (err) {
|
|
2428
|
+
console.error('history endpoint GET failed; starting fresh (will keep saving)', err);
|
|
2429
|
+
} finally {
|
|
2430
|
+
hydrated = true;
|
|
2431
|
+
}
|
|
2432
|
+
})();
|
|
2433
|
+
createEffect(() => {
|
|
2434
|
+
const snapshot = chat.messages();
|
|
2435
|
+
if (!hydrated) return;
|
|
2436
|
+
fetch(${url}, {
|
|
2437
|
+
method: 'PUT',
|
|
2438
|
+
headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
|
|
2439
|
+
body: JSON.stringify(snapshot),
|
|
2440
|
+
}).catch((err) => {
|
|
2441
|
+
console.error('history endpoint PUT failed; this change was not persisted', err);
|
|
2442
|
+
});
|
|
2443
|
+
});
|
|
2444
|
+
`;
|
|
2445
|
+
}
|
|
2446
|
+
function emitProviderImports(c) {
|
|
2447
|
+
if (c.provider.mode === "mock") {
|
|
2448
|
+
return `import { createMockResponder, type MockReply } from '@kitn.ai/ui/state';
|
|
2449
|
+
import { readOpenAIStream } from '@kitn.ai/ui/wire';`;
|
|
2450
|
+
}
|
|
2451
|
+
const read = c.provider.wire === "openai" ? "readOpenAIStream" : "readAnthropicStream";
|
|
2452
|
+
const encode = c.provider.wire === "openai" ? "toOpenAIMessages" : "toAnthropicMessages";
|
|
2453
|
+
return `import { ${read}, ${encode} } from '@kitn.ai/ui/wire';`;
|
|
2454
|
+
}
|
|
2455
|
+
function emitProviderSetup(c) {
|
|
2456
|
+
if (c.provider.mode === "mock") {
|
|
2457
|
+
const script = mockScriptFor(c);
|
|
2458
|
+
const hasOutputs = Object.keys(script.toolOutputs).length > 0;
|
|
2459
|
+
const cardsNote = c.cards ? `
|
|
2460
|
+
// Cards demo keylessly: the script's \`kai_<card name>\` call renders exactly
|
|
2461
|
+
// like a live model's would, below.` : "";
|
|
2462
|
+
const outputsDecl = hasOutputs ? `
|
|
2463
|
+
|
|
2464
|
+
// Scripted outputs for the demo tool calls above. The wire only ever ANNOUNCES
|
|
2465
|
+
// a call — executing it and answering is the host's side of the seam — so the
|
|
2466
|
+
// mock's "host" is this map plus the settle step after the read. It disappears
|
|
2467
|
+
// with the mock: a real backend's tool loop replaces it.
|
|
2468
|
+
const MOCK_TOOL_OUTPUTS: Record<string, Record<string, unknown>> = ${JSON.stringify(script.toolOutputs, null, 2)};` : "";
|
|
2469
|
+
return `// Provider seam: mock — keyless, streams locally, announces itself once.
|
|
2470
|
+
// Swap for provider.mode "endpoint" in the construct and re-run kai dev; the
|
|
2471
|
+
// generated fetch keeps this exact shape (the seam is the point).${cardsNote}
|
|
2472
|
+
//
|
|
2473
|
+
// The script below is this template's mock conversation: it exercises every
|
|
2474
|
+
// content type this construct enables (reasoning, citations, tool rows${c.cards ? ", cards" : ""})
|
|
2475
|
+
// through the kit's real parser, so the first run SHOWS the rendering paths a
|
|
2476
|
+
// live model would use. Edit it freely — it is data, not wiring.
|
|
2477
|
+
const MOCK_SCRIPT: MockReply[] = ${JSON.stringify(script.replies, null, 2)};${outputsDecl}
|
|
2478
|
+
|
|
2479
|
+
const respond = createMockResponder({ replies: MOCK_SCRIPT });
|
|
2480
|
+
const chat = createKaiChat();
|
|
2481
|
+
|
|
2482
|
+
async function submit(detail: { value: string; attachments: AttachmentData[] }) {
|
|
2483
|
+
if (!detail.value.trim() || chat.loading()) return;
|
|
2484
|
+
chat.append({
|
|
2485
|
+
id: crypto.randomUUID(),
|
|
2486
|
+
role: 'user',
|
|
2487
|
+
parts: [
|
|
2488
|
+
{ type: 'text', text: detail.value },
|
|
2489
|
+
...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
|
|
2490
|
+
],
|
|
2491
|
+
});
|
|
2492
|
+
const stream = chat.streamAssistant();
|
|
2493
|
+
try {
|
|
2494
|
+
await readOpenAIStream(respond(detail.value), stream);${emitSettleMockTools(hasOutputs)}${emitApplyCardTools(c)}
|
|
2495
|
+
stream.done();
|
|
2496
|
+
} catch (err) {
|
|
2497
|
+
stream.abort(err instanceof Error ? err.message : String(err));
|
|
2498
|
+
}
|
|
2499
|
+
}`;
|
|
2500
|
+
}
|
|
2501
|
+
const { url, wire } = c.provider;
|
|
2502
|
+
const read = wire === "openai" ? "readOpenAIStream" : "readAnthropicStream";
|
|
2503
|
+
const encode = wire === "openai" ? "toOpenAIMessages" : "toAnthropicMessages";
|
|
2504
|
+
return `// Provider seam: YOUR endpoint (${wire} wire, see the fetch call below
|
|
2505
|
+
// for the URL). The kit PARSES, this component FETCHES — no key, no
|
|
2506
|
+
// provider SDK, no client in here. Your route holds the key and re-frames to
|
|
2507
|
+
// the provider; the kai MCP scaffold tool emits one for your framework.
|
|
2508
|
+
const chat = createKaiChat();
|
|
2509
|
+
|
|
2510
|
+
async function submit(detail: { value: string; attachments: AttachmentData[] }) {
|
|
2511
|
+
if (!detail.value.trim() || chat.loading()) return;
|
|
2512
|
+
chat.append({
|
|
2513
|
+
id: crypto.randomUUID(),
|
|
2514
|
+
role: 'user',
|
|
2515
|
+
parts: [
|
|
2516
|
+
{ type: 'text', text: detail.value },
|
|
2517
|
+
...detail.attachments.map((attachment) => ({ type: 'file' as const, attachment })),
|
|
2518
|
+
],
|
|
2519
|
+
});
|
|
2520
|
+
const stream = chat.streamAssistant();
|
|
2521
|
+
try {
|
|
2522
|
+
const response = await fetch(${JSON.stringify(url)}, {
|
|
2523
|
+
method: 'POST',
|
|
2524
|
+
headers: { 'content-type': 'application/json'${emitUserIdHeaderEntry(c)} },
|
|
2525
|
+
body: JSON.stringify({ messages: ${encode}(chat.messages())${emitToolsField(c)} }),
|
|
2526
|
+
});
|
|
2527
|
+
if (!response.ok) throw new Error(\`endpoint responded \${response.status}\`);
|
|
2528
|
+
await ${read}(response, stream);${emitApplyCardTools(c)}
|
|
2529
|
+
stream.done();
|
|
2530
|
+
} catch (err) {
|
|
2531
|
+
stream.abort(err instanceof Error ? err.message : String(err));
|
|
2532
|
+
}
|
|
2533
|
+
}`;
|
|
2534
|
+
}
|
|
2535
|
+
function emitLayoutImport(c) {
|
|
2536
|
+
switch (c.layout) {
|
|
2537
|
+
case "widget":
|
|
2538
|
+
return `, Dock${hasLauncherIcon(c) ? ", DockLauncherImage" : ""}`;
|
|
2539
|
+
case "split":
|
|
2540
|
+
return workSurfaceOf(c) ? ", WorkspaceShell, WorkSurface" : ", WorkspaceShell";
|
|
2541
|
+
case "fullscreen":
|
|
2542
|
+
case "aside":
|
|
2543
|
+
return "";
|
|
2544
|
+
case "custom":
|
|
2545
|
+
return "";
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
function emitSlots(slots, indent) {
|
|
2549
|
+
if (!slots || slots.length === 0) return "";
|
|
2550
|
+
return slots.map((name) => `${indent}<slot name="${name}" />
|
|
2551
|
+
`).join("");
|
|
2552
|
+
}
|
|
2553
|
+
function splitNeedsPaneProbe(c) {
|
|
2554
|
+
return c.layout === "split" && !c.workSurface;
|
|
2555
|
+
}
|
|
2556
|
+
function emitPaneProbeVar(c, indent) {
|
|
2557
|
+
if (!splitNeedsPaneProbe(c)) return "";
|
|
2558
|
+
return `${indent}// Does the consumer project anything into <slot name="pane">? Read the
|
|
2559
|
+
${indent}// HOST's own light-DOM children: that is observable whether or not the
|
|
2560
|
+
${indent}// column (and with it the <slot>) is currently mounted, so it cannot
|
|
2561
|
+
${indent}// deadlock the way a slotchange listener on an unmounted slot would.
|
|
2562
|
+
${indent}const [paneProjected, setPaneProjected] = createSignal(false);
|
|
2563
|
+
${indent}onMount(() => {
|
|
2564
|
+
${indent} const sync = () => setPaneProjected(props.host.querySelector(':scope > [slot="pane"]') !== null);
|
|
2565
|
+
${indent} const observer = new MutationObserver(sync);
|
|
2566
|
+
${indent} observer.observe(props.host, { childList: true });
|
|
2567
|
+
${indent} sync();
|
|
2568
|
+
${indent} onCleanup(() => observer.disconnect());
|
|
2569
|
+
${indent}});
|
|
2570
|
+
`;
|
|
2571
|
+
}
|
|
2572
|
+
function needsHost(c) {
|
|
2573
|
+
return hasThemeToggleChrome(c) || hasHeaderActionsChrome(c) || hasUserMenuChrome(c) || splitNeedsPaneProbe(c);
|
|
2574
|
+
}
|
|
2575
|
+
function emitDockPosition(c) {
|
|
2576
|
+
const w = c.layout === "widget" ? c.widget : void 0;
|
|
2577
|
+
return w?.position ? ` position="${w.position}"` : "";
|
|
2578
|
+
}
|
|
2579
|
+
function hasLauncherIcon(c) {
|
|
2580
|
+
return c.layout === "widget" && !!c.widget?.launcherIcon;
|
|
2581
|
+
}
|
|
2582
|
+
function emitDockLauncher(c) {
|
|
2583
|
+
const w = c.layout === "widget" ? c.widget : void 0;
|
|
2584
|
+
if (!w?.launcherIcon) return "";
|
|
2585
|
+
return ` launcher={<DockLauncherImage src={${JSON.stringify(w.launcherIcon)}} />}`;
|
|
2586
|
+
}
|
|
2587
|
+
function emitDockDefaultOpen(c) {
|
|
2588
|
+
const w = c.layout === "widget" ? c.widget : void 0;
|
|
2589
|
+
return w?.defaultOpen === true ? " defaultOpen={true}" : "";
|
|
2590
|
+
}
|
|
2591
|
+
function workSurfaceOf(c) {
|
|
2592
|
+
return c.layout === "split" ? c.workSurface : void 0;
|
|
2593
|
+
}
|
|
2594
|
+
const WORK_SURFACE_IFRAME_TITLE = {
|
|
2595
|
+
artifact: "Work surface",
|
|
2596
|
+
preview: "App preview"
|
|
2597
|
+
};
|
|
2598
|
+
function emitWorkSurfaceVars(c, indent) {
|
|
2599
|
+
return workSurfaceOf(c)?.chrome?.expand ? `${indent}// workSurface.chrome.expand -> WorkspaceShell's own CONTROLLED startCollapsed
|
|
2600
|
+
${indent}// (collapse the chat rail, click again to restore). NOT the kai-resizable
|
|
2601
|
+
${indent}// maximize protocol: WorkspaceShell does not forward maximizedIndex/
|
|
2602
|
+
${indent}// onMaximizeChange — see components/work-surface/work-surface.tsx's doc comment.
|
|
2603
|
+
${indent}const [surfaceExpanded, setSurfaceExpanded] = createSignal(false);
|
|
2604
|
+
` : "";
|
|
2605
|
+
}
|
|
2606
|
+
function emitWorkSurface(c, indent) {
|
|
2607
|
+
const ws = workSurfaceOf(c);
|
|
2608
|
+
if (!ws) return "";
|
|
2609
|
+
const chrome = ws.chrome ?? {};
|
|
2610
|
+
const flag = (name, on) => `${indent} ${name}={${on === true}}
|
|
2611
|
+
`;
|
|
2612
|
+
return `${indent}<WorkSurface
|
|
2613
|
+
${indent} src={${JSON.stringify(ws.url)}}
|
|
2614
|
+
${indent} variant="${ws.kind}"
|
|
2615
|
+
${indent} iframeTitle={${JSON.stringify(WORK_SURFACE_IFRAME_TITLE[ws.kind])}}
|
|
2616
|
+
` + (chrome.urlBar ? `${indent} urlLabel={${JSON.stringify(ws.url)}}
|
|
2617
|
+
` : "") + (ws.codeUrl ? `${indent} codeSrc={${JSON.stringify(ws.codeUrl)}}
|
|
2618
|
+
` : "") + flag("showDeviceToggle", chrome.deviceToggle) + flag("showUrlBar", chrome.urlBar) + flag("showOpenInNewTab", chrome.openInNewTab) + flag("showExpand", chrome.expand) + flag("showCodeView", chrome.codeView) + (chrome.expand ? `${indent} expanded={surfaceExpanded()}
|
|
2619
|
+
${indent} onExpandedChange={setSurfaceExpanded}
|
|
2620
|
+
` : "") + `${indent}/>
|
|
2621
|
+
`;
|
|
2622
|
+
}
|
|
2623
|
+
function workSurfaceUrlIsRelative(url) {
|
|
2624
|
+
return !/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(url) && !url.startsWith("//");
|
|
2625
|
+
}
|
|
2626
|
+
const WORK_SURFACE_PAGE = "public/work-surface.html";
|
|
2627
|
+
function emitWorkSurfacePage(c) {
|
|
2628
|
+
const ws = workSurfaceOf(c);
|
|
2629
|
+
const headline = ws.kind === "artifact" ? "Your work surface" : "Your app preview";
|
|
2630
|
+
return `<!doctype html>
|
|
2631
|
+
<html lang="en">
|
|
2632
|
+
<head>
|
|
2633
|
+
<meta charset="utf-8" />
|
|
2634
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
2635
|
+
<title>${headline}</title>
|
|
2636
|
+
</head>
|
|
2637
|
+
<body style="margin: 0; background: #f8fafc; color: #0f172a; font: 15px/1.6 system-ui, -apple-system, sans-serif;">
|
|
2638
|
+
<main style="max-width: 34rem; margin: 0 auto; padding: 3.5rem 1.5rem;">
|
|
2639
|
+
<h1 style="margin: 0 0 0.5rem; font-size: 1.125rem; font-weight: 600;">${headline}</h1>
|
|
2640
|
+
<p style="margin: 0 0 1rem; color: #64748b;">
|
|
2641
|
+
This placeholder ships with the construct so the pane renders offline, with no network and no backend.
|
|
2642
|
+
</p>
|
|
2643
|
+
<p style="margin: 0; color: #64748b;">
|
|
2644
|
+
Replace it by pointing <code>workSurface.url</code> at your own page — or project your own markup as a
|
|
2645
|
+
<code><slot name="pane"></code> child of the element, which wins over this pane entirely.
|
|
2646
|
+
</p>
|
|
2647
|
+
</main>
|
|
2648
|
+
</body>
|
|
2649
|
+
</html>
|
|
2650
|
+
`;
|
|
2651
|
+
}
|
|
2652
|
+
function emitLayoutOpen(c) {
|
|
2653
|
+
switch (c.layout) {
|
|
2654
|
+
case "widget":
|
|
2655
|
+
return ` <Dock label="${c.name}"${emitDockPosition(c)}${emitDockLauncher(c)}${emitDockDefaultOpen(c)}${emitDockHideClose(c)}${emitDockControllerRef(c)}${emitDockOnOpenChangeProp(c)}${emitDockUnreadProp(c)}>
|
|
2656
|
+
`;
|
|
2657
|
+
case "fullscreen":
|
|
2658
|
+
return ` <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>
|
|
2659
|
+
`;
|
|
2660
|
+
case "aside": {
|
|
2661
|
+
const position = c.aside?.position ?? "end";
|
|
2662
|
+
const width = JSON.stringify(c.aside?.width ?? "380px");
|
|
2663
|
+
const inset = position === "start" ? "'inset-inline-start': '0'" : "'inset-inline-end': '0'";
|
|
2664
|
+
const borderSide = position === "start" ? "border-inline-end" : "border-inline-start";
|
|
2665
|
+
return ` <aside data-kai-layout="aside" style={{ position: 'fixed', 'inset-block': '0', ${inset}, width: ${width}, display: 'flex', 'flex-direction': 'column', '${borderSide}': '1px solid var(--kai-color-border)' }}>
|
|
2666
|
+
{/* Mirrors Dock's own narrow-viewport full-bleed rule (components/dock/dock.tsx:229-240)
|
|
2667
|
+
— aside has no dedicated kit component (see the emitLayoutOpen doc
|
|
2668
|
+
comment above), so this is the honest hand-rolled equivalent, not a
|
|
2669
|
+
new responsive strategy. */}
|
|
2670
|
+
<style>{\`@media (max-width: 480px) { [data-kai-layout="aside"] { inset: 0; width: auto; height: auto; ${borderSide}: 0; } }\`}</style>
|
|
2671
|
+
`;
|
|
2672
|
+
}
|
|
2673
|
+
case "split": {
|
|
2674
|
+
const ws = workSurfaceOf(c);
|
|
2675
|
+
const header = emitAppHeader(c, " ");
|
|
2676
|
+
const frameOpen = header ? ` <div style={{ height: '100dvh', display: 'flex', 'flex-direction': 'column' }}>
|
|
2677
|
+
${header}` : ` <div style={{ height: '100dvh' }}>
|
|
2678
|
+
`;
|
|
2679
|
+
const shellClass = header ? "min-h-0 flex-1" : "h-full";
|
|
2680
|
+
if (!ws) {
|
|
2681
|
+
return `${frameOpen} <WorkspaceShell class="${shellClass}" drawerBelow={480} end={paneProjected() ? (
|
|
2682
|
+
<div style={{ height: '100%', overflow: 'auto' }}>
|
|
2683
|
+
<slot name="pane" />
|
|
2684
|
+
</div>
|
|
2685
|
+
) : undefined}>
|
|
2686
|
+
`;
|
|
2687
|
+
}
|
|
2688
|
+
return `${frameOpen} <WorkspaceShell class="${shellClass}" drawerBelow={480} startWidth={360} startMinWidth={280} startMaxWidth={520}${ws.chrome?.expand ? " startCollapsed={surfaceExpanded()}" : ""} start={
|
|
2689
|
+
<div style={{ height: '100%', 'min-height': '0', display: 'flex', 'flex-direction': 'column' }}>
|
|
2690
|
+
`;
|
|
2691
|
+
}
|
|
2692
|
+
case "custom":
|
|
2693
|
+
return "";
|
|
2694
|
+
}
|
|
2695
|
+
}
|
|
2696
|
+
function emitLayoutClose(c) {
|
|
2697
|
+
switch (c.layout) {
|
|
2698
|
+
case "widget":
|
|
2699
|
+
return ` </Dock>
|
|
2700
|
+
`;
|
|
2701
|
+
case "fullscreen":
|
|
2702
|
+
return ` </div>
|
|
2703
|
+
`;
|
|
2704
|
+
case "aside":
|
|
2705
|
+
return ` </aside>
|
|
2706
|
+
`;
|
|
2707
|
+
case "split": {
|
|
2708
|
+
const ws = workSurfaceOf(c);
|
|
2709
|
+
if (!ws) {
|
|
2710
|
+
return ` </WorkspaceShell>
|
|
2711
|
+
</div>
|
|
2712
|
+
`;
|
|
2713
|
+
}
|
|
2714
|
+
return ` </div>
|
|
2715
|
+
}>
|
|
2716
|
+
{/* Your own <slot name="pane"> projection WINS over this: assigned nodes
|
|
2717
|
+
replace fallback content. The construct's work surface is the
|
|
2718
|
+
DEFAULT, not an override. Declared \`slots\` still render inside the
|
|
2719
|
+
chat rail above the thread — the same relative position they hold
|
|
2720
|
+
in every other layout. */}
|
|
2721
|
+
<slot name="pane">
|
|
2722
|
+
${emitWorkSurface(c, " ")} </slot>
|
|
2723
|
+
</WorkspaceShell>
|
|
2724
|
+
</div>
|
|
2725
|
+
`;
|
|
2726
|
+
}
|
|
2727
|
+
case "custom":
|
|
2728
|
+
return "";
|
|
2729
|
+
}
|
|
2730
|
+
}
|
|
2731
|
+
function emitTypes(c) {
|
|
2732
|
+
return `declare global {
|
|
2733
|
+
interface HTMLElementTagNameMap {
|
|
2734
|
+
'${c.name}': HTMLElement & { theme: 'light' | 'dark' | 'auto' };
|
|
2735
|
+
}
|
|
2736
|
+
}
|
|
2737
|
+
export {};
|
|
2738
|
+
`;
|
|
2739
|
+
}
|
|
2740
|
+
const MANIFEST = ".kai-manifest.json";
|
|
2741
|
+
function writeProject(files, dir) {
|
|
2742
|
+
const manifestPath = join(dir, MANIFEST);
|
|
2743
|
+
const previous = existsSync(manifestPath) ? JSON.parse(readFileSync(manifestPath, "utf8")) : [];
|
|
2744
|
+
const current = new Set(files.map((f) => f.path));
|
|
2745
|
+
for (const stale of previous) {
|
|
2746
|
+
if (!current.has(stale)) rmSync(join(dir, stale), { force: true });
|
|
2747
|
+
}
|
|
2748
|
+
const overwritten = [];
|
|
2749
|
+
for (const f of files) {
|
|
2750
|
+
const abs = join(dir, f.path);
|
|
2751
|
+
if (existsSync(abs)) {
|
|
2752
|
+
if (readFileSync(abs, "utf8") === f.code) continue;
|
|
2753
|
+
overwritten.push(f.path);
|
|
2754
|
+
}
|
|
2755
|
+
mkdirSync(dirname(abs), { recursive: true });
|
|
2756
|
+
writeFileSync(abs, f.code);
|
|
2757
|
+
}
|
|
2758
|
+
const manifestText = `${JSON.stringify([...current].sort(), null, 2)}
|
|
2759
|
+
`;
|
|
2760
|
+
if (!existsSync(manifestPath) || readFileSync(manifestPath, "utf8") !== manifestText) {
|
|
2761
|
+
writeFileSync(manifestPath, manifestText);
|
|
2762
|
+
}
|
|
2763
|
+
return overwritten;
|
|
2764
|
+
}
|
|
2765
|
+
const KIT_PACKAGE_NAME = "@kitn.ai/ui";
|
|
2766
|
+
function npmInvocation(platform = process.platform) {
|
|
2767
|
+
return platform === "win32" ? { command: "npm.cmd", shell: true } : { command: "npm", shell: false };
|
|
2768
|
+
}
|
|
2769
|
+
function npmArgs(args, shell) {
|
|
2770
|
+
return shell ? args.map((a) => /\s/.test(a) ? `"${a}"` : a) : args;
|
|
2771
|
+
}
|
|
2772
|
+
const LOCAL_KIT_CACHE_DIRNAME = ".kai-local-kit";
|
|
2773
|
+
const BUILD_COMMAND = "npx nx build ui (or: cd packages/ui && npm run build)";
|
|
2774
|
+
function resolveKitPackageRoot(startDir) {
|
|
2775
|
+
const tried = [];
|
|
2776
|
+
let dir = startDir;
|
|
2777
|
+
for (let i = 0; i < 8; i++) {
|
|
2778
|
+
const manifest = join(dir, "package.json");
|
|
2779
|
+
tried.push(manifest);
|
|
2780
|
+
if (existsSync(manifest)) {
|
|
2781
|
+
try {
|
|
2782
|
+
const parsed = JSON.parse(readFileSync(manifest, "utf8"));
|
|
2783
|
+
if (parsed.name === KIT_PACKAGE_NAME) return { dir };
|
|
2784
|
+
} catch {
|
|
2785
|
+
}
|
|
2786
|
+
}
|
|
2787
|
+
const parent = dirname(dir);
|
|
2788
|
+
if (parent === dir) break;
|
|
2789
|
+
dir = parent;
|
|
2790
|
+
}
|
|
2791
|
+
return { tried };
|
|
2792
|
+
}
|
|
2793
|
+
function localKitStartDir() {
|
|
2794
|
+
return dirname(fileURLToPath(import.meta.url));
|
|
2795
|
+
}
|
|
2796
|
+
function isSourceCheckout(pkgRoot) {
|
|
2797
|
+
return existsSync(join(pkgRoot, "mcp", "construct", "cli.ts")) && existsSync(join(pkgRoot, "..", "..", "pnpm-workspace.yaml"));
|
|
2798
|
+
}
|
|
2799
|
+
function distExportTargets(pkg) {
|
|
2800
|
+
const out = /* @__PURE__ */ new Set();
|
|
2801
|
+
const walk = (value) => {
|
|
2802
|
+
if (typeof value === "string") {
|
|
2803
|
+
if (value.startsWith("./dist/") && !value.includes("*")) out.add(value);
|
|
2804
|
+
return;
|
|
2805
|
+
}
|
|
2806
|
+
if (value && typeof value === "object") for (const nested of Object.values(value)) walk(nested);
|
|
2807
|
+
};
|
|
2808
|
+
walk(pkg?.exports);
|
|
2809
|
+
return [...out];
|
|
2810
|
+
}
|
|
2811
|
+
const SOURCE_EXTENSIONS = [".ts", ".tsx", ".css"];
|
|
2812
|
+
const GENERATED_SOURCES = /* @__PURE__ */ new Set([join("web-components", "compiled.css")]);
|
|
2813
|
+
function isSourceInput(rel) {
|
|
2814
|
+
if (GENERATED_SOURCES.has(rel)) return false;
|
|
2815
|
+
const base = rel.split(sep).at(-1);
|
|
2816
|
+
if (base.includes(".test.") || base.includes(".stories.")) return false;
|
|
2817
|
+
return SOURCE_EXTENSIONS.some((ext) => base.endsWith(ext));
|
|
2818
|
+
}
|
|
2819
|
+
function newestFile(root, accept) {
|
|
2820
|
+
let newest = null;
|
|
2821
|
+
const walk = (dir) => {
|
|
2822
|
+
let entries;
|
|
2823
|
+
try {
|
|
2824
|
+
entries = readdirSync(dir, { withFileTypes: true });
|
|
2825
|
+
} catch {
|
|
2826
|
+
return;
|
|
2827
|
+
}
|
|
2828
|
+
for (const entry of entries) {
|
|
2829
|
+
const full = join(dir, entry.name);
|
|
2830
|
+
if (entry.isDirectory()) {
|
|
2831
|
+
walk(full);
|
|
2832
|
+
continue;
|
|
2833
|
+
}
|
|
2834
|
+
if (!entry.isFile()) continue;
|
|
2835
|
+
const rel = relative(root, full);
|
|
2836
|
+
if (!accept(rel)) continue;
|
|
2837
|
+
const { mtimeMs } = statSync(full);
|
|
2838
|
+
if (!newest || mtimeMs > newest.mtimeMs) newest = { file: full, mtimeMs };
|
|
2839
|
+
}
|
|
2840
|
+
};
|
|
2841
|
+
walk(root);
|
|
2842
|
+
return newest;
|
|
2843
|
+
}
|
|
2844
|
+
function distProblem(pkgRoot) {
|
|
2845
|
+
const distDir = join(pkgRoot, "dist");
|
|
2846
|
+
if (!existsSync(distDir)) return `${distDir} does not exist — this checkout has never been built.`;
|
|
2847
|
+
let pkg;
|
|
2848
|
+
try {
|
|
2849
|
+
pkg = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8"));
|
|
2850
|
+
} catch (err) {
|
|
2851
|
+
return `cannot read ${join(pkgRoot, "package.json")}: ${err instanceof Error ? err.message : String(err)}`;
|
|
2852
|
+
}
|
|
2853
|
+
const missing = distExportTargets(pkg).filter((target) => !existsSync(join(pkgRoot, target)));
|
|
2854
|
+
if (missing.length > 0) {
|
|
2855
|
+
return `${missing.length} entry point(s) named by the exports map are missing from dist/, starting with ${missing.slice(0, 3).join(", ")} — the build is incomplete.`;
|
|
2856
|
+
}
|
|
2857
|
+
const newestSource = [newestFile(join(pkgRoot, "src"), isSourceInput), newestFile(join(pkgRoot, "mcp"), isSourceInput)].filter((entry) => entry !== null).sort((a, b) => b.mtimeMs - a.mtimeMs)[0];
|
|
2858
|
+
const newestBuilt = newestFile(distDir, () => true);
|
|
2859
|
+
if (newestSource && newestBuilt && newestSource.mtimeMs > newestBuilt.mtimeMs) {
|
|
2860
|
+
return `${relative(pkgRoot, newestSource.file)} is newer than everything in dist/ (newest built file: ${relative(pkgRoot, newestBuilt.file)}) — the build is stale.`;
|
|
2861
|
+
}
|
|
2862
|
+
return null;
|
|
2863
|
+
}
|
|
2864
|
+
function distFingerprint(distDir) {
|
|
2865
|
+
const hash = createHash("sha256");
|
|
2866
|
+
const walk = (dir) => {
|
|
2867
|
+
const out = [];
|
|
2868
|
+
for (const entry of [...readdirSync(dir, { withFileTypes: true })].sort((a, b) => a.name.localeCompare(b.name))) {
|
|
2869
|
+
const full = join(dir, entry.name);
|
|
2870
|
+
if (entry.isDirectory()) out.push(...walk(full));
|
|
2871
|
+
else out.push(full);
|
|
2872
|
+
}
|
|
2873
|
+
return out;
|
|
2874
|
+
};
|
|
2875
|
+
for (const file of walk(distDir)) {
|
|
2876
|
+
const stat = statSync(file);
|
|
2877
|
+
hash.update(file.slice(distDir.length));
|
|
2878
|
+
hash.update(String(stat.size));
|
|
2879
|
+
hash.update(String(stat.mtimeMs));
|
|
2880
|
+
}
|
|
2881
|
+
return hash.digest("hex").slice(0, 16);
|
|
2882
|
+
}
|
|
2883
|
+
function packLocalKit(pkgRoot) {
|
|
2884
|
+
const distDir = join(pkgRoot, "dist");
|
|
2885
|
+
const version = JSON.parse(readFileSync(join(pkgRoot, "package.json"), "utf8")).version;
|
|
2886
|
+
const cacheDir = join(pkgRoot, LOCAL_KIT_CACHE_DIRNAME);
|
|
2887
|
+
const tarball = join(cacheDir, `kitn.ai-ui-${version}-${distFingerprint(distDir)}.tgz`);
|
|
2888
|
+
if (existsSync(tarball)) return { tarball, packed: false };
|
|
2889
|
+
const stage = join(cacheDir, `.pack-${process.pid}`);
|
|
2890
|
+
mkdirSync(stage, { recursive: true });
|
|
2891
|
+
try {
|
|
2892
|
+
const npm = npmInvocation();
|
|
2893
|
+
execFileSync(npm.command, npmArgs(["pack", "--silent", "--pack-destination", stage], npm.shell), {
|
|
2894
|
+
cwd: pkgRoot,
|
|
2895
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
2896
|
+
shell: npm.shell
|
|
2897
|
+
});
|
|
2898
|
+
const produced = readdirSync(stage).filter((f) => f.endsWith(".tgz"));
|
|
2899
|
+
if (produced.length !== 1) {
|
|
2900
|
+
throw new Error(`npm pack wrote ${produced.length} tarball(s) into ${stage} — expected exactly one`);
|
|
2901
|
+
}
|
|
2902
|
+
renameSync(join(stage, produced[0]), tarball);
|
|
2903
|
+
} finally {
|
|
2904
|
+
rmSync(stage, { recursive: true, force: true });
|
|
2905
|
+
}
|
|
2906
|
+
for (const file of readdirSync(cacheDir)) {
|
|
2907
|
+
if (file.endsWith(".tgz") && join(cacheDir, file) !== tarball) rmSync(join(cacheDir, file), { force: true });
|
|
2908
|
+
}
|
|
2909
|
+
return { tarball, packed: true };
|
|
2910
|
+
}
|
|
2911
|
+
function classifyKit(explicit, startDir) {
|
|
2912
|
+
if (explicit !== void 0) return { kind: "explicit", uiSpec: explicit };
|
|
2913
|
+
const root = resolveKitPackageRoot(startDir);
|
|
2914
|
+
if (!("dir" in root)) return { kind: "published", why: `no ${KIT_PACKAGE_NAME} package.json above ${startDir}` };
|
|
2915
|
+
if (!isSourceCheckout(root.dir)) return { kind: "published", why: `${root.dir} is an installed package, not a checkout` };
|
|
2916
|
+
const problem = distProblem(root.dir);
|
|
2917
|
+
return problem ? { kind: "unbuilt", pkgRoot: root.dir, problem } : { kind: "checkout", pkgRoot: root.dir };
|
|
2918
|
+
}
|
|
2919
|
+
function unbuiltMessage(origin) {
|
|
2920
|
+
return `cannot use this checkout's @kitn.ai/ui build: ${origin.problem}
|
|
2921
|
+
Running the CLI from a source checkout (${origin.pkgRoot}) installs THAT build into the
|
|
2922
|
+
generated project, because the published version does not have the exports your source adds.
|
|
2923
|
+
Build it: ${BUILD_COMMAND}
|
|
2924
|
+
Or choose a kit explicitly: --ui <version|tarball|path>`;
|
|
2925
|
+
}
|
|
2926
|
+
function localKitNotice(pkgRoot, tarball, packed) {
|
|
2927
|
+
return `using this checkout's own @kitn.ai/ui build — ${join(pkgRoot, "dist")} ${packed ? "packed to" : "cached at"} ${tarball}. Generated projects install THAT, not the published version. Pass --ui <spec> to override.`;
|
|
2928
|
+
}
|
|
2929
|
+
const defaultIo = { log: (s) => console.log(s), error: (s) => console.error(s) };
|
|
2930
|
+
const USAGE = `usage: npx -y @kitn.ai/cli <command> (or \`kai <command>\` once @kitn.ai/cli is installed)
|
|
2931
|
+
|
|
2932
|
+
kai validate <construct.json> check a construct, print problems with paths
|
|
2933
|
+
kai eject <construct.json> <outDir> write the generated Solid project (it's yours)
|
|
2934
|
+
kai dev <construct.json> live preview with reload-on-edit
|
|
2935
|
+
kai dev --builder [name|construct.json] visual builder + live preview (no arg = your constructs, or the template picker)
|
|
2936
|
+
kai compile <construct.json> [outDir] one self-registering .js
|
|
2937
|
+
`;
|
|
2938
|
+
function homeRecentConversationWarning(construct) {
|
|
2939
|
+
if (construct.home?.recentConversation && !construct.capabilities?.conversations) {
|
|
2940
|
+
return "warning: home.recentConversation is set but capabilities.conversations is not — the recent-conversation card will render nothing without it.";
|
|
2941
|
+
}
|
|
2942
|
+
return null;
|
|
2943
|
+
}
|
|
2944
|
+
function workSurfaceProjectionNotice(construct) {
|
|
2945
|
+
if (!construct.workSurface) return null;
|
|
2946
|
+
return 'note: workSurface renders as <slot name="pane"> fallback — a child with slot="pane" projected by the consumer replaces it.';
|
|
2947
|
+
}
|
|
2948
|
+
function splitWithoutWorkSurfaceNotice(construct) {
|
|
2949
|
+
if (construct.layout !== "split" || construct.workSurface) return null;
|
|
2950
|
+
return 'note: layout "split" with no workSurface — the pane stays hidden until a child with slot="pane" is projected. Add a workSurface to render one.';
|
|
2951
|
+
}
|
|
2952
|
+
function generationNotices(construct) {
|
|
2953
|
+
return [
|
|
2954
|
+
accentContrastNotice(construct),
|
|
2955
|
+
workSurfaceProjectionNotice(construct),
|
|
2956
|
+
splitWithoutWorkSurfaceNotice(construct)
|
|
2957
|
+
].filter((n) => n !== null);
|
|
2958
|
+
}
|
|
2959
|
+
function loadConstruct(path, io) {
|
|
2960
|
+
const abs = resolve(path);
|
|
2961
|
+
let raw;
|
|
2962
|
+
try {
|
|
2963
|
+
raw = readFileSync(abs, "utf8");
|
|
2964
|
+
} catch {
|
|
2965
|
+
io.error(`cannot read ${abs}`);
|
|
2966
|
+
return null;
|
|
2967
|
+
}
|
|
2968
|
+
let json;
|
|
2969
|
+
try {
|
|
2970
|
+
json = JSON.parse(raw);
|
|
2971
|
+
} catch (err) {
|
|
2972
|
+
io.error(`${abs} is not valid JSON: ${err instanceof Error ? err.message : String(err)}`);
|
|
2973
|
+
return null;
|
|
2974
|
+
}
|
|
2975
|
+
const out = validateConstruct(json);
|
|
2976
|
+
if (!out.ok) {
|
|
2977
|
+
io.error(`${abs} is not a valid construct:`);
|
|
2978
|
+
for (const p of out.problems) io.error(` ${p.path || "(root)"}: ${p.message}`);
|
|
2979
|
+
return null;
|
|
2980
|
+
}
|
|
2981
|
+
return out.construct;
|
|
2982
|
+
}
|
|
2983
|
+
function vendorLocalKit(tarball, outDirAbs, io) {
|
|
2984
|
+
const vendorRel = join("vendor", basename(tarball));
|
|
2985
|
+
mkdirSync(join(outDirAbs, "vendor"), { recursive: true });
|
|
2986
|
+
copyFileSync(tarball, join(outDirAbs, vendorRel));
|
|
2987
|
+
io.log(`vendored this checkout's kit into ${join(outDirAbs, vendorRel)} — the ejected project installs that copy.`);
|
|
2988
|
+
return `file:${vendorRel.split(sep).join("/")}`;
|
|
2989
|
+
}
|
|
2990
|
+
function parseUiFlag(rest) {
|
|
2991
|
+
const uiFlag = rest.indexOf("--ui");
|
|
2992
|
+
const uiSpec = uiFlag >= 0 ? rest[uiFlag + 1] : void 0;
|
|
2993
|
+
const positional = uiFlag >= 0 ? rest.filter((_, i) => i !== uiFlag && i !== uiFlag + 1) : rest;
|
|
2994
|
+
return { uiSpec, positional };
|
|
2995
|
+
}
|
|
2996
|
+
function parseDevArgs(rest) {
|
|
2997
|
+
const { uiSpec, positional } = parseUiFlag(rest);
|
|
2998
|
+
const builder = positional.includes("--builder");
|
|
2999
|
+
const path = positional.filter((a) => a !== "--builder")[0];
|
|
3000
|
+
return { uiSpec, builder, path };
|
|
3001
|
+
}
|
|
3002
|
+
function resolveUiSpec(explicit, io) {
|
|
3003
|
+
const origin = classifyKit(explicit, localKitStartDir());
|
|
3004
|
+
switch (origin.kind) {
|
|
3005
|
+
case "explicit":
|
|
3006
|
+
return { ok: true, uiSpec: origin.uiSpec };
|
|
3007
|
+
case "published":
|
|
3008
|
+
return { ok: true, uiSpec: void 0 };
|
|
3009
|
+
case "unbuilt":
|
|
3010
|
+
io.error(unbuiltMessage(origin));
|
|
3011
|
+
return { ok: false };
|
|
3012
|
+
case "checkout": {
|
|
3013
|
+
let packed;
|
|
3014
|
+
try {
|
|
3015
|
+
packed = packLocalKit(origin.pkgRoot);
|
|
3016
|
+
} catch (err) {
|
|
3017
|
+
io.error(
|
|
3018
|
+
`packing this checkout's @kitn.ai/ui failed: ${err instanceof Error ? err.message : String(err)}
|
|
3019
|
+
Pass --ui <version|tarball|path> to choose a kit explicitly.`
|
|
3020
|
+
);
|
|
3021
|
+
return { ok: false };
|
|
3022
|
+
}
|
|
3023
|
+
io.log(localKitNotice(origin.pkgRoot, packed.tarball, packed.packed));
|
|
3024
|
+
return { ok: true, uiSpec: packed.tarball, localTarball: packed.tarball };
|
|
3025
|
+
}
|
|
3026
|
+
}
|
|
3027
|
+
}
|
|
3028
|
+
async function runCli(argv, io = defaultIo) {
|
|
3029
|
+
const [command, ...rest] = argv;
|
|
3030
|
+
switch (command) {
|
|
3031
|
+
case "validate": {
|
|
3032
|
+
const construct = loadConstruct(rest[0] ?? "", io);
|
|
3033
|
+
if (!construct) return 1;
|
|
3034
|
+
io.log(`valid construct: <${construct.name}> (layout: ${construct.layout}, provider: ${construct.provider.mode})`);
|
|
3035
|
+
const warning = homeRecentConversationWarning(construct);
|
|
3036
|
+
if (warning) io.log(warning);
|
|
3037
|
+
for (const n of generationNotices(construct)) io.log(n);
|
|
3038
|
+
return 0;
|
|
3039
|
+
}
|
|
3040
|
+
case "eject": {
|
|
3041
|
+
const { uiSpec, positional } = parseUiFlag(rest);
|
|
3042
|
+
const [path, outDir] = positional;
|
|
3043
|
+
if (!path || !outDir) {
|
|
3044
|
+
io.error(USAGE);
|
|
3045
|
+
return 2;
|
|
3046
|
+
}
|
|
3047
|
+
const construct = loadConstruct(path, io);
|
|
3048
|
+
if (!construct) return 1;
|
|
3049
|
+
const kit = resolveUiSpec(uiSpec, io);
|
|
3050
|
+
if (!kit.ok) return 1;
|
|
3051
|
+
const ejectUiSpec = kit.localTarball !== void 0 ? vendorLocalKit(kit.localTarball, resolve(outDir), io) : kit.uiSpec;
|
|
3052
|
+
const overwritten = writeProject(generateProject(construct, { uiSpec: ejectUiSpec }), resolve(outDir));
|
|
3053
|
+
if (overwritten.length > 0) {
|
|
3054
|
+
io.log(`overwriting ${overwritten.length} existing file(s)`);
|
|
3055
|
+
}
|
|
3056
|
+
for (const n of generationNotices(construct)) io.log(n);
|
|
3057
|
+
io.log(`ejected <${construct.name}> to ${resolve(outDir)} — npm install && npm run dev. The source is yours.`);
|
|
3058
|
+
return 0;
|
|
3059
|
+
}
|
|
3060
|
+
case "dev": {
|
|
3061
|
+
const { uiSpec, builder, path } = parseDevArgs(rest);
|
|
3062
|
+
if (builder) {
|
|
3063
|
+
const kit2 = resolveUiSpec(uiSpec, io);
|
|
3064
|
+
if (!kit2.ok) return 1;
|
|
3065
|
+
const { devBuilder } = await import("./assets/dev-C27Rnkul.js");
|
|
3066
|
+
await devBuilder(path, { io, uiSpec: kit2.uiSpec });
|
|
3067
|
+
return 0;
|
|
3068
|
+
}
|
|
3069
|
+
if (!path) {
|
|
3070
|
+
io.error(USAGE);
|
|
3071
|
+
return 2;
|
|
3072
|
+
}
|
|
3073
|
+
const kit = resolveUiSpec(uiSpec, io);
|
|
3074
|
+
if (!kit.ok) return 1;
|
|
3075
|
+
const { dev } = await import("./assets/dev-C27Rnkul.js");
|
|
3076
|
+
await dev(path, { io, uiSpec: kit.uiSpec });
|
|
3077
|
+
return 0;
|
|
3078
|
+
}
|
|
3079
|
+
case "compile": {
|
|
3080
|
+
const { uiSpec, positional } = parseUiFlag(rest);
|
|
3081
|
+
const [path, outArg] = positional;
|
|
3082
|
+
if (!path) {
|
|
3083
|
+
io.error(USAGE);
|
|
3084
|
+
return 2;
|
|
3085
|
+
}
|
|
3086
|
+
const construct = loadConstruct(path, io);
|
|
3087
|
+
if (!construct) return 1;
|
|
3088
|
+
const kit = resolveUiSpec(uiSpec, io);
|
|
3089
|
+
if (!kit.ok) return 1;
|
|
3090
|
+
const outDir = resolve(outArg ?? "dist-construct");
|
|
3091
|
+
const { workDirFor, ensureInstalled } = await import("./assets/dev-C27Rnkul.js");
|
|
3092
|
+
const dir = workDirFor(construct.name, process.cwd());
|
|
3093
|
+
const files = generateProject(construct, { uiSpec: kit.uiSpec });
|
|
3094
|
+
writeProject(files, dir);
|
|
3095
|
+
await ensureInstalled(dir, files, io);
|
|
3096
|
+
const npm = npmInvocation();
|
|
3097
|
+
await new Promise((done, fail) => {
|
|
3098
|
+
const child = spawn(npm.command, npmArgs(["run", "build"], npm.shell), { cwd: dir, stdio: "inherit", shell: npm.shell });
|
|
3099
|
+
child.on("exit", (code) => code === 0 ? done() : fail(new Error(`vite build exited ${code}`)));
|
|
3100
|
+
child.on("error", (err) => fail(new Error(`npm run build failed to start: ${err.message}`)));
|
|
3101
|
+
});
|
|
3102
|
+
mkdirSync(outDir, { recursive: true });
|
|
3103
|
+
copyFileSync(join(dir, "dist", `${construct.name}.js`), join(outDir, `${construct.name}.js`));
|
|
3104
|
+
writeFileSync(join(outDir, `${construct.name}.d.ts`), emitTypes(construct));
|
|
3105
|
+
writeProject(files, join(outDir, "source"));
|
|
3106
|
+
io.log(`compiled <${construct.name}> → ${outDir}/${construct.name}.js (source beside it in source/).`);
|
|
3107
|
+
io.log(`endpoint backends: the kai MCP scaffold tool emits a matching route — see its output for your framework.`);
|
|
3108
|
+
return 0;
|
|
3109
|
+
}
|
|
3110
|
+
default:
|
|
3111
|
+
io.error(USAGE);
|
|
3112
|
+
return 2;
|
|
3113
|
+
}
|
|
3114
|
+
}
|
|
3115
|
+
runCli(process.argv.slice(2)).then((code) => {
|
|
3116
|
+
process.exitCode = code;
|
|
3117
|
+
});
|
|
3118
|
+
export {
|
|
3119
|
+
npmArgs as a,
|
|
3120
|
+
generationNotices as b,
|
|
3121
|
+
buildableTemplates as c,
|
|
3122
|
+
generateProject as g,
|
|
3123
|
+
inferTemplateId as i,
|
|
3124
|
+
npmInvocation as n,
|
|
3125
|
+
templateById as t,
|
|
3126
|
+
validateConstruct as v,
|
|
3127
|
+
writeProject as w
|
|
3128
|
+
};
|