create-spatius-app 0.1.0-beta.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/LICENSE +21 -0
- package/README.md +57 -0
- package/dist/cli.js +9046 -0
- package/dist/cli.js.map +1 -0
- package/dist/templates.js +2856 -0
- package/dist/templates.js.map +1 -0
- package/package.json +79 -0
- package/schemas/result-v1.schema.json +129 -0
- package/schemas/result-v2.schema.json +146 -0
- package/templates/cloudflare-livekit/.editorconfig +15 -0
- package/templates/cloudflare-livekit/.prettierignore +6 -0
- package/templates/cloudflare-livekit/.prettierrc.json +4 -0
- package/templates/cloudflare-livekit/AGENTS.md +41 -0
- package/templates/cloudflare-livekit/README.md +37 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.pip +31 -0
- package/templates/cloudflare-livekit/agent/Dockerfile.uv +29 -0
- package/templates/cloudflare-livekit/agent/README.md +84 -0
- package/templates/cloudflare-livekit/agent/dockerignore +11 -0
- package/templates/cloudflare-livekit/agent/env.example +8 -0
- package/templates/cloudflare-livekit/agent/pyproject.toml +21 -0
- package/templates/cloudflare-livekit/agent/python-version +1 -0
- package/templates/cloudflare-livekit/agent/src/__init__.py +1 -0
- package/templates/cloudflare-livekit/agent/src/agent.py +97 -0
- package/templates/cloudflare-livekit/agent/src/dispatch_metadata.py +64 -0
- package/templates/cloudflare-livekit/agent/tests/test_agent_startup.py +211 -0
- package/templates/cloudflare-livekit/agent/tests/test_dispatch_metadata.py +97 -0
- package/templates/cloudflare-livekit/agent/uv.lock +2272 -0
- package/templates/cloudflare-livekit/components.json +23 -0
- package/templates/cloudflare-livekit/contracts/agent-dispatch-metadata.schema.json +40 -0
- package/templates/cloudflare-livekit/dev.vars.example +12 -0
- package/templates/cloudflare-livekit/eslint.config.mjs +41 -0
- package/templates/cloudflare-livekit/gitignore +30 -0
- package/templates/cloudflare-livekit/index.html +17 -0
- package/templates/cloudflare-livekit/npmrc +4 -0
- package/templates/cloudflare-livekit/package-lock.json +6514 -0
- package/templates/cloudflare-livekit/package.json +66 -0
- package/templates/cloudflare-livekit/playwright.config.ts +29 -0
- package/templates/cloudflare-livekit/pnpm-lock.yaml +4012 -0
- package/templates/cloudflare-livekit/pnpm-workspace.yaml +8 -0
- package/templates/cloudflare-livekit/scripts/agent-runtime.mjs +129 -0
- package/templates/cloudflare-livekit/scripts/command.mjs +52 -0
- package/templates/cloudflare-livekit/scripts/dev-agent.mjs +77 -0
- package/templates/cloudflare-livekit/scripts/dev-web.mjs +18 -0
- package/templates/cloudflare-livekit/scripts/managed-process.mjs +144 -0
- package/templates/cloudflare-livekit/scripts/process-tree.mjs +111 -0
- package/templates/cloudflare-livekit/scripts/run-agent-python.mjs +25 -0
- package/templates/cloudflare-livekit/tests/dev-agent.test.mjs +327 -0
- package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +235 -0
- package/templates/cloudflare-livekit/tests/dev-supervision.test.mjs +203 -0
- package/templates/cloudflare-livekit/tests/e2e/conversation.spec.ts +376 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-avatar.ts +24 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-components.tsx +173 -0
- package/templates/cloudflare-livekit/tests/e2e/fake-livekit.ts +123 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.html +12 -0
- package/templates/cloudflare-livekit/tests/e2e/harness.tsx +1 -0
- package/templates/cloudflare-livekit/tests/e2e/vite.config.ts +48 -0
- package/templates/cloudflare-livekit/tests/web/api.test.ts +64 -0
- package/templates/cloudflare-livekit/tests/web/app.test.tsx +125 -0
- package/templates/cloudflare-livekit/tests/web/audio-visualizer.test.tsx +53 -0
- package/templates/cloudflare-livekit/tests/web/avatar-session.test.ts +119 -0
- package/templates/cloudflare-livekit/tests/web/components.test.tsx +152 -0
- package/templates/cloudflare-livekit/tests/web/conversation.test.tsx +76 -0
- package/templates/cloudflare-livekit/tests/web/session-attempt.test.ts +178 -0
- package/templates/cloudflare-livekit/tsconfig.json +9 -0
- package/templates/cloudflare-livekit/tsconfig.node.json +23 -0
- package/templates/cloudflare-livekit/tsconfig.web.json +26 -0
- package/templates/cloudflare-livekit/tsconfig.worker.json +22 -0
- package/templates/cloudflare-livekit/vite.config.ts +49 -0
- package/templates/cloudflare-livekit/vitest.config.ts +11 -0
- package/templates/cloudflare-livekit/web/App.tsx +176 -0
- package/templates/cloudflare-livekit/web/api.ts +69 -0
- package/templates/cloudflare-livekit/web/assets/FONT-PROVENANCE.md +8 -0
- package/templates/cloudflare-livekit/web/assets/OFL.txt +92 -0
- package/templates/cloudflare-livekit/web/assets/inter-latin.woff2 +0 -0
- package/templates/cloudflare-livekit/web/avatar-session.ts +67 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/LICENSE +201 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/PROVENANCE.md +32 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-audio-visualizer-bar.tsx +43 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-input.tsx +78 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +145 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-disconnect-button.tsx +27 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-session-provider.tsx +29 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-track-toggle.tsx +51 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/start-audio-button.tsx +13 -0
- package/templates/cloudflare-livekit/web/components/conversation.tsx +352 -0
- package/templates/cloudflare-livekit/web/components/icons.tsx +39 -0
- package/templates/cloudflare-livekit/web/components/transcript-panel.tsx +12 -0
- package/templates/cloudflare-livekit/web/main.tsx +17 -0
- package/templates/cloudflare-livekit/web/session-attempt.ts +110 -0
- package/templates/cloudflare-livekit/web/styles.css +571 -0
- package/templates/cloudflare-livekit/worker/dispatch-metadata.ts +22 -0
- package/templates/cloudflare-livekit/worker/index.test.ts +152 -0
- package/templates/cloudflare-livekit/worker/index.ts +61 -0
- package/templates/cloudflare-livekit/worker/session.ts +103 -0
- package/templates/cloudflare-livekit/worker-configuration.d.ts +17184 -0
- package/templates/cloudflare-livekit/wrangler.jsonc +27 -0
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { handleRequest } from './index.js';
|
|
4
|
+
import { createSession } from './session.js';
|
|
5
|
+
|
|
6
|
+
const TEST_ENV = {
|
|
7
|
+
CARTESIA_VOICE_ID: '',
|
|
8
|
+
LIVEKIT_AGENT_NAME: 'spatius-agent',
|
|
9
|
+
LIVEKIT_API_KEY: 'test-api-key',
|
|
10
|
+
LIVEKIT_API_SECRET: 'test-api-secret-with-enough-entropy',
|
|
11
|
+
LIVEKIT_URL: 'wss://example.livekit.cloud',
|
|
12
|
+
SPATIUS_APP_ID: 'test-app-id',
|
|
13
|
+
SPATIUS_AVATAR_ID: 'test-avatar-id',
|
|
14
|
+
SPATIUS_AVATAR_BACKGROUND_URL: '',
|
|
15
|
+
} satisfies CloudflareBindings;
|
|
16
|
+
|
|
17
|
+
describe('createSession', () => {
|
|
18
|
+
it.each([
|
|
19
|
+
'9626c31c-bec5-4cca-baa8-f8ba9e84c8bc',
|
|
20
|
+
'a167e0f3-df7e-4d52-a9c3-f949145efdab',
|
|
21
|
+
])(
|
|
22
|
+
'dispatches the configured voice alongside the avatar: %s',
|
|
23
|
+
async (voiceId) => {
|
|
24
|
+
const session = await createSession({
|
|
25
|
+
...TEST_ENV,
|
|
26
|
+
CARTESIA_VOICE_ID: voiceId,
|
|
27
|
+
});
|
|
28
|
+
const payload = JSON.parse(
|
|
29
|
+
atob(session.participant_token.split('.')[1]),
|
|
30
|
+
) as {
|
|
31
|
+
roomConfig: { agents: Array<{ metadata: string }> };
|
|
32
|
+
};
|
|
33
|
+
expect(JSON.parse(payload.roomConfig.agents[0].metadata)).toEqual({
|
|
34
|
+
version: 1,
|
|
35
|
+
avatar: { id: TEST_ENV.SPATIUS_AVATAR_ID },
|
|
36
|
+
voice: { id: voiceId },
|
|
37
|
+
});
|
|
38
|
+
},
|
|
39
|
+
);
|
|
40
|
+
it('exposes only the optional public background URL, without changing dispatch metadata', async () => {
|
|
41
|
+
const background = 'https://cdn.example.com/background.jpg';
|
|
42
|
+
const result = await createSession({
|
|
43
|
+
...TEST_ENV,
|
|
44
|
+
SPATIUS_AVATAR_BACKGROUND_URL: background,
|
|
45
|
+
});
|
|
46
|
+
expect(result.spatius_avatar_background_url).toBe(background);
|
|
47
|
+
expect(await createSession(TEST_ENV)).not.toHaveProperty(
|
|
48
|
+
'spatius_avatar_background_url',
|
|
49
|
+
);
|
|
50
|
+
});
|
|
51
|
+
it('creates an anonymous room and a short-lived participant token', async () => {
|
|
52
|
+
const identifiers = ['room-id', 'participant-id'];
|
|
53
|
+
const session = await createSession(TEST_ENV, () => identifiers.shift()!);
|
|
54
|
+
|
|
55
|
+
expect(session).toMatchObject({
|
|
56
|
+
room_name: 'spatius-room-id',
|
|
57
|
+
server_url: TEST_ENV.LIVEKIT_URL,
|
|
58
|
+
spatius_app_id: TEST_ENV.SPATIUS_APP_ID,
|
|
59
|
+
spatius_avatar_id: TEST_ENV.SPATIUS_AVATAR_ID,
|
|
60
|
+
});
|
|
61
|
+
expect(session).not.toHaveProperty('spatius_region');
|
|
62
|
+
expect(session.participant_token.split('.')).toHaveLength(3);
|
|
63
|
+
|
|
64
|
+
const encodedPayload = session.participant_token.split('.')[1];
|
|
65
|
+
const payload = JSON.parse(atob(encodedPayload)) as {
|
|
66
|
+
exp: number;
|
|
67
|
+
nbf: number;
|
|
68
|
+
roomConfig: {
|
|
69
|
+
agents: Array<{ agentName: string; metadata: string }>;
|
|
70
|
+
};
|
|
71
|
+
sub: string;
|
|
72
|
+
video: { room: string; roomJoin: boolean };
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
expect(payload.sub).toBe('web-participant-id');
|
|
76
|
+
expect(payload.exp - payload.nbf).toBe(600);
|
|
77
|
+
expect(payload.video).toMatchObject({
|
|
78
|
+
room: 'spatius-room-id',
|
|
79
|
+
roomJoin: true,
|
|
80
|
+
});
|
|
81
|
+
expect(payload.roomConfig.agents).toEqual([
|
|
82
|
+
expect.objectContaining({
|
|
83
|
+
agentName: 'spatius-agent',
|
|
84
|
+
metadata: JSON.stringify({
|
|
85
|
+
avatar: { id: TEST_ENV.SPATIUS_AVATAR_ID },
|
|
86
|
+
version: 1,
|
|
87
|
+
}),
|
|
88
|
+
}),
|
|
89
|
+
]);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('rejects missing configuration without leaking secret values', async () => {
|
|
93
|
+
await expect(
|
|
94
|
+
createSession({ ...TEST_ENV, LIVEKIT_API_SECRET: '' }),
|
|
95
|
+
).rejects.toThrow('LIVEKIT_API_SECRET');
|
|
96
|
+
});
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
describe('Worker routes', () => {
|
|
100
|
+
it('returns a no-store session response', async () => {
|
|
101
|
+
const response = await handleRequest(
|
|
102
|
+
new Request('https://example.com/api/session', { method: 'POST' }),
|
|
103
|
+
TEST_ENV,
|
|
104
|
+
);
|
|
105
|
+
const body: unknown = await response.json();
|
|
106
|
+
|
|
107
|
+
expect(response.status).toBe(201);
|
|
108
|
+
expect(response.headers.get('cache-control')).toBe('no-store');
|
|
109
|
+
expect(body).toMatchObject({
|
|
110
|
+
server_url: TEST_ENV.LIVEKIT_URL,
|
|
111
|
+
spatius_app_id: TEST_ENV.SPATIUS_APP_ID,
|
|
112
|
+
spatius_avatar_id: TEST_ENV.SPATIUS_AVATAR_ID,
|
|
113
|
+
});
|
|
114
|
+
expect(JSON.stringify(body)).not.toContain(TEST_ENV.LIVEKIT_API_SECRET);
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
it('reports health and rejects unsupported routes and methods', async () => {
|
|
118
|
+
const health = await handleRequest(
|
|
119
|
+
new Request('https://example.com/api/health'),
|
|
120
|
+
TEST_ENV,
|
|
121
|
+
);
|
|
122
|
+
const wrongMethod = await handleRequest(
|
|
123
|
+
new Request('https://example.com/api/session'),
|
|
124
|
+
TEST_ENV,
|
|
125
|
+
);
|
|
126
|
+
const missing = await handleRequest(
|
|
127
|
+
new Request('https://example.com/api/missing'),
|
|
128
|
+
TEST_ENV,
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
await expect(health.json()).resolves.toEqual({ ok: true });
|
|
132
|
+
expect(wrongMethod.status).toBe(405);
|
|
133
|
+
expect(missing.status).toBe(404);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('returns a safe configuration error', async () => {
|
|
137
|
+
const consoleError = vi
|
|
138
|
+
.spyOn(console, 'error')
|
|
139
|
+
.mockImplementation(() => {});
|
|
140
|
+
const response = await handleRequest(
|
|
141
|
+
new Request('https://example.com/api/session', { method: 'POST' }),
|
|
142
|
+
{ ...TEST_ENV, LIVEKIT_API_KEY: '' },
|
|
143
|
+
);
|
|
144
|
+
|
|
145
|
+
expect(response.status).toBe(503);
|
|
146
|
+
await expect(response.json()).resolves.toEqual({
|
|
147
|
+
error:
|
|
148
|
+
'The voice session service is not configured. Check the Worker environment.',
|
|
149
|
+
});
|
|
150
|
+
consoleError.mockRestore();
|
|
151
|
+
});
|
|
152
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { ConfigurationError, createSession } from './session.js';
|
|
2
|
+
|
|
3
|
+
const JSON_HEADERS = {
|
|
4
|
+
'cache-control': 'no-store',
|
|
5
|
+
'content-type': 'application/json; charset=utf-8',
|
|
6
|
+
} as const;
|
|
7
|
+
|
|
8
|
+
function json(data: unknown, status = 200): Response {
|
|
9
|
+
return Response.json(data, {
|
|
10
|
+
headers: JSON_HEADERS,
|
|
11
|
+
status,
|
|
12
|
+
});
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export async function handleRequest(
|
|
16
|
+
request: Request,
|
|
17
|
+
env: CloudflareBindings,
|
|
18
|
+
): Promise<Response> {
|
|
19
|
+
const url = new URL(request.url);
|
|
20
|
+
|
|
21
|
+
if (url.pathname === '/api/health') {
|
|
22
|
+
if (request.method !== 'GET') {
|
|
23
|
+
return json({ error: 'Method not allowed.' }, 405);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return json({ ok: true });
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
if (url.pathname === '/api/session') {
|
|
30
|
+
if (request.method !== 'POST') {
|
|
31
|
+
return json({ error: 'Method not allowed.' }, 405);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try {
|
|
35
|
+
return json(await createSession(env), 201);
|
|
36
|
+
} catch (error) {
|
|
37
|
+
if (error instanceof ConfigurationError) {
|
|
38
|
+
console.error(
|
|
39
|
+
'Session endpoint is not configured correctly:',
|
|
40
|
+
error.message,
|
|
41
|
+
);
|
|
42
|
+
return json(
|
|
43
|
+
{
|
|
44
|
+
error:
|
|
45
|
+
'The voice session service is not configured. Check the Worker environment.',
|
|
46
|
+
},
|
|
47
|
+
503,
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
console.error('Could not create a voice session:', error);
|
|
52
|
+
return json({ error: 'Could not create a voice session.' }, 500);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
return json({ error: 'Not found.' }, 404);
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export default {
|
|
60
|
+
fetch: handleRequest,
|
|
61
|
+
};
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import {
|
|
2
|
+
AccessToken,
|
|
3
|
+
RoomAgentDispatch,
|
|
4
|
+
RoomConfiguration,
|
|
5
|
+
} from 'livekit-server-sdk';
|
|
6
|
+
|
|
7
|
+
import { serializeAgentDispatchMetadata } from './dispatch-metadata.js';
|
|
8
|
+
|
|
9
|
+
const TOKEN_TTL = '10m';
|
|
10
|
+
const REQUIRED_ENVIRONMENT_KEYS = [
|
|
11
|
+
'LIVEKIT_URL',
|
|
12
|
+
'LIVEKIT_API_KEY',
|
|
13
|
+
'LIVEKIT_API_SECRET',
|
|
14
|
+
'LIVEKIT_AGENT_NAME',
|
|
15
|
+
'SPATIUS_APP_ID',
|
|
16
|
+
'SPATIUS_AVATAR_ID',
|
|
17
|
+
] as const;
|
|
18
|
+
|
|
19
|
+
export interface SessionResponse {
|
|
20
|
+
participant_token: string;
|
|
21
|
+
room_name: string;
|
|
22
|
+
server_url: string;
|
|
23
|
+
spatius_app_id: string;
|
|
24
|
+
spatius_avatar_id: string;
|
|
25
|
+
spatius_avatar_background_url?: string;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export class ConfigurationError extends Error {
|
|
29
|
+
constructor(message: string) {
|
|
30
|
+
super(message);
|
|
31
|
+
this.name = 'ConfigurationError';
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function requireConfiguration(env: CloudflareBindings): void {
|
|
36
|
+
for (const key of REQUIRED_ENVIRONMENT_KEYS) {
|
|
37
|
+
const value = env[key];
|
|
38
|
+
|
|
39
|
+
if (typeof value !== 'string' || value.trim() === '') {
|
|
40
|
+
throw new ConfigurationError(`Missing required configuration: ${key}`);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
let livekitUrl: URL;
|
|
45
|
+
try {
|
|
46
|
+
livekitUrl = new URL(env.LIVEKIT_URL);
|
|
47
|
+
} catch {
|
|
48
|
+
throw new ConfigurationError('LIVEKIT_URL must be a valid URL.');
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!['wss:', 'https:'].includes(livekitUrl.protocol)) {
|
|
52
|
+
throw new ConfigurationError('LIVEKIT_URL must use wss:// or https://.');
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export async function createSession(
|
|
57
|
+
env: CloudflareBindings,
|
|
58
|
+
randomUUID: () => string = () => crypto.randomUUID(),
|
|
59
|
+
): Promise<SessionResponse> {
|
|
60
|
+
requireConfiguration(env);
|
|
61
|
+
|
|
62
|
+
const avatarId = env.SPATIUS_AVATAR_ID.trim();
|
|
63
|
+
const roomName = `spatius-${randomUUID()}`;
|
|
64
|
+
const participantIdentity = `web-${randomUUID()}`;
|
|
65
|
+
const accessToken = new AccessToken(
|
|
66
|
+
env.LIVEKIT_API_KEY,
|
|
67
|
+
env.LIVEKIT_API_SECRET,
|
|
68
|
+
{
|
|
69
|
+
identity: participantIdentity,
|
|
70
|
+
ttl: TOKEN_TTL,
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
accessToken.addGrant({
|
|
75
|
+
canPublish: true,
|
|
76
|
+
canPublishData: true,
|
|
77
|
+
canSubscribe: true,
|
|
78
|
+
room: roomName,
|
|
79
|
+
roomJoin: true,
|
|
80
|
+
});
|
|
81
|
+
accessToken.roomConfig = new RoomConfiguration({
|
|
82
|
+
agents: [
|
|
83
|
+
new RoomAgentDispatch({
|
|
84
|
+
agentName: env.LIVEKIT_AGENT_NAME,
|
|
85
|
+
metadata: serializeAgentDispatchMetadata(
|
|
86
|
+
avatarId,
|
|
87
|
+
env.CARTESIA_VOICE_ID,
|
|
88
|
+
),
|
|
89
|
+
}),
|
|
90
|
+
],
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
return {
|
|
94
|
+
participant_token: await accessToken.toJwt(),
|
|
95
|
+
room_name: roomName,
|
|
96
|
+
server_url: env.LIVEKIT_URL,
|
|
97
|
+
spatius_app_id: env.SPATIUS_APP_ID,
|
|
98
|
+
spatius_avatar_id: avatarId,
|
|
99
|
+
...(env.SPATIUS_AVATAR_BACKGROUND_URL
|
|
100
|
+
? { spatius_avatar_background_url: env.SPATIUS_AVATAR_BACKGROUND_URL }
|
|
101
|
+
: {}),
|
|
102
|
+
};
|
|
103
|
+
}
|