create-kai 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 +311 -0
- package/dist/index.js +4433 -0
- package/dist/templates/angular/_gitignore +7 -0
- package/dist/templates/angular/angular.json +53 -0
- package/dist/templates/angular/package.json +30 -0
- package/dist/templates/angular/src/app/app.config.ts +11 -0
- package/dist/templates/angular/src/app/app.css +36 -0
- package/dist/templates/angular/src/app/app.html +51 -0
- package/dist/templates/angular/src/app/app.ts +78 -0
- package/dist/templates/angular/src/app/components/composer/composer.css +31 -0
- package/dist/templates/angular/src/app/components/composer/composer.html +18 -0
- package/dist/templates/angular/src/app/components/composer/composer.ts +81 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.css +3 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.html +12 -0
- package/dist/templates/angular/src/app/components/icons/moon-icon/moon-icon.ts +15 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.css +3 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.html +14 -0
- package/dist/templates/angular/src/app/components/icons/sun-icon/sun-icon.ts +13 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.css +24 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.html +12 -0
- package/dist/templates/angular/src/app/components/sidebar/sidebar.ts +43 -0
- package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.html +13 -0
- package/dist/templates/angular/src/app/components/theme-toggle/theme-toggle.ts +21 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.css +6 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.html +6 -0
- package/dist/templates/angular/src/app/components/thread-view/thread-view.ts +45 -0
- package/dist/templates/angular/src/app/state/chat.store.ts +47 -0
- package/dist/templates/angular/src/app/state/conversations.store.ts +42 -0
- package/dist/templates/angular/src/app/state/voice-input.ts +57 -0
- package/dist/templates/angular/src/app/types.ts +2 -0
- package/dist/templates/angular/src/chat-data.ts +106 -0
- package/dist/templates/angular/src/index.html +16 -0
- package/dist/templates/angular/src/main.ts +16 -0
- package/dist/templates/angular/src/styles.css +10 -0
- package/dist/templates/angular/tsconfig.app.json +9 -0
- package/dist/templates/angular/tsconfig.json +22 -0
- package/dist/templates/nextjs/_gitignore +11 -0
- package/dist/templates/nextjs/app/chat-data.ts +125 -0
- package/dist/templates/nextjs/app/components/Composer.tsx +88 -0
- package/dist/templates/nextjs/app/components/HydrationBadge.tsx +68 -0
- package/dist/templates/nextjs/app/components/Sidebar.tsx +48 -0
- package/dist/templates/nextjs/app/components/ThemeToggle.tsx +27 -0
- package/dist/templates/nextjs/app/components/ThreadView.tsx +48 -0
- package/dist/templates/nextjs/app/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/nextjs/app/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/nextjs/app/components/icons/index.ts +2 -0
- package/dist/templates/nextjs/app/globals.css +153 -0
- package/dist/templates/nextjs/app/hooks/index.ts +1 -0
- package/dist/templates/nextjs/app/hooks/useConversations.ts +42 -0
- package/dist/templates/nextjs/app/layout.tsx +50 -0
- package/dist/templates/nextjs/app/page.tsx +17 -0
- package/dist/templates/nextjs/app/theme.ts +10 -0
- package/dist/templates/nextjs/app/workspace.tsx +148 -0
- package/dist/templates/nextjs/next.config.mjs +22 -0
- package/dist/templates/nextjs/package.json +23 -0
- package/dist/templates/nextjs/postcss.config.mjs +16 -0
- package/dist/templates/nextjs/tsconfig.json +23 -0
- package/dist/templates/react/_gitignore +6 -0
- package/dist/templates/react/index.html +16 -0
- package/dist/templates/react/package.json +25 -0
- package/dist/templates/react/src/App.tsx +108 -0
- package/dist/templates/react/src/chat-data.ts +106 -0
- package/dist/templates/react/src/components/Composer.tsx +87 -0
- package/dist/templates/react/src/components/Sidebar.tsx +38 -0
- package/dist/templates/react/src/components/ThemeToggle.tsx +22 -0
- package/dist/templates/react/src/components/ThreadView.tsx +40 -0
- package/dist/templates/react/src/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/react/src/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/react/src/components/icons/index.ts +2 -0
- package/dist/templates/react/src/hooks/index.ts +1 -0
- package/dist/templates/react/src/hooks/useConversations.ts +37 -0
- package/dist/templates/react/src/index.css +111 -0
- package/dist/templates/react/src/main.tsx +15 -0
- package/dist/templates/react/src/vite-env.d.ts +1 -0
- package/dist/templates/react/tsconfig.app.json +28 -0
- package/dist/templates/react/tsconfig.json +7 -0
- package/dist/templates/react/tsconfig.node.json +25 -0
- package/dist/templates/react/vite.config.ts +11 -0
- package/dist/templates/solid/_gitignore +6 -0
- package/dist/templates/solid/index.html +13 -0
- package/dist/templates/solid/package.json +23 -0
- package/dist/templates/solid/src/App.tsx +151 -0
- package/dist/templates/solid/src/chat-data.ts +234 -0
- package/dist/templates/solid/src/components/Composer.tsx +90 -0
- package/dist/templates/solid/src/components/Sidebar.tsx +36 -0
- package/dist/templates/solid/src/components/ThemeToggle.tsx +25 -0
- package/dist/templates/solid/src/components/ThreadView.tsx +122 -0
- package/dist/templates/solid/src/components/icons/index.tsx +84 -0
- package/dist/templates/solid/src/index.tsx +5 -0
- package/dist/templates/solid/src/lib/chat.ts +64 -0
- package/dist/templates/solid/src/lib/conversations.ts +54 -0
- package/dist/templates/solid/src/lib/types.ts +2 -0
- package/dist/templates/solid/src/styles.css +11 -0
- package/dist/templates/solid/tsconfig.json +18 -0
- package/dist/templates/solid/vite.config.ts +10 -0
- package/dist/templates/svelte/_gitignore +6 -0
- package/dist/templates/svelte/index.html +16 -0
- package/dist/templates/svelte/package.json +23 -0
- package/dist/templates/svelte/src/App.svelte +116 -0
- package/dist/templates/svelte/src/app.d.ts +20 -0
- package/dist/templates/svelte/src/chat-data.ts +106 -0
- package/dist/templates/svelte/src/components/Composer.svelte +102 -0
- package/dist/templates/svelte/src/components/Sidebar.svelte +61 -0
- package/dist/templates/svelte/src/components/ThemeToggle.svelte +45 -0
- package/dist/templates/svelte/src/components/ThreadView.svelte +49 -0
- package/dist/templates/svelte/src/components/icons/MoonIcon.svelte +24 -0
- package/dist/templates/svelte/src/components/icons/SunIcon.svelte +24 -0
- package/dist/templates/svelte/src/index.css +111 -0
- package/dist/templates/svelte/src/lib/chat.svelte.ts +52 -0
- package/dist/templates/svelte/src/lib/conversations.svelte.ts +46 -0
- package/dist/templates/svelte/src/lib/types.ts +2 -0
- package/dist/templates/svelte/src/lib/voiceInput.ts +57 -0
- package/dist/templates/svelte/src/main.ts +18 -0
- package/dist/templates/svelte/svelte.config.js +8 -0
- package/dist/templates/svelte/tsconfig.app.json +28 -0
- package/dist/templates/svelte/tsconfig.json +7 -0
- package/dist/templates/svelte/tsconfig.node.json +25 -0
- package/dist/templates/svelte/vite.config.ts +15 -0
- package/dist/templates/tanstack-start/_gitignore +14 -0
- package/dist/templates/tanstack-start/package.json +28 -0
- package/dist/templates/tanstack-start/serve.mjs +129 -0
- package/dist/templates/tanstack-start/serve.traversal.test.mjs +445 -0
- package/dist/templates/tanstack-start/src/chat-data.ts +124 -0
- package/dist/templates/tanstack-start/src/components/Composer.tsx +87 -0
- package/dist/templates/tanstack-start/src/components/HydrationBadge.tsx +63 -0
- package/dist/templates/tanstack-start/src/components/Sidebar.tsx +43 -0
- package/dist/templates/tanstack-start/src/components/ThemeToggle.tsx +26 -0
- package/dist/templates/tanstack-start/src/components/ThreadView.tsx +48 -0
- package/dist/templates/tanstack-start/src/components/icons/MoonIcon.tsx +25 -0
- package/dist/templates/tanstack-start/src/components/icons/SunIcon.tsx +25 -0
- package/dist/templates/tanstack-start/src/components/icons/index.ts +2 -0
- package/dist/templates/tanstack-start/src/hooks/index.ts +1 -0
- package/dist/templates/tanstack-start/src/hooks/useConversations.ts +42 -0
- package/dist/templates/tanstack-start/src/router.tsx +16 -0
- package/dist/templates/tanstack-start/src/routes/__root.tsx +62 -0
- package/dist/templates/tanstack-start/src/routes/index.tsx +135 -0
- package/dist/templates/tanstack-start/src/styles.css +153 -0
- package/dist/templates/tanstack-start/src/theme.ts +10 -0
- package/dist/templates/tanstack-start/tsconfig.json +16 -0
- package/dist/templates/tanstack-start/vite.config.ts +28 -0
- package/dist/templates/vanilla/_gitignore +6 -0
- package/dist/templates/vanilla/index.html +16 -0
- package/dist/templates/vanilla/package.json +19 -0
- package/dist/templates/vanilla/src/chat-data.ts +106 -0
- package/dist/templates/vanilla/src/index.css +111 -0
- package/dist/templates/vanilla/src/main.ts +53 -0
- package/dist/templates/vanilla/src/state.ts +119 -0
- package/dist/templates/vanilla/src/view.ts +222 -0
- package/dist/templates/vanilla/src/vite-env.d.ts +1 -0
- package/dist/templates/vanilla/src/voice-input.ts +55 -0
- package/dist/templates/vanilla/tsconfig.json +24 -0
- package/dist/templates/vanilla/vite.config.ts +14 -0
- package/dist/templates/vue/_gitignore +6 -0
- package/dist/templates/vue/index.html +16 -0
- package/dist/templates/vue/package.json +23 -0
- package/dist/templates/vue/src/App.vue +104 -0
- package/dist/templates/vue/src/chat-data.ts +106 -0
- package/dist/templates/vue/src/components/Composer.vue +96 -0
- package/dist/templates/vue/src/components/Sidebar.vue +48 -0
- package/dist/templates/vue/src/components/ThemeToggle.vue +27 -0
- package/dist/templates/vue/src/components/ThreadView.vue +50 -0
- package/dist/templates/vue/src/components/icons/MoonIcon.vue +24 -0
- package/dist/templates/vue/src/components/icons/SunIcon.vue +24 -0
- package/dist/templates/vue/src/composables/index.ts +3 -0
- package/dist/templates/vue/src/composables/useChat.ts +47 -0
- package/dist/templates/vue/src/composables/useConversations.ts +42 -0
- package/dist/templates/vue/src/composables/useVoiceInput.ts +57 -0
- package/dist/templates/vue/src/index.css +111 -0
- package/dist/templates/vue/src/main.ts +18 -0
- package/dist/templates/vue/src/types.ts +4 -0
- package/dist/templates/vue/src/vite-env.d.ts +1 -0
- package/dist/templates/vue/tsconfig.app.json +29 -0
- package/dist/templates/vue/tsconfig.json +7 -0
- package/dist/templates/vue/tsconfig.node.json +25 -0
- package/dist/templates/vue/vite.config.ts +24 -0
- package/package.json +63 -0
|
@@ -0,0 +1,445 @@
|
|
|
1
|
+
// Static-file guard tests for serve.mjs. Zero dependencies: `node --test`.
|
|
2
|
+
//
|
|
3
|
+
// node --test examples/starters/tanstack-start/serve.traversal.test.mjs
|
|
4
|
+
//
|
|
5
|
+
// This starter is deliberately NOT a pnpm workspace member (see pnpm-workspace.yaml
|
|
6
|
+
// -- it is a standalone `file:` consumer with its own npm lockfile), so it has no
|
|
7
|
+
// vitest and no nx `test` target. Node's built-in runner is the only thing available
|
|
8
|
+
// that costs no dependency and no lockfile churn.
|
|
9
|
+
//
|
|
10
|
+
// The tests spawn the REAL serve.mjs, byte-for-byte, against a synthetic fixture
|
|
11
|
+
// tree, and speak raw HTTP/1.0 over a socket so the request target arrives exactly
|
|
12
|
+
// as written. `fetch`/undici normalises or rejects several of these targets before
|
|
13
|
+
// they leave the client, which would quietly turn a probe into a no-op.
|
|
14
|
+
//
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// WHY THE `not vacuous` BLOCK EXISTS -- read this before deleting it
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// A traversal suite that has only ever run against fixed code proves nothing: the
|
|
19
|
+
// pre-fix serve.mjs never decoded anything, so every payload below would have
|
|
20
|
+
// passed against it *by accident*, and a green run would have been evidence of
|
|
21
|
+
// exactly nothing. The `not vacuous` block pins that down by re-running the same
|
|
22
|
+
// probes against two deliberately wrong implementations -- the two fixes a
|
|
23
|
+
// reasonable person actually writes -- and asserting they LEAK. If someone
|
|
24
|
+
// reorders the real guard back to either shape, the block that proves the suite
|
|
25
|
+
// can fail is itself the thing that starts failing.
|
|
26
|
+
import { test, describe, before, after } from 'node:test';
|
|
27
|
+
import assert from 'node:assert/strict';
|
|
28
|
+
import net from 'node:net';
|
|
29
|
+
import { spawn } from 'node:child_process';
|
|
30
|
+
import { mkdtemp, mkdir, writeFile, readFile, rm } from 'node:fs/promises';
|
|
31
|
+
import { join, dirname } from 'node:path';
|
|
32
|
+
import { tmpdir } from 'node:os';
|
|
33
|
+
import { fileURLToPath } from 'node:url';
|
|
34
|
+
|
|
35
|
+
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
36
|
+
const REAL_SERVE = join(HERE, 'serve.mjs');
|
|
37
|
+
|
|
38
|
+
// Markers. A response body containing one of these means the file was read.
|
|
39
|
+
const TOP_SECRET = 'TOP-SECRET-OUTSIDE-CLIENT-DIR';
|
|
40
|
+
const SIBLING_LEAK = 'SIBLING-DIR-LEAK';
|
|
41
|
+
const HASHED_ASSET = 'hashed-asset-ok';
|
|
42
|
+
const SPACE_ASSET = 'space-asset-ok';
|
|
43
|
+
const SSR = 'SSR-FALLBACK';
|
|
44
|
+
|
|
45
|
+
/** A stub of the fetch handler `vite build` emits at dist/server/server.js. */
|
|
46
|
+
const SERVER_STUB = `export default {
|
|
47
|
+
fetch: async () => new Response(${JSON.stringify(SSR)}, {
|
|
48
|
+
status: 200,
|
|
49
|
+
headers: { 'content-type': 'text/html' },
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Replace the whole `serveStatic` function in serve.mjs's source. Used only by the
|
|
56
|
+
* `not vacuous` block. Fails loudly rather than silently no-op'ing, so a refactor of
|
|
57
|
+
* serve.mjs surfaces as a broken test instead of a test that stopped testing.
|
|
58
|
+
*/
|
|
59
|
+
function withServeStatic(source, replacement) {
|
|
60
|
+
const re = /async function serveStatic\(pathname\) \{[\s\S]*?\n\}\n/;
|
|
61
|
+
assert.match(
|
|
62
|
+
source,
|
|
63
|
+
re,
|
|
64
|
+
'could not find `async function serveStatic(pathname) { ... }` in serve.mjs -- ' +
|
|
65
|
+
'the variant injection in the `not vacuous` block needs updating',
|
|
66
|
+
);
|
|
67
|
+
return source.replace(re, replacement);
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// The classic wrong order: containment is checked on the ENCODED path, and the
|
|
71
|
+
// decode that produces the string the filesystem actually sees happens afterwards.
|
|
72
|
+
// `%2e%2e` sails past a guard that already declared the path safe.
|
|
73
|
+
const VARIANT_GUARD_BEFORE_DECODE = `async function serveStatic(pathname) {
|
|
74
|
+
if (pathname === '/' || pathname.endsWith('/')) return null;
|
|
75
|
+
const filePath = join(clientDir, pathname);
|
|
76
|
+
if (!filePath.startsWith(clientDir)) return null; // guard runs on the ENCODED path
|
|
77
|
+
const onDisk = decodeURIComponent(filePath); // ...and the decode happens AFTER it
|
|
78
|
+
try {
|
|
79
|
+
if (!(await stat(onDisk)).isFile()) return null;
|
|
80
|
+
} catch {
|
|
81
|
+
return null;
|
|
82
|
+
}
|
|
83
|
+
const body = await readFile(onDisk);
|
|
84
|
+
return new Response(body, {
|
|
85
|
+
headers: { 'content-type': TYPES[extname(onDisk)] ?? 'application/octet-stream' },
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
`;
|
|
89
|
+
|
|
90
|
+
// Decodes in the right place, but compares with a bare prefix match. `dist/client`
|
|
91
|
+
// is a string prefix of `dist/client-secret`, so a sibling directory is reachable.
|
|
92
|
+
// Also lets decodeURIComponent's URIError escape as a 500.
|
|
93
|
+
const VARIANT_PREFIX_MATCH = `async function serveStatic(pathname) {
|
|
94
|
+
if (pathname === '/' || pathname.endsWith('/')) return null;
|
|
95
|
+
const filePath = join(clientDir, decodeURIComponent(pathname));
|
|
96
|
+
if (!filePath.startsWith(clientDir)) return null; // no separator boundary
|
|
97
|
+
try {
|
|
98
|
+
if (!(await stat(filePath)).isFile()) return null;
|
|
99
|
+
} catch {
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
const body = await readFile(filePath);
|
|
103
|
+
return new Response(body, {
|
|
104
|
+
headers: { 'content-type': TYPES[extname(filePath)] ?? 'application/octet-stream' },
|
|
105
|
+
});
|
|
106
|
+
}
|
|
107
|
+
`;
|
|
108
|
+
|
|
109
|
+
async function freePort() {
|
|
110
|
+
const probe = net.createServer();
|
|
111
|
+
await new Promise((resolve) => probe.listen(0, '127.0.0.1', resolve));
|
|
112
|
+
const { port } = probe.address();
|
|
113
|
+
await new Promise((resolve) => probe.close(resolve));
|
|
114
|
+
return port;
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Build a fixture tree and spawn serve.mjs against it.
|
|
119
|
+
*
|
|
120
|
+
* <fixture>/secret.txt <- TWO levels above clientDir
|
|
121
|
+
* <fixture>/dist/client-secret/leak.txt <- SIBLING whose name has clientDir as a prefix
|
|
122
|
+
* <fixture>/dist/client/assets/app.js <- an ordinary hashed asset
|
|
123
|
+
* <fixture>/dist/client/assets/a b.js <- a name only reachable once %20 is decoded
|
|
124
|
+
*/
|
|
125
|
+
async function startServer({ transform = (s) => s } = {}) {
|
|
126
|
+
const dir = await mkdtemp(join(tmpdir(), 'kai-tss-serve-'));
|
|
127
|
+
await mkdir(join(dir, 'dist', 'server'), { recursive: true });
|
|
128
|
+
await mkdir(join(dir, 'dist', 'client', 'assets'), { recursive: true });
|
|
129
|
+
await mkdir(join(dir, 'dist', 'client-secret'), { recursive: true });
|
|
130
|
+
|
|
131
|
+
await writeFile(join(dir, 'secret.txt'), TOP_SECRET);
|
|
132
|
+
await writeFile(join(dir, 'dist', 'client-secret', 'leak.txt'), SIBLING_LEAK);
|
|
133
|
+
await writeFile(join(dir, 'dist', 'server', 'server.js'), SERVER_STUB);
|
|
134
|
+
await writeFile(join(dir, 'dist', 'client', 'assets', 'app.js'), HASHED_ASSET);
|
|
135
|
+
await writeFile(join(dir, 'dist', 'client', 'assets', 'a b.js'), SPACE_ASSET);
|
|
136
|
+
|
|
137
|
+
const source = await readFile(REAL_SERVE, 'utf8');
|
|
138
|
+
await writeFile(join(dir, 'serve.mjs'), transform(source));
|
|
139
|
+
|
|
140
|
+
const port = await freePort();
|
|
141
|
+
const child = spawn(process.execPath, ['serve.mjs'], {
|
|
142
|
+
cwd: dir,
|
|
143
|
+
env: { ...process.env, PORT: String(port) },
|
|
144
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
145
|
+
});
|
|
146
|
+
let stderr = '';
|
|
147
|
+
child.stderr.on('data', (d) => {
|
|
148
|
+
stderr += d;
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
await new Promise((resolve, reject) => {
|
|
152
|
+
const timer = setTimeout(
|
|
153
|
+
() => reject(new Error(`serve.mjs did not start in 10s. stderr:\n${stderr}`)),
|
|
154
|
+
10_000,
|
|
155
|
+
);
|
|
156
|
+
child.stdout.on('data', (d) => {
|
|
157
|
+
if (String(d).includes('http://localhost:')) {
|
|
158
|
+
clearTimeout(timer);
|
|
159
|
+
resolve();
|
|
160
|
+
}
|
|
161
|
+
});
|
|
162
|
+
child.on('exit', (code) => {
|
|
163
|
+
clearTimeout(timer);
|
|
164
|
+
reject(new Error(`serve.mjs exited early (code ${code}). stderr:\n${stderr}`));
|
|
165
|
+
});
|
|
166
|
+
});
|
|
167
|
+
|
|
168
|
+
return {
|
|
169
|
+
port,
|
|
170
|
+
stderr: () => stderr,
|
|
171
|
+
async stop() {
|
|
172
|
+
child.kill('SIGKILL');
|
|
173
|
+
await rm(dir, { recursive: true, force: true });
|
|
174
|
+
},
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Send one request over a raw socket so the request target is byte-exact.
|
|
180
|
+
* HTTP/1.0 keeps the response unchunked and closes the connection, so the body is
|
|
181
|
+
* simply everything after the header block.
|
|
182
|
+
*
|
|
183
|
+
* A socket-level failure (Node's HTTP parser rejecting the target outright) resolves
|
|
184
|
+
* as status 0 -- for a malicious payload that is a perfectly good outcome, and the
|
|
185
|
+
* assertions below treat it as one.
|
|
186
|
+
*/
|
|
187
|
+
function get(port, target) {
|
|
188
|
+
return new Promise((resolve) => {
|
|
189
|
+
const socket = net.connect(port, '127.0.0.1');
|
|
190
|
+
const chunks = [];
|
|
191
|
+
socket.setTimeout(5000);
|
|
192
|
+
socket.on('connect', () => {
|
|
193
|
+
socket.write(`GET ${target} HTTP/1.0\r\nHost: 127.0.0.1:${port}\r\n\r\n`);
|
|
194
|
+
});
|
|
195
|
+
socket.on('data', (d) => chunks.push(d));
|
|
196
|
+
socket.on('timeout', () => {
|
|
197
|
+
socket.destroy();
|
|
198
|
+
resolve({ status: 0, head: '', body: '', note: 'TIMEOUT' });
|
|
199
|
+
});
|
|
200
|
+
socket.on('error', (err) => resolve({ status: 0, head: '', body: '', note: err.code ?? String(err) }));
|
|
201
|
+
socket.on('close', () => {
|
|
202
|
+
const text = Buffer.concat(chunks).toString('utf8');
|
|
203
|
+
const split = text.indexOf('\r\n\r\n');
|
|
204
|
+
const head = split === -1 ? text : text.slice(0, split);
|
|
205
|
+
const body = split === -1 ? '' : text.slice(split + 4);
|
|
206
|
+
const matched = /^HTTP\/\d\.\d (\d{3})/.exec(head);
|
|
207
|
+
resolve({ status: matched ? Number(matched[1]) : 0, head, body, note: '' });
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
// Payloads that reach serveStatic STILL ENCODED, because the WHATWG URL parser
|
|
213
|
+
// leaves %2f/%5c alone. These are the ones that can actually escape, and they are
|
|
214
|
+
// the ones the `not vacuous` block below uses to prove the suite bites.
|
|
215
|
+
const LIVE_PAYLOADS = [
|
|
216
|
+
['encoded slash', '/..%2f..%2fsecret.txt', TOP_SECRET],
|
|
217
|
+
['encoded slash, upper case', '/..%2F..%2Fsecret.txt', TOP_SECRET],
|
|
218
|
+
['fully encoded segment', '/%2e%2e%2f%2e%2e%2fsecret.txt', TOP_SECRET],
|
|
219
|
+
['fully encoded, upper case', '/%2E%2E%2F%2E%2E%2Fsecret.txt', TOP_SECRET],
|
|
220
|
+
['fully encoded, mixed case', '/%2e%2E%2f%2E%2e%2Fsecret.txt', TOP_SECRET],
|
|
221
|
+
['double encoded', '/%252e%252e/%252e%252e/secret.txt', TOP_SECRET],
|
|
222
|
+
['double encoded slash', '/..%252f..%252fsecret.txt', TOP_SECRET],
|
|
223
|
+
['triple encoded', '/%25252e%25252e/%25252e%25252e/secret.txt', TOP_SECRET],
|
|
224
|
+
['sibling dir sharing a name prefix', '/..%2fclient-secret%2fleak.txt', SIBLING_LEAK],
|
|
225
|
+
['sibling dir, fully encoded', '/%2e%2e%2fclient-secret%2fleak.txt', SIBLING_LEAK],
|
|
226
|
+
['encoded backslash', '/..%5c..%5csecret.txt', TOP_SECRET],
|
|
227
|
+
['encoded backslash, upper case', '/..%5C..%5Csecret.txt', TOP_SECRET],
|
|
228
|
+
['mixed separators', '/%2e%2e%5c%2e%2e%2fsecret.txt', TOP_SECRET],
|
|
229
|
+
['deep climb', '/%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2f%2e%2e%2fetc%2fpasswd', 'root:'],
|
|
230
|
+
['encoded absolute path', '/%2fetc%2fpasswd', 'root:'],
|
|
231
|
+
];
|
|
232
|
+
|
|
233
|
+
// Payloads the WHATWG URL parser already collapses before serveStatic sees them
|
|
234
|
+
// (`new URL` treats `%2e` as a dot for path normalisation). Kept deliberately: they
|
|
235
|
+
// are defence in depth, and they become live again the moment anyone stops routing
|
|
236
|
+
// the request target through `new URL` -- e.g. by splitting `req.url` on '?' by hand.
|
|
237
|
+
// The `relies on` block below pins the upstream behaviour so a Node change surfaces
|
|
238
|
+
// here rather than as a silent regression.
|
|
239
|
+
const NEUTRALISED_UPSTREAM = [
|
|
240
|
+
['plain dot-dot', '/../../secret.txt', TOP_SECRET],
|
|
241
|
+
['encoded dot-dot', '/%2e%2e/%2e%2e/secret.txt', TOP_SECRET],
|
|
242
|
+
['encoded dot-dot, upper case', '/%2E%2E/%2E%2E/secret.txt', TOP_SECRET],
|
|
243
|
+
['encoded dot-dot, mixed case', '/%2e%2E/%2E%2e/secret.txt', TOP_SECRET],
|
|
244
|
+
['encoded dot-dot then sibling', '/%2e%2e/client-secret/leak.txt', SIBLING_LEAK],
|
|
245
|
+
['plain backslash', '/..\\..\\secret.txt', TOP_SECRET],
|
|
246
|
+
['absolute path, plain', '/etc/passwd', 'root:'],
|
|
247
|
+
];
|
|
248
|
+
|
|
249
|
+
/** Every payload that must never reach a file outside dist/client. */
|
|
250
|
+
const TRAVERSAL_PAYLOADS = [...LIVE_PAYLOADS, ...NEUTRALISED_UPSTREAM];
|
|
251
|
+
|
|
252
|
+
describe('serve.mjs static guard', () => {
|
|
253
|
+
let server;
|
|
254
|
+
before(async () => {
|
|
255
|
+
server = await startServer();
|
|
256
|
+
});
|
|
257
|
+
after(async () => {
|
|
258
|
+
await server.stop();
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
for (const [name, target, marker] of TRAVERSAL_PAYLOADS) {
|
|
262
|
+
test(`refuses traversal: ${name} -- ${target}`, async () => {
|
|
263
|
+
const res = await get(server.port, target);
|
|
264
|
+
assert.ok(
|
|
265
|
+
!res.body.includes(marker),
|
|
266
|
+
`LEAKED. ${target} returned ${res.status} with a body containing ${JSON.stringify(marker)}:\n${res.body.slice(0, 400)}`,
|
|
267
|
+
);
|
|
268
|
+
assert.notEqual(res.status, 500, `${target} should not 500 (got head:\n${res.head})`);
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
test('a decode that throws is a 400, not a crash', async () => {
|
|
273
|
+
// decodeURIComponent('%') throws URIError.
|
|
274
|
+
const res = await get(server.port, '/%');
|
|
275
|
+
assert.equal(res.status, 400, `expected 400 for "/%", got ${res.status}\n${res.head}`);
|
|
276
|
+
});
|
|
277
|
+
|
|
278
|
+
test('an invalid escape sequence is a 400', async () => {
|
|
279
|
+
const res = await get(server.port, '/assets/%zz.js');
|
|
280
|
+
assert.equal(res.status, 400, `expected 400 for "/assets/%zz.js", got ${res.status}\n${res.head}`);
|
|
281
|
+
});
|
|
282
|
+
|
|
283
|
+
test('a lone trailing percent is a 400', async () => {
|
|
284
|
+
const res = await get(server.port, '/assets/app.js%');
|
|
285
|
+
assert.equal(res.status, 400, `expected 400 for "/assets/app.js%", got ${res.status}\n${res.head}`);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
test('a null byte is rejected, not passed to the filesystem', async () => {
|
|
289
|
+
const res = await get(server.port, '/assets/app.js%00.png');
|
|
290
|
+
assert.equal(res.status, 400, `expected 400 for an embedded null byte, got ${res.status}\n${res.head}`);
|
|
291
|
+
assert.ok(!res.body.includes(HASHED_ASSET), 'null-byte truncation served the asset');
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
test('a backslash is rejected rather than treated as a name character', async () => {
|
|
295
|
+
const res = await get(server.port, '/assets%5capp.js');
|
|
296
|
+
assert.equal(res.status, 400, `expected 400 for an encoded backslash, got ${res.status}\n${res.head}`);
|
|
297
|
+
});
|
|
298
|
+
|
|
299
|
+
test('the server survives the whole payload list and still serves assets', async () => {
|
|
300
|
+
for (const [, target] of TRAVERSAL_PAYLOADS) await get(server.port, target);
|
|
301
|
+
const res = await get(server.port, '/assets/app.js');
|
|
302
|
+
assert.equal(res.status, 200, `server stopped serving after the payload run\n${server.stderr()}`);
|
|
303
|
+
assert.ok(res.body.includes(HASHED_ASSET));
|
|
304
|
+
});
|
|
305
|
+
});
|
|
306
|
+
|
|
307
|
+
describe('serve.mjs still serves ordinary traffic', () => {
|
|
308
|
+
let server;
|
|
309
|
+
before(async () => {
|
|
310
|
+
server = await startServer();
|
|
311
|
+
});
|
|
312
|
+
after(async () => {
|
|
313
|
+
await server.stop();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
test('a hashed asset is served from disk with its content type', async () => {
|
|
317
|
+
const res = await get(server.port, '/assets/app.js');
|
|
318
|
+
assert.equal(res.status, 200, res.head);
|
|
319
|
+
assert.ok(res.body.includes(HASHED_ASSET), `body was ${JSON.stringify(res.body)}`);
|
|
320
|
+
assert.match(res.head, /content-type: text\/javascript/i);
|
|
321
|
+
});
|
|
322
|
+
|
|
323
|
+
test('a percent-encoded space resolves to the real filename', async () => {
|
|
324
|
+
const res = await get(server.port, '/assets/a%20b.js');
|
|
325
|
+
assert.equal(res.status, 200, res.head);
|
|
326
|
+
assert.ok(
|
|
327
|
+
res.body.includes(SPACE_ASSET),
|
|
328
|
+
`"/assets/a%20b.js" must serve the file named "a b.js"; got ${res.status}: ${JSON.stringify(res.body)}`,
|
|
329
|
+
);
|
|
330
|
+
assert.match(res.head, /content-type: text\/javascript/i);
|
|
331
|
+
});
|
|
332
|
+
|
|
333
|
+
test('an unknown extension falls back to octet-stream', async () => {
|
|
334
|
+
const res = await get(server.port, '/assets/a%20b.js');
|
|
335
|
+
assert.equal(res.status, 200, res.head);
|
|
336
|
+
});
|
|
337
|
+
|
|
338
|
+
test('/ falls through to the SSR handler', async () => {
|
|
339
|
+
const res = await get(server.port, '/');
|
|
340
|
+
assert.equal(res.status, 200, res.head);
|
|
341
|
+
assert.ok(res.body.includes(SSR), `body was ${JSON.stringify(res.body)}`);
|
|
342
|
+
});
|
|
343
|
+
|
|
344
|
+
test('an SPA route falls through to the SSR handler', async () => {
|
|
345
|
+
const res = await get(server.port, '/some/deep/route');
|
|
346
|
+
assert.equal(res.status, 200, res.head);
|
|
347
|
+
assert.ok(res.body.includes(SSR), `body was ${JSON.stringify(res.body)}`);
|
|
348
|
+
});
|
|
349
|
+
|
|
350
|
+
test('a missing asset falls through to the SSR handler', async () => {
|
|
351
|
+
const res = await get(server.port, '/assets/does-not-exist.js');
|
|
352
|
+
assert.equal(res.status, 200, res.head);
|
|
353
|
+
assert.ok(res.body.includes(SSR), `body was ${JSON.stringify(res.body)}`);
|
|
354
|
+
});
|
|
355
|
+
|
|
356
|
+
test('a trailing-slash path falls through to the SSR handler', async () => {
|
|
357
|
+
const res = await get(server.port, '/assets/');
|
|
358
|
+
assert.equal(res.status, 200, res.head);
|
|
359
|
+
assert.ok(res.body.includes(SSR), `body was ${JSON.stringify(res.body)}`);
|
|
360
|
+
});
|
|
361
|
+
});
|
|
362
|
+
|
|
363
|
+
describe('serve.mjs relies on `new URL` collapsing dot-segments', () => {
|
|
364
|
+
// serve.mjs hands serveStatic `url.pathname`, never the raw `req.url`. The WHATWG
|
|
365
|
+
// URL parser resolves dot-segments AND treats a percent-encoded `%2e` as a dot
|
|
366
|
+
// while doing it, so `..` and `%2e%2e` are gone before the handler runs. It does
|
|
367
|
+
// NOT decode `%2f`, which is why the encoded slash is the vector that survives.
|
|
368
|
+
//
|
|
369
|
+
// Written down as an executable assertion rather than a comment because the whole
|
|
370
|
+
// NEUTRALISED_UPSTREAM list is only safe for as long as this holds. Whoever swaps
|
|
371
|
+
// `new URL` for hand-rolled `req.url.split('?')[0]` parsing needs this to fail.
|
|
372
|
+
test('`..` and `%2e%2e` segments are resolved away', () => {
|
|
373
|
+
for (const raw of ['/../../secret.txt', '/%2e%2e/%2e%2e/secret.txt', '/%2E%2E/%2E%2E/secret.txt']) {
|
|
374
|
+
assert.equal(new URL(raw, 'http://h').pathname, '/secret.txt', `for ${raw}`);
|
|
375
|
+
}
|
|
376
|
+
});
|
|
377
|
+
|
|
378
|
+
test('a percent-encoded slash is NOT decoded, so it reaches the handler intact', () => {
|
|
379
|
+
assert.equal(new URL('/..%2f..%2fsecret.txt', 'http://h').pathname, '/..%2f..%2fsecret.txt');
|
|
380
|
+
assert.equal(new URL('/%2e%2e%2fsecret.txt', 'http://h').pathname, '/%2e%2e%2fsecret.txt');
|
|
381
|
+
});
|
|
382
|
+
});
|
|
383
|
+
|
|
384
|
+
// ---------------------------------------------------------------------------
|
|
385
|
+
// Proof the block above can fail. See the header comment.
|
|
386
|
+
// ---------------------------------------------------------------------------
|
|
387
|
+
describe('the guard suite is not vacuous', () => {
|
|
388
|
+
describe('a build that checks containment BEFORE decoding', () => {
|
|
389
|
+
let server;
|
|
390
|
+
before(async () => {
|
|
391
|
+
server = await startServer({
|
|
392
|
+
transform: (s) => withServeStatic(s, VARIANT_GUARD_BEFORE_DECODE),
|
|
393
|
+
});
|
|
394
|
+
});
|
|
395
|
+
after(async () => {
|
|
396
|
+
await server.stop();
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
for (const target of [
|
|
400
|
+
'/..%2f..%2fsecret.txt',
|
|
401
|
+
'/..%2F..%2Fsecret.txt',
|
|
402
|
+
'/%2e%2e%2f%2e%2e%2fsecret.txt',
|
|
403
|
+
]) {
|
|
404
|
+
test(`leaks a file two directories above dist/client via ${target}`, async () => {
|
|
405
|
+
const res = await get(server.port, target);
|
|
406
|
+
assert.ok(
|
|
407
|
+
res.body.includes(TOP_SECRET),
|
|
408
|
+
'the wrong-order build did NOT leak, so this payload proves nothing. ' +
|
|
409
|
+
`Got ${res.status}: ${JSON.stringify(res.body.slice(0, 200))}`,
|
|
410
|
+
);
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
});
|
|
414
|
+
|
|
415
|
+
describe('a build that decodes first but prefix-matches without a separator', () => {
|
|
416
|
+
let server;
|
|
417
|
+
before(async () => {
|
|
418
|
+
server = await startServer({ transform: (s) => withServeStatic(s, VARIANT_PREFIX_MATCH) });
|
|
419
|
+
});
|
|
420
|
+
after(async () => {
|
|
421
|
+
await server.stop();
|
|
422
|
+
});
|
|
423
|
+
|
|
424
|
+
for (const target of ['/..%2fclient-secret%2fleak.txt', '/%2e%2e%2fclient-secret%2fleak.txt']) {
|
|
425
|
+
test(`leaks a sibling directory whose name starts with the client dir via ${target}`, async () => {
|
|
426
|
+
const res = await get(server.port, target);
|
|
427
|
+
assert.ok(
|
|
428
|
+
res.body.includes(SIBLING_LEAK),
|
|
429
|
+
'the bare-prefix build did NOT leak the sibling, so that payload proves nothing. ' +
|
|
430
|
+
`Got ${res.status}: ${JSON.stringify(res.body.slice(0, 200))}`,
|
|
431
|
+
);
|
|
432
|
+
});
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
test('turns a URIError into a 500 instead of a 400', async () => {
|
|
436
|
+
const res = await get(server.port, '/%');
|
|
437
|
+
assert.equal(
|
|
438
|
+
res.status,
|
|
439
|
+
500,
|
|
440
|
+
`expected the unguarded decode to blow up as a 500; got ${res.status}. ` +
|
|
441
|
+
'If this is now 400 the variant no longer models the bug.',
|
|
442
|
+
);
|
|
443
|
+
});
|
|
444
|
+
});
|
|
445
|
+
});
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
// Self-contained sample data + the kit's shared mock responder — no backend.
|
|
2
|
+
//
|
|
3
|
+
// THIS MODULE IS EVALUATED TWICE: once on the server while the route is being
|
|
4
|
+
// rendered, and once in the browser. Everything in it is therefore plain data and
|
|
5
|
+
// pure functions — nothing here touches `window`, `document` or `customElements`,
|
|
6
|
+
// which is the rule for any module a server-rendered route imports.
|
|
7
|
+
//
|
|
8
|
+
// The date helper below is the one thing that produces a DIFFERENT value on each
|
|
9
|
+
// of those two evaluations, and it is safe for a specific reason worth knowing:
|
|
10
|
+
// `CONVERSATIONS` is only ever handed to `<kai-conversations>` as a JS PROPERTY,
|
|
11
|
+
// and the React wrappers assign properties in a layout effect, which never runs on
|
|
12
|
+
// the server. So no timestamp reaches the server-rendered HTML and there is
|
|
13
|
+
// nothing for hydration to compare. Render one of these into JSX and that stops
|
|
14
|
+
// being true — that is the shape of a real hydration mismatch.
|
|
15
|
+
import type { MessagePart } from '@kitn.ai/ui';
|
|
16
|
+
import { createMockResponder } from '@kitn.ai/ui/state';
|
|
17
|
+
|
|
18
|
+
export interface Conversation {
|
|
19
|
+
id: string;
|
|
20
|
+
title: string;
|
|
21
|
+
scope: { type: 'document' | 'collection' };
|
|
22
|
+
messageCount: number;
|
|
23
|
+
lastMessageAt: string;
|
|
24
|
+
updatedAt: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface Msg {
|
|
28
|
+
id: string;
|
|
29
|
+
role: 'user' | 'assistant';
|
|
30
|
+
parts: MessagePart[];
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function newId(): string {
|
|
34
|
+
return typeof crypto !== 'undefined' && typeof crypto.randomUUID === 'function'
|
|
35
|
+
? crypto.randomUUID()
|
|
36
|
+
: 'id-' + Math.random().toString(36).slice(2);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const iso = (daysAgo = 0) => new Date(Date.now() - daysAgo * 86_400_000).toISOString();
|
|
40
|
+
|
|
41
|
+
export const CONVERSATIONS: Conversation[] = [
|
|
42
|
+
{ id: 'c1', title: 'Server rendering', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
|
|
43
|
+
{ id: 'c2', title: 'Composing your own chat', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(0), updatedAt: iso(0) },
|
|
44
|
+
{ id: 'c3', title: 'Theming + dark mode', scope: { type: 'collection' }, messageCount: 2, lastMessageAt: iso(1), updatedAt: iso(1) },
|
|
45
|
+
];
|
|
46
|
+
|
|
47
|
+
export const THREADS: Record<string, Msg[]> = {
|
|
48
|
+
c1: [
|
|
49
|
+
{ id: 'c1u', role: 'user', parts: [{ type: 'text', text: 'How do the `kai-*` elements survive server rendering?' }] },
|
|
50
|
+
{ id: 'c1a', role: 'assistant', parts: [{ type: 'text', text: "They server-render as **bare tags**. The React wrappers register their element inside a layout effect, which never runs on the server, so the SSR pass emits `<kai-thread></kai-thread>` and nothing more. After hydration the wrapper defines the element and assigns `messages` as a live JS **property** — arrays and objects go across unstringified, so nothing has to be serialized into an attribute.\n\nView source on this page: you'll find the empty tags. Then watch this reply stream in — that's the same property channel still working, one new array reference per chunk." }] },
|
|
51
|
+
],
|
|
52
|
+
c2: [
|
|
53
|
+
{ id: 'c2u', role: 'user', parts: [{ type: 'text', text: 'How do I build my own chat instead of dropping in `<kai-chat>`?' }] },
|
|
54
|
+
{ id: 'c2a', role: 'assistant', parts: [{ type: 'text', text: "Put a `<Thread>` in a scroll area, a `<PromptInput>` below it, and let `useKaiChat` own the message array and the streaming. That's exactly what this route does — read `src/routes/index.tsx`." }] },
|
|
55
|
+
],
|
|
56
|
+
c3: [
|
|
57
|
+
{ id: 'c3u', role: 'user', parts: [{ type: 'text', text: 'How does dark mode work?' }] },
|
|
58
|
+
{ id: 'c3a', role: 'assistant', parts: [{ type: 'text', text: "Each element takes a `theme` prop (`light` / `dark` / `auto`). Drive it from route state — toggle it with the button top-right. The kit's `--color-*` tokens flip under a `.dark` class for your own surrounding chrome." }] },
|
|
59
|
+
],
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
export const SUGGESTIONS = [
|
|
63
|
+
'What survives server rendering?',
|
|
64
|
+
'How do I install it?',
|
|
65
|
+
'Show me some markdown',
|
|
66
|
+
];
|
|
67
|
+
|
|
68
|
+
// Rich entity triggers for the prompt input: typing `/` opens the skills menu,
|
|
69
|
+
// `@` opens the agents menu. Each selection inserts an atomic pill. Set on
|
|
70
|
+
// <PromptInput> as the `triggers` JS property (a TriggerDef[]).
|
|
71
|
+
export const TRIGGERS = [
|
|
72
|
+
{
|
|
73
|
+
char: '/',
|
|
74
|
+
kind: 'skill',
|
|
75
|
+
items: [
|
|
76
|
+
{ id: 'summarize', label: 'Summarize', description: 'Summarize the thread', promptText: 'Summarize the thread.' },
|
|
77
|
+
{ id: 'translate', label: 'Translate', description: 'Translate to English', promptText: 'Translate to English.' },
|
|
78
|
+
{ id: 'rewrite', label: 'Rewrite', description: 'Rewrite for clarity', promptText: 'Rewrite this for clarity.' },
|
|
79
|
+
],
|
|
80
|
+
},
|
|
81
|
+
{
|
|
82
|
+
char: '@',
|
|
83
|
+
kind: 'agent',
|
|
84
|
+
items: [
|
|
85
|
+
{ id: 'researcher', label: 'Researcher', description: 'Deep web research', group: 'Agents' },
|
|
86
|
+
{ id: 'coder', label: 'Coder', description: 'Writes and edits code', group: 'Agents' },
|
|
87
|
+
{ id: 'designer', label: 'Designer', description: 'UI and visual design', group: 'Agents' },
|
|
88
|
+
],
|
|
89
|
+
},
|
|
90
|
+
];
|
|
91
|
+
|
|
92
|
+
// The replies the mock cycles through, one per turn. Markdown-rich on purpose:
|
|
93
|
+
// the thread renders markdown, code blocks and blockquotes, so the canned replies
|
|
94
|
+
// exercise that instead of only proving text arrives.
|
|
95
|
+
//
|
|
96
|
+
// The first one says it is a mock in words. That is the WEAKEST of the responder's
|
|
97
|
+
// tells and the only one a real model could imitate, which is exactly why it is
|
|
98
|
+
// not the only one — the rest are on the wire, below.
|
|
99
|
+
const MOCK_REPLIES = [
|
|
100
|
+
"This reply streams token-by-token from the kit's own mock responder — no API key, no backend, no provider was contacted. Replace `mockResponse(text)` with a real model call (Anthropic, OpenAI, your own route) to ship a real app.",
|
|
101
|
+
"Install it:\n\n```bash\nnpm install @kitn.ai/ui\n```\n\nThen import the wrappers + tokens:\n\n```tsx\nimport { Thread, PromptInput } from '@kitn.ai/ui/react';\nimport '@kitn.ai/ui/theme.tokens.css';\n```",
|
|
102
|
+
"I render **bold**, *italic*, `inline code`, lists, and code blocks:\n\n```ts\nconst stream = chat.streamAssistant();\nawait readOpenAIStream(mockResponse(text), stream);\nstream.done();\n```\n\n> Blockquotes too.",
|
|
103
|
+
"Nothing about this thread came from the server. TanStack Start rendered the **empty** `<kai-thread>` tag, the browser hydrated it, and every chunk you are reading now arrived as a new `messages` array assigned to that element as a JS property.",
|
|
104
|
+
];
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* The kit's own mock responder, shared with the `kai` MCP scaffolder and
|
|
108
|
+
* `create-kai` so there is ONE implementation of this and not seven.
|
|
109
|
+
*
|
|
110
|
+
* It yields canned SSE frames in the OpenAI chat-completions shape, which
|
|
111
|
+
* `readOpenAIStream` parses exactly as it parses a real provider's. So this
|
|
112
|
+
* no-backend preview runs the kit's REAL streaming path — the SSE reader, the
|
|
113
|
+
* part folding, all of it — rather than a hand-rolled loop that merely resembles
|
|
114
|
+
* it. Going live then changes one expression, not the handler.
|
|
115
|
+
*
|
|
116
|
+
* It also cannot be mistaken for a real turn: the stream opens with a `: kai-mock`
|
|
117
|
+
* SSE comment, every frame carries a `_kai_mock` field, `model` reports as
|
|
118
|
+
* `kai-mock` (no provider serves that, so an echoed mock frame is rejected
|
|
119
|
+
* upstream rather than quietly believed), and usage is all zeros.
|
|
120
|
+
*
|
|
121
|
+
* MODULE scope, not per-send: the responder owns the cursor into the replies
|
|
122
|
+
* above, so rebuilding it each turn would answer with the first one forever.
|
|
123
|
+
*/
|
|
124
|
+
export const mockResponse = createMockResponder({ replies: MOCK_REPLIES });
|
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
import { useCallback, useRef } from 'react';
|
|
2
|
+
import { PromptInput, useVoiceInput } from '@kitn.ai/ui/react';
|
|
3
|
+
import type { PromptInputProps } from '@kitn.ai/ui/react';
|
|
4
|
+
import type { Theme } from '../theme';
|
|
5
|
+
|
|
6
|
+
interface ComposerProps {
|
|
7
|
+
theme: Theme;
|
|
8
|
+
loading: boolean;
|
|
9
|
+
suggestions: string[];
|
|
10
|
+
triggers: PromptInputProps['triggers'];
|
|
11
|
+
onSubmit: (value: string) => void;
|
|
12
|
+
onSuggestionClick: (value: string) => void;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The bottom composer. Wraps `<kai-prompt-input>` and owns the input-DOM concerns:
|
|
17
|
+
* the element ref, a live text mirror, clear-on-submit, and seeding voice.
|
|
18
|
+
*
|
|
19
|
+
* The prompt input stays UNCONTROLLED so the `/`+`@` trigger menus keep a live
|
|
20
|
+
* caret. Assigning a plain STRING `value` flips the element into controlled mode,
|
|
21
|
+
* which re-applies the property and collapses the shadow-DOM selection — that
|
|
22
|
+
* disables the caret-anchored menu. So we NEVER push a string: clear-on-submit
|
|
23
|
+
* goes through the element's `clear()` method, and voice seeds a ComposerDoc (a
|
|
24
|
+
* non-string value is a one-time seed that leaves the input uncontrolled). We keep
|
|
25
|
+
* the live text mirrored into `textRef` (from `onValueChange`) so voice can append.
|
|
26
|
+
*
|
|
27
|
+
* SERVER-RENDERING NOTE. `useVoiceInput` reports `supported: false` on the server
|
|
28
|
+
* (there is no `window.SpeechRecognition` there) and true in Chromium, so that
|
|
29
|
+
* flag is read ONLY inside the click handler and never during render. Branching
|
|
30
|
+
* render output on it would put a different tree in the server HTML than the
|
|
31
|
+
* client builds on hydration, which is the textbook hydration mismatch.
|
|
32
|
+
*/
|
|
33
|
+
export function Composer({ theme, loading, suggestions, triggers, onSubmit, onSuggestionClick }: ComposerProps) {
|
|
34
|
+
// Ref to the underlying <kai-prompt-input> element for the imperative pushes
|
|
35
|
+
// (clear() on submit, ComposerDoc seed from voice); textRef mirrors the live text.
|
|
36
|
+
const promptRef = useRef<HTMLElement>(null);
|
|
37
|
+
const textRef = useRef('');
|
|
38
|
+
|
|
39
|
+
const { supported: voiceSupported, start: startVoice } = useVoiceInput((transcript) => {
|
|
40
|
+
const next = (textRef.current ? textRef.current + ' ' : '') + transcript;
|
|
41
|
+
textRef.current = next;
|
|
42
|
+
const el = promptRef.current as (HTMLElement & { value?: unknown }) | null;
|
|
43
|
+
if (el) el.value = [{ type: 'text', text: next }];
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
const handleVoice = useCallback(() => {
|
|
47
|
+
// Mic is Chromium-only; degrade gracefully everywhere else.
|
|
48
|
+
if (!voiceSupported) {
|
|
49
|
+
alert('Voice input needs a Chromium browser.');
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
startVoice();
|
|
53
|
+
}, [voiceSupported, startVoice]);
|
|
54
|
+
|
|
55
|
+
const handleSubmit = useCallback(
|
|
56
|
+
(value: string) => {
|
|
57
|
+
// Clear our own uncontrolled input first — reset the mirror + call the
|
|
58
|
+
// element's clear() method (uncontrolled-safe; never a string assignment) —
|
|
59
|
+
// then hand the text up. The route owns append + streaming.
|
|
60
|
+
textRef.current = '';
|
|
61
|
+
(promptRef.current as (HTMLElement & { clear?: () => void }) | null)?.clear?.();
|
|
62
|
+
onSubmit(value);
|
|
63
|
+
},
|
|
64
|
+
[onSubmit],
|
|
65
|
+
);
|
|
66
|
+
|
|
67
|
+
return (
|
|
68
|
+
<div className="composer">
|
|
69
|
+
<PromptInput
|
|
70
|
+
ref={promptRef}
|
|
71
|
+
theme={theme}
|
|
72
|
+
placeholder="Message the demo…"
|
|
73
|
+
loading={loading}
|
|
74
|
+
suggestions={suggestions}
|
|
75
|
+
triggers={triggers}
|
|
76
|
+
voice
|
|
77
|
+
onVoice={handleVoice}
|
|
78
|
+
onValueChange={(e) => { textRef.current = e.detail.value; }}
|
|
79
|
+
onSubmit={(e) => handleSubmit(e.detail.value)}
|
|
80
|
+
onSuggestionClick={(e) => onSuggestionClick(e.detail.value)}
|
|
81
|
+
/>
|
|
82
|
+
<p className="composer-hint">
|
|
83
|
+
Type <kbd>/</kbd> for skills · <kbd>@</kbd> for agents
|
|
84
|
+
</p>
|
|
85
|
+
</div>
|
|
86
|
+
);
|
|
87
|
+
}
|