create-spatius-app 0.1.2 → 0.1.3-beta.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +17 -6
- package/dist/cli.js +721 -143
- package/dist/templates.js +612 -66
- package/package.json +4 -4
- package/schemas/result-v3.schema.json +6 -2
- package/schemas/result-v4.schema.json +170 -0
- package/templates/cloudflare-livekit/agent/env.example +3 -0
- package/templates/cloudflare-livekit/agent/src/agent.py +5 -3
- package/templates/cloudflare-livekit/package-lock.json +70 -142
- package/templates/cloudflare-livekit/package.json +1 -1
- package/templates/cloudflare-livekit/pnpm-lock.yaml +96 -153
- package/templates/cloudflare-livekit/tests/dev-events.test.mjs +23 -0
- package/templates/cloudflare-livekit/tests/dev-fixtures.mjs +7 -3
- package/templates/cloudflare-livekit/tsconfig.web.json +1 -0
- package/templates/cloudflare-livekit/tsconfig.worker.json +1 -0
- package/templates/cloudflare-livekit/web/api.ts +12 -0
- package/templates/cloudflare-livekit/web/components/agents-ui/agent-chat-transcript.tsx +6 -90
- package/templates/cloudflare-livekit/web/components/transcript-view.tsx +90 -0
- package/templates/cloudflare-livekit/web/session-attempt.ts +23 -2
- package/templates/cloudflare-livekit/worker/capability.test.ts +31 -0
- package/templates/cloudflare-livekit/worker/capability.ts +72 -0
- package/templates/cloudflare-livekit/worker/index.test.ts +2 -2
- package/templates/cloudflare-livekit/worker/index.ts +15 -3
- package/templates/cloudflare-livekit/worker/session.ts +34 -0
- package/templates/layers/agora/AGENTS.md +8 -0
- package/templates/layers/agora/README.md +19 -0
- package/templates/layers/agora/env.example +9 -0
- package/templates/layers/agora/package-lock.json +6068 -0
- package/templates/layers/agora/package.json +54 -0
- package/templates/layers/agora/pnpm-lock.yaml +3816 -0
- package/templates/layers/agora/tsconfig.json +16 -0
- package/templates/layers/agora/vitest.config.ts +4 -0
- package/templates/layers/agora/web/App.tsx +174 -0
- package/templates/layers/agora/web/agora-session.test.ts +70 -0
- package/templates/layers/agora/web/agora-session.ts +267 -0
- package/templates/layers/agora/worker/agora.test.ts +60 -0
- package/templates/layers/agora/worker/agora.ts +165 -0
- package/templates/layers/agora/worker/index.ts +50 -0
- package/templates/layers/catalog/DEPLOYMENT.md +3 -0
- package/templates/layers/catalog/spatius.config.json +1 -0
- package/templates/layers/node/Dockerfile +12 -0
- package/templates/layers/node/dockerignore +10 -0
- package/templates/layers/node/package-lock.json +5569 -0
- package/templates/layers/node/pnpm-lock.yaml +3470 -0
- package/templates/layers/node/railway.json +8 -0
- package/templates/layers/node/scripts/dev-service.mjs +14 -0
- package/templates/layers/node/scripts/dev-web.mjs +22 -0
- package/templates/layers/node/server/index.ts +92 -0
- package/templates/layers/node/vite.config.ts +17 -0
- package/templates/layers/railway-agent/agent/railway.json +5 -0
- package/dist/cli.js.map +0 -1
- package/dist/templates.js.map +0 -1
|
@@ -1,14 +1,10 @@
|
|
|
1
1
|
// Copyright LiveKit. Apache-2.0. Adapted from Agents UI; see ./PROVENANCE.md.
|
|
2
|
-
import {
|
|
3
|
-
import type {
|
|
4
|
-
export
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
isUser: boolean;
|
|
9
|
-
source?: 'user' | 'agent' | 'avatar';
|
|
10
|
-
senderIdentity?: string;
|
|
11
|
-
};
|
|
2
|
+
import type { ReceivedMessage } from '@livekit/components-react';
|
|
3
|
+
import type { TranscriptMessage } from '../transcript-view.js';
|
|
4
|
+
export {
|
|
5
|
+
AgentChatTranscript,
|
|
6
|
+
type TranscriptMessage,
|
|
7
|
+
} from '../transcript-view.js';
|
|
12
8
|
export type ConversationParticipants = {
|
|
13
9
|
agentIdentity?: string;
|
|
14
10
|
avatarIdentity?: string;
|
|
@@ -63,83 +59,3 @@ export function snapshotMessages(
|
|
|
63
59
|
];
|
|
64
60
|
});
|
|
65
61
|
}
|
|
66
|
-
export function AgentChatTranscript({
|
|
67
|
-
messages = [],
|
|
68
|
-
agentState,
|
|
69
|
-
agentName = 'Agent',
|
|
70
|
-
}: {
|
|
71
|
-
messages?: readonly TranscriptMessage[];
|
|
72
|
-
agentState?: AgentState;
|
|
73
|
-
agentName?: string;
|
|
74
|
-
}) {
|
|
75
|
-
const viewport = useRef<HTMLDivElement>(null);
|
|
76
|
-
const atBottom = useRef(true);
|
|
77
|
-
const [showLatest, setShowLatest] = useState(false);
|
|
78
|
-
const scrollToLatest = () => {
|
|
79
|
-
const element = viewport.current;
|
|
80
|
-
if (element) element.scrollTop = element.scrollHeight;
|
|
81
|
-
atBottom.current = true;
|
|
82
|
-
setShowLatest(false);
|
|
83
|
-
};
|
|
84
|
-
useLayoutEffect(() => {
|
|
85
|
-
if (atBottom.current) scrollToLatest();
|
|
86
|
-
else setShowLatest(true);
|
|
87
|
-
}, [messages, agentState]);
|
|
88
|
-
return (
|
|
89
|
-
<div className="transcript-container">
|
|
90
|
-
<div
|
|
91
|
-
ref={viewport}
|
|
92
|
-
className="transcript-scroll"
|
|
93
|
-
role="log"
|
|
94
|
-
aria-label="Conversation transcript"
|
|
95
|
-
aria-live="polite"
|
|
96
|
-
onScroll={(event) => {
|
|
97
|
-
const element = event.currentTarget;
|
|
98
|
-
atBottom.current =
|
|
99
|
-
element.scrollHeight - element.scrollTop - element.clientHeight <
|
|
100
|
-
48;
|
|
101
|
-
if (atBottom.current) setShowLatest(false);
|
|
102
|
-
}}
|
|
103
|
-
>
|
|
104
|
-
{messages.length === 0 && (
|
|
105
|
-
<p className="transcript-empty">
|
|
106
|
-
Your conversation will appear here as you speak or type.
|
|
107
|
-
</p>
|
|
108
|
-
)}
|
|
109
|
-
{messages.map(({ id, timestamp, isUser, message, source }) => (
|
|
110
|
-
<article
|
|
111
|
-
key={id}
|
|
112
|
-
className="transcript-message"
|
|
113
|
-
data-speaker={isUser ? 'user' : 'agent'}
|
|
114
|
-
data-source={source}
|
|
115
|
-
>
|
|
116
|
-
<div className="message-meta">
|
|
117
|
-
<span>{isUser ? 'You' : agentName}</span>
|
|
118
|
-
<time dateTime={new Date(timestamp).toISOString()}>
|
|
119
|
-
{new Date(timestamp).toLocaleTimeString([], {
|
|
120
|
-
hour: '2-digit',
|
|
121
|
-
minute: '2-digit',
|
|
122
|
-
})}
|
|
123
|
-
</time>
|
|
124
|
-
</div>
|
|
125
|
-
<p>{message}</p>
|
|
126
|
-
</article>
|
|
127
|
-
))}
|
|
128
|
-
{agentState === 'thinking' && (
|
|
129
|
-
<p className="thinking-indicator" role="status">
|
|
130
|
-
Thinking<span aria-hidden="true">…</span>
|
|
131
|
-
</p>
|
|
132
|
-
)}
|
|
133
|
-
</div>
|
|
134
|
-
{showLatest && (
|
|
135
|
-
<button
|
|
136
|
-
className="jump-to-latest"
|
|
137
|
-
type="button"
|
|
138
|
-
onClick={scrollToLatest}
|
|
139
|
-
>
|
|
140
|
-
Latest messages ↓
|
|
141
|
-
</button>
|
|
142
|
-
)}
|
|
143
|
-
</div>
|
|
144
|
-
);
|
|
145
|
-
}
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
// Copyright LiveKit. Apache-2.0. Adapted from Agents UI; see agents-ui/PROVENANCE.md.
|
|
2
|
+
import { useLayoutEffect, useRef, useState } from 'react';
|
|
3
|
+
export interface TranscriptMessage {
|
|
4
|
+
id: string;
|
|
5
|
+
timestamp: number;
|
|
6
|
+
message: string;
|
|
7
|
+
isUser: boolean;
|
|
8
|
+
source?: 'user' | 'agent' | 'avatar';
|
|
9
|
+
senderIdentity?: string;
|
|
10
|
+
}
|
|
11
|
+
export function AgentChatTranscript({
|
|
12
|
+
messages = [],
|
|
13
|
+
agentState,
|
|
14
|
+
agentName = 'Agent',
|
|
15
|
+
}: {
|
|
16
|
+
messages?: readonly TranscriptMessage[];
|
|
17
|
+
agentState?: string;
|
|
18
|
+
agentName?: string;
|
|
19
|
+
}) {
|
|
20
|
+
const viewport = useRef<HTMLDivElement>(null);
|
|
21
|
+
const atBottom = useRef(true);
|
|
22
|
+
const [showLatest, setShowLatest] = useState(false);
|
|
23
|
+
const scrollToLatest = () => {
|
|
24
|
+
const element = viewport.current;
|
|
25
|
+
if (element) element.scrollTop = element.scrollHeight;
|
|
26
|
+
atBottom.current = true;
|
|
27
|
+
setShowLatest(false);
|
|
28
|
+
};
|
|
29
|
+
useLayoutEffect(() => {
|
|
30
|
+
if (atBottom.current) scrollToLatest();
|
|
31
|
+
else setShowLatest(true);
|
|
32
|
+
}, [messages, agentState]);
|
|
33
|
+
return (
|
|
34
|
+
<div className="transcript-container">
|
|
35
|
+
<div
|
|
36
|
+
ref={viewport}
|
|
37
|
+
className="transcript-scroll"
|
|
38
|
+
role="log"
|
|
39
|
+
aria-label="Conversation transcript"
|
|
40
|
+
aria-live="polite"
|
|
41
|
+
onScroll={(event) => {
|
|
42
|
+
const element = event.currentTarget;
|
|
43
|
+
atBottom.current =
|
|
44
|
+
element.scrollHeight - element.scrollTop - element.clientHeight <
|
|
45
|
+
48;
|
|
46
|
+
if (atBottom.current) setShowLatest(false);
|
|
47
|
+
}}
|
|
48
|
+
>
|
|
49
|
+
{messages.length === 0 && (
|
|
50
|
+
<p className="transcript-empty">
|
|
51
|
+
Your conversation will appear here as you speak or type.
|
|
52
|
+
</p>
|
|
53
|
+
)}
|
|
54
|
+
{messages.map(({ id, timestamp, isUser, message, source }) => (
|
|
55
|
+
<article
|
|
56
|
+
key={id}
|
|
57
|
+
className="transcript-message"
|
|
58
|
+
data-speaker={isUser ? 'user' : 'agent'}
|
|
59
|
+
data-source={source}
|
|
60
|
+
>
|
|
61
|
+
<div className="message-meta">
|
|
62
|
+
<span>{isUser ? 'You' : agentName}</span>
|
|
63
|
+
<time dateTime={new Date(timestamp).toISOString()}>
|
|
64
|
+
{new Date(timestamp).toLocaleTimeString([], {
|
|
65
|
+
hour: '2-digit',
|
|
66
|
+
minute: '2-digit',
|
|
67
|
+
})}
|
|
68
|
+
</time>
|
|
69
|
+
</div>
|
|
70
|
+
<p>{message}</p>
|
|
71
|
+
</article>
|
|
72
|
+
))}
|
|
73
|
+
{agentState === 'thinking' && (
|
|
74
|
+
<p className="thinking-indicator" role="status">
|
|
75
|
+
Thinking<span aria-hidden="true">…</span>
|
|
76
|
+
</p>
|
|
77
|
+
)}
|
|
78
|
+
</div>
|
|
79
|
+
{showLatest && (
|
|
80
|
+
<button
|
|
81
|
+
className="jump-to-latest"
|
|
82
|
+
type="button"
|
|
83
|
+
onClick={scrollToLatest}
|
|
84
|
+
>
|
|
85
|
+
Latest messages ↓
|
|
86
|
+
</button>
|
|
87
|
+
)}
|
|
88
|
+
</div>
|
|
89
|
+
);
|
|
90
|
+
}
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
import { Room, TokenSource, Track } from 'livekit-client';
|
|
2
|
-
import {
|
|
2
|
+
import {
|
|
3
|
+
requestVoiceSession,
|
|
4
|
+
stopVoiceSession,
|
|
5
|
+
type VoiceSession,
|
|
6
|
+
} from './api.js';
|
|
3
7
|
import type { AvatarSessionController } from './avatar-session.js';
|
|
4
8
|
|
|
5
9
|
export type PreparedSession = {
|
|
@@ -30,6 +34,16 @@ export class SessionAttempt {
|
|
|
30
34
|
stage: 'bootstrap' | 'avatar' = 'bootstrap';
|
|
31
35
|
readonly room: Room;
|
|
32
36
|
readonly abort = new AbortController();
|
|
37
|
+
private capability?: string;
|
|
38
|
+
private readonly unload = () => {
|
|
39
|
+
if (this.capability)
|
|
40
|
+
navigator.sendBeacon?.(
|
|
41
|
+
'/api/session/stop',
|
|
42
|
+
new Blob([JSON.stringify({ capability: this.capability })], {
|
|
43
|
+
type: 'application/json',
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
};
|
|
33
47
|
private avatar?: AvatarSessionController;
|
|
34
48
|
private disposal?: Promise<void>;
|
|
35
49
|
constructor(private readonly deps: AttemptDependencies = dependencies) {
|
|
@@ -41,7 +55,12 @@ export class SessionAttempt {
|
|
|
41
55
|
): Promise<PreparedSession> {
|
|
42
56
|
const { signal } = this.abort;
|
|
43
57
|
const credentials = await this.deps.request(signal);
|
|
44
|
-
|
|
58
|
+
this.capability = credentials.session_capability;
|
|
59
|
+
if (signal.aborted) {
|
|
60
|
+
await stopVoiceSession(this.capability).catch(() => undefined);
|
|
61
|
+
signal.throwIfAborted();
|
|
62
|
+
}
|
|
63
|
+
globalThis.window?.addEventListener('pagehide', this.unload);
|
|
45
64
|
// Warm LiveKit's region selection and connection while the avatar loads.
|
|
46
65
|
// This does not join the room or acquire media; attach must still finish
|
|
47
66
|
// before useSession starts the connection. A failed warmup is non-fatal.
|
|
@@ -100,7 +119,9 @@ export class SessionAttempt {
|
|
|
100
119
|
dispose(): Promise<void> {
|
|
101
120
|
this.abort.abort();
|
|
102
121
|
this.disposal ??= (async () => {
|
|
122
|
+
globalThis.window?.removeEventListener('pagehide', this.unload);
|
|
103
123
|
await Promise.allSettled([
|
|
124
|
+
stopVoiceSession(this.capability),
|
|
104
125
|
this.room.disconnect(true),
|
|
105
126
|
this.avatar?.dispose() ?? Promise.resolve(),
|
|
106
127
|
]);
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from 'vitest';
|
|
2
|
+
import { signCapability, verifyCapability } from './capability.js';
|
|
3
|
+
describe('session capabilities', () => {
|
|
4
|
+
it('binds purpose and expiry and rejects tampering', async () => {
|
|
5
|
+
const secret = 'test-only-signing-key';
|
|
6
|
+
const token = await signCapability(
|
|
7
|
+
secret,
|
|
8
|
+
{ purpose: 'session', room: 'one' },
|
|
9
|
+
10,
|
|
10
|
+
);
|
|
11
|
+
expect(await verifyCapability(secret, token, 'session')).toMatchObject({
|
|
12
|
+
room: 'one',
|
|
13
|
+
});
|
|
14
|
+
await expect(verifyCapability(secret, token, 'memory')).rejects.toThrow();
|
|
15
|
+
await expect(
|
|
16
|
+
verifyCapability(secret, token + 'x', 'session'),
|
|
17
|
+
).rejects.toThrow();
|
|
18
|
+
await expect(
|
|
19
|
+
verifyCapability('different-key', token, 'session'),
|
|
20
|
+
).rejects.toThrow();
|
|
21
|
+
vi.useFakeTimers();
|
|
22
|
+
try {
|
|
23
|
+
vi.setSystemTime(Date.now() + 20000);
|
|
24
|
+
await expect(verifyCapability(secret, token, 'session')).rejects.toThrow(
|
|
25
|
+
'Expired',
|
|
26
|
+
);
|
|
27
|
+
} finally {
|
|
28
|
+
vi.useRealTimers();
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
});
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
const encoder = new TextEncoder();
|
|
2
|
+
function encode(value: Uint8Array): string {
|
|
3
|
+
return btoa(String.fromCharCode(...value))
|
|
4
|
+
.replaceAll('+', '-')
|
|
5
|
+
.replaceAll('/', '_')
|
|
6
|
+
.replaceAll('=', '');
|
|
7
|
+
}
|
|
8
|
+
function decode(value: string): Uint8Array<ArrayBuffer> {
|
|
9
|
+
return Uint8Array.from(
|
|
10
|
+
atob(value.replaceAll('-', '+').replaceAll('_', '/')),
|
|
11
|
+
(character) => character.charCodeAt(0),
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
async function key(secret: string) {
|
|
15
|
+
if (!secret) throw new Error('Session signing secret is missing');
|
|
16
|
+
return crypto.subtle.importKey(
|
|
17
|
+
'raw',
|
|
18
|
+
encoder.encode(secret),
|
|
19
|
+
{ name: 'HMAC', hash: 'SHA-256' },
|
|
20
|
+
false,
|
|
21
|
+
['sign', 'verify'],
|
|
22
|
+
);
|
|
23
|
+
}
|
|
24
|
+
export async function signCapability(
|
|
25
|
+
secret: string,
|
|
26
|
+
claims: Record<string, unknown>,
|
|
27
|
+
seconds = 3600,
|
|
28
|
+
): Promise<string> {
|
|
29
|
+
const payload = encode(
|
|
30
|
+
encoder.encode(
|
|
31
|
+
JSON.stringify({
|
|
32
|
+
...claims,
|
|
33
|
+
exp: Math.floor(Date.now() / 1000) + seconds,
|
|
34
|
+
}),
|
|
35
|
+
),
|
|
36
|
+
);
|
|
37
|
+
const signature = await crypto.subtle.sign(
|
|
38
|
+
'HMAC',
|
|
39
|
+
await key(secret),
|
|
40
|
+
encoder.encode(payload),
|
|
41
|
+
);
|
|
42
|
+
return `${payload}.${encode(new Uint8Array(signature))}`;
|
|
43
|
+
}
|
|
44
|
+
export async function verifyCapability(
|
|
45
|
+
secret: string,
|
|
46
|
+
token: string,
|
|
47
|
+
purpose: string,
|
|
48
|
+
): Promise<Record<string, unknown>> {
|
|
49
|
+
if (typeof token !== 'string' || token.length > 8192)
|
|
50
|
+
throw new Error('Invalid session authorization');
|
|
51
|
+
const parts = token.split('.');
|
|
52
|
+
if (
|
|
53
|
+
parts.length !== 2 ||
|
|
54
|
+
!(await crypto.subtle.verify(
|
|
55
|
+
'HMAC',
|
|
56
|
+
await key(secret),
|
|
57
|
+
decode(parts[1]),
|
|
58
|
+
encoder.encode(parts[0]),
|
|
59
|
+
))
|
|
60
|
+
)
|
|
61
|
+
throw new Error('Invalid session authorization');
|
|
62
|
+
const value = JSON.parse(
|
|
63
|
+
new TextDecoder().decode(decode(parts[0])),
|
|
64
|
+
) as Record<string, unknown>;
|
|
65
|
+
if (
|
|
66
|
+
value.purpose !== purpose ||
|
|
67
|
+
typeof value.exp !== 'number' ||
|
|
68
|
+
value.exp <= Date.now() / 1000
|
|
69
|
+
)
|
|
70
|
+
throw new Error('Expired session authorization');
|
|
71
|
+
return value;
|
|
72
|
+
}
|
|
@@ -30,7 +30,7 @@ describe('createSession', () => {
|
|
|
30
30
|
) as {
|
|
31
31
|
roomConfig: { agents: Array<{ metadata: string }> };
|
|
32
32
|
};
|
|
33
|
-
expect(JSON.parse(payload.roomConfig.agents[0].metadata)).
|
|
33
|
+
expect(JSON.parse(payload.roomConfig.agents[0].metadata)).toMatchObject({
|
|
34
34
|
version: 1,
|
|
35
35
|
avatar: { id: TEST_ENV.SPATIUS_AVATAR_ID },
|
|
36
36
|
voice: { id: voiceId },
|
|
@@ -145,7 +145,7 @@ describe('Worker routes', () => {
|
|
|
145
145
|
expect(response.status).toBe(503);
|
|
146
146
|
await expect(response.json()).resolves.toEqual({
|
|
147
147
|
error:
|
|
148
|
-
'The voice session service is not configured. Check the
|
|
148
|
+
'The voice session service is not configured. Check the server environment.',
|
|
149
149
|
});
|
|
150
150
|
consoleError.mockRestore();
|
|
151
151
|
});
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { ConfigurationError, createSession } from './session.js';
|
|
1
|
+
import { ConfigurationError, createSession, stopSession } from './session.js';
|
|
2
2
|
|
|
3
3
|
const JSON_HEADERS = {
|
|
4
4
|
'cache-control': 'no-store',
|
|
@@ -26,6 +26,18 @@ export async function handleRequest(
|
|
|
26
26
|
return json({ ok: true });
|
|
27
27
|
}
|
|
28
28
|
|
|
29
|
+
if (url.pathname === '/api/session/stop') {
|
|
30
|
+
if (request.method !== 'POST')
|
|
31
|
+
return json({ error: 'Method not allowed.' }, 405);
|
|
32
|
+
try {
|
|
33
|
+
const body: { capability?: string } = await request.json();
|
|
34
|
+
await stopSession(env, body.capability ?? '');
|
|
35
|
+
return json({ ok: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return json({ error: 'Could not stop this session.' }, 403);
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
29
41
|
if (url.pathname === '/api/session') {
|
|
30
42
|
if (request.method !== 'POST') {
|
|
31
43
|
return json({ error: 'Method not allowed.' }, 405);
|
|
@@ -42,13 +54,13 @@ export async function handleRequest(
|
|
|
42
54
|
return json(
|
|
43
55
|
{
|
|
44
56
|
error:
|
|
45
|
-
'The voice session service is not configured. Check the
|
|
57
|
+
'The voice session service is not configured. Check the server environment.',
|
|
46
58
|
},
|
|
47
59
|
503,
|
|
48
60
|
);
|
|
49
61
|
}
|
|
50
62
|
|
|
51
|
-
console.error('Could not create a voice session
|
|
63
|
+
console.error('Could not create a voice session.');
|
|
52
64
|
return json({ error: 'Could not create a voice session.' }, 500);
|
|
53
65
|
}
|
|
54
66
|
}
|
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
import {
|
|
2
2
|
AccessToken,
|
|
3
|
+
RoomServiceClient,
|
|
3
4
|
RoomAgentDispatch,
|
|
4
5
|
RoomConfiguration,
|
|
5
6
|
} from 'livekit-server-sdk';
|
|
6
7
|
|
|
8
|
+
import { signCapability, verifyCapability } from './capability.js';
|
|
7
9
|
import { serializeAgentDispatchMetadata } from './dispatch-metadata.js';
|
|
8
10
|
|
|
9
11
|
const TOKEN_TTL = '10m';
|
|
@@ -17,6 +19,7 @@ const REQUIRED_ENVIRONMENT_KEYS = [
|
|
|
17
19
|
] as const;
|
|
18
20
|
|
|
19
21
|
export interface SessionResponse {
|
|
22
|
+
session_capability?: string;
|
|
20
23
|
participant_token: string;
|
|
21
24
|
room_name: string;
|
|
22
25
|
server_url: string;
|
|
@@ -91,6 +94,10 @@ export async function createSession(
|
|
|
91
94
|
});
|
|
92
95
|
|
|
93
96
|
return {
|
|
97
|
+
session_capability: await signCapability(env.LIVEKIT_API_SECRET, {
|
|
98
|
+
purpose: 'session',
|
|
99
|
+
room: roomName,
|
|
100
|
+
}),
|
|
94
101
|
participant_token: await accessToken.toJwt(),
|
|
95
102
|
room_name: roomName,
|
|
96
103
|
server_url: env.LIVEKIT_URL,
|
|
@@ -101,3 +108,30 @@ export async function createSession(
|
|
|
101
108
|
: {}),
|
|
102
109
|
};
|
|
103
110
|
}
|
|
111
|
+
|
|
112
|
+
export async function stopSession(
|
|
113
|
+
env: CloudflareBindings,
|
|
114
|
+
token: string,
|
|
115
|
+
): Promise<void> {
|
|
116
|
+
const claims = await verifyCapability(
|
|
117
|
+
env.LIVEKIT_API_SECRET,
|
|
118
|
+
token,
|
|
119
|
+
'session',
|
|
120
|
+
);
|
|
121
|
+
if (typeof claims.room !== 'string') throw new Error('Invalid room');
|
|
122
|
+
const rooms = new RoomServiceClient(
|
|
123
|
+
env.LIVEKIT_URL.replace(/^wss:/, 'https:'),
|
|
124
|
+
env.LIVEKIT_API_KEY,
|
|
125
|
+
env.LIVEKIT_API_SECRET,
|
|
126
|
+
);
|
|
127
|
+
try {
|
|
128
|
+
await rooms.deleteRoom(claims.room);
|
|
129
|
+
} catch (error) {
|
|
130
|
+
if (!(
|
|
131
|
+
error instanceof Error &&
|
|
132
|
+
'code' in error &&
|
|
133
|
+
error.code === 'not_found'
|
|
134
|
+
))
|
|
135
|
+
throw error;
|
|
136
|
+
}
|
|
137
|
+
}
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
# Generated Agora application
|
|
2
|
+
|
|
3
|
+
Use `__DEV__` for local development and `__CHECK__` for verification.
|
|
4
|
+
The Node backend calls hosted Conversational AI over HTTP; do not add a Python
|
|
5
|
+
worker. Browser audio uses Agora RTC, text input and transcripts use the official
|
|
6
|
+
Agora client toolkit. Spatius renders the avatar locally.
|
|
7
|
+
Keep secrets in .env.local or deployment environment variables. Preserve session
|
|
8
|
+
cleanup on disconnect and failed startup. See DEPLOYMENT.md for Zeabur setup.
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# Spatius voice assistant — Zeabur + Agora
|
|
2
|
+
|
|
3
|
+
A minimal React avatar conversation with an HTTP backend and Agora Conversational AI.
|
|
4
|
+
Requires Node.js 22.12+, an Agora project with Conversational AI and RTM enabled,
|
|
5
|
+
a published English assistant pipeline, and Spatius credentials. No Python is needed.
|
|
6
|
+
|
|
7
|
+
```sh
|
|
8
|
+
__INSTALL__
|
|
9
|
+
npx create-spatius-app setup . --interactive
|
|
10
|
+
__DEV__
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Open the Vite URL, start a conversation, and speak or type. Microphone permission
|
|
14
|
+
is optional: typed messages go to the model through Agora RTM. The transcript
|
|
15
|
+
stays visible after disconnecting. Configure the assistant persona, speech
|
|
16
|
+
recognition language, model, and voice in the published Agora pipeline. Match
|
|
17
|
+
AGORA_AVATAR_SAMPLE_RATE to that pipeline's TTS output.
|
|
18
|
+
|
|
19
|
+
See DEPLOYMENT.md to deploy the frontend and API together to Zeabur.
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
AGORA_APP_ID=your-agora-app-id
|
|
2
|
+
AGORA_APP_CERTIFICATE=your-agora-app-certificate
|
|
3
|
+
AGORA_PIPELINE_ID=your-published-pipeline-id
|
|
4
|
+
SPATIUS_APP_ID=your-spatius-app-id
|
|
5
|
+
SPATIUS_API_KEY=your-spatius-api-key
|
|
6
|
+
SPATIUS_AVATAR_ID=your-spatius-avatar-id
|
|
7
|
+
SPATIUS_REGION=cn-beijing
|
|
8
|
+
# Must match the published pipeline's TTS output.
|
|
9
|
+
AGORA_AVATAR_SAMPLE_RATE=24000
|