create-local-voice-agent 1.0.3 → 1.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/package.json +2 -1
- package/src/index.js +24 -2
- package/src/scaffold.js +2 -1
- package/templates/docker-compose.yml.ejs +8 -9
- package/templates/frontend/app/page.tsx.ejs +118 -16
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "create-local-voice-agent",
|
|
3
|
-
"version": "1.0
|
|
3
|
+
"version": "1.1.0",
|
|
4
4
|
"description": "CLI to scaffold production-ready, containerized Voice AI Agents using LiveKit, Python, and Docker",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "src/index.js",
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
"templates/"
|
|
28
28
|
],
|
|
29
29
|
"dependencies": {
|
|
30
|
+
"@inquirer/input": "^5.0.8",
|
|
30
31
|
"@inquirer/prompts": "^8.3.0",
|
|
31
32
|
"commander": "^14.0.3",
|
|
32
33
|
"ejs": "^4.0.1"
|
package/src/index.js
CHANGED
|
@@ -1,8 +1,22 @@
|
|
|
1
1
|
import { Command } from 'commander';
|
|
2
|
-
import { select,
|
|
2
|
+
import { select, confirm } from '@inquirer/prompts';
|
|
3
|
+
import input from '@inquirer/input';
|
|
3
4
|
import { execSync } from 'node:child_process';
|
|
5
|
+
import os from 'node:os';
|
|
4
6
|
import { scaffold } from './scaffold.js';
|
|
5
7
|
|
|
8
|
+
function getLocalIp() {
|
|
9
|
+
const nets = os.networkInterfaces();
|
|
10
|
+
for (const name of Object.keys(nets)) {
|
|
11
|
+
for (const net of nets[name]) {
|
|
12
|
+
if (net.family === 'IPv4' && !net.internal) {
|
|
13
|
+
return net.address;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
return '127.0.0.1';
|
|
18
|
+
}
|
|
19
|
+
|
|
6
20
|
// ─── Ollama API Fetching ──────────────────────────────────────────────
|
|
7
21
|
async function fetchOllamaModels(ollamaIp = '127.0.0.1') {
|
|
8
22
|
try {
|
|
@@ -172,6 +186,7 @@ program
|
|
|
172
186
|
let includeFrontend = true;
|
|
173
187
|
let shouldAutoStart = false;
|
|
174
188
|
let ollamaIp = 'localhost';
|
|
189
|
+
let hostIp = getLocalIp();
|
|
175
190
|
|
|
176
191
|
// ─── AUTO MODE ────────────────────────────────────────────────
|
|
177
192
|
if (setupMode === 'auto') {
|
|
@@ -308,6 +323,12 @@ program
|
|
|
308
323
|
shouldAutoStart = false;
|
|
309
324
|
}
|
|
310
325
|
|
|
326
|
+
hostIp = await input({
|
|
327
|
+
message:
|
|
328
|
+
'🌐 Enter the IP address where this agent will run (Press Enter to use detected IP):',
|
|
329
|
+
default: getLocalIp(),
|
|
330
|
+
});
|
|
331
|
+
|
|
311
332
|
// ─── Scaffold ─────────────────────────────────────────────────
|
|
312
333
|
await scaffold({
|
|
313
334
|
projectName,
|
|
@@ -317,6 +338,7 @@ program
|
|
|
317
338
|
modelName,
|
|
318
339
|
language,
|
|
319
340
|
ollamaIp,
|
|
341
|
+
hostIp,
|
|
320
342
|
});
|
|
321
343
|
|
|
322
344
|
console.log(`\n✅ Project "${projectName}" created successfully!\n`);
|
|
@@ -345,4 +367,4 @@ program
|
|
|
345
367
|
}
|
|
346
368
|
});
|
|
347
369
|
|
|
348
|
-
program.parse();
|
|
370
|
+
program.parse();
|
package/src/scaffold.js
CHANGED
|
@@ -51,6 +51,7 @@ export async function scaffold({
|
|
|
51
51
|
modelName = 'qwen3-vl:2b',
|
|
52
52
|
language = 'en',
|
|
53
53
|
ollamaIp = 'localhost',
|
|
54
|
+
hostIp = '127.0.0.1',
|
|
54
55
|
}) {
|
|
55
56
|
const projectDir = path.resolve(process.cwd(), projectName);
|
|
56
57
|
|
|
@@ -71,6 +72,7 @@ export async function scaffold({
|
|
|
71
72
|
modelName,
|
|
72
73
|
language,
|
|
73
74
|
ollamaIp,
|
|
75
|
+
hostIp,
|
|
74
76
|
cpuLimit,
|
|
75
77
|
};
|
|
76
78
|
|
|
@@ -99,4 +101,3 @@ export async function scaffold({
|
|
|
99
101
|
|
|
100
102
|
console.log(`\n📁 Generated files in ./${projectName}/`);
|
|
101
103
|
}
|
|
102
|
-
|
|
@@ -36,18 +36,17 @@ services:
|
|
|
36
36
|
redis:
|
|
37
37
|
condition: service_healthy
|
|
38
38
|
ports:
|
|
39
|
-
- "7880:7880"
|
|
40
|
-
- "7881:7881"
|
|
41
|
-
- "50000-50050:50000-50050/udp"
|
|
39
|
+
- "7880:7880"
|
|
40
|
+
- "7881:7881"
|
|
41
|
+
- "50000-50050:50000-50050/udp"
|
|
42
42
|
environment:
|
|
43
|
-
- LIVEKIT_KEYS=${LIVEKIT_API_KEY}
|
|
43
|
+
- "LIVEKIT_KEYS=${LIVEKIT_API_KEY}: ${LIVEKIT_API_SECRET}"
|
|
44
|
+
- "NODE_IP=<%= hostIp %>"
|
|
45
|
+
- "UDP_PORT=50000-50050"
|
|
46
|
+
- "REDIS_HOST=redis:6379"
|
|
44
47
|
command: >
|
|
45
|
-
--config /etc/livekit.yaml
|
|
46
48
|
--bind 0.0.0.0
|
|
47
|
-
--
|
|
48
|
-
--rtc.port-range-start 50000
|
|
49
|
-
--rtc.port-range-end 50050
|
|
50
|
-
--rtc.tcp-port 7881
|
|
49
|
+
--dev
|
|
51
50
|
|
|
52
51
|
# ─── Python Voice Agent ────────────────────────────────────────────
|
|
53
52
|
agent:
|
|
@@ -6,31 +6,102 @@ import {
|
|
|
6
6
|
RoomAudioRenderer,
|
|
7
7
|
useVoiceAssistant,
|
|
8
8
|
BarVisualizer,
|
|
9
|
+
Chat,
|
|
9
10
|
DisconnectButton,
|
|
10
11
|
} from "@livekit/components-react";
|
|
11
12
|
import "@livekit/components-styles";
|
|
12
13
|
|
|
13
14
|
function VoiceAssistantUI() {
|
|
14
15
|
const { state, audioTrack } = useVoiceAssistant();
|
|
16
|
+
const [temperature, setTemperature] = useState(0.7);
|
|
17
|
+
const [selectedModel, setSelectedModel] = useState("qwen3-vl:2b");
|
|
15
18
|
|
|
16
19
|
return (
|
|
17
20
|
<div
|
|
18
21
|
style={{
|
|
19
|
-
display: "
|
|
20
|
-
|
|
21
|
-
alignItems: "center",
|
|
22
|
+
display: "grid",
|
|
23
|
+
gridTemplateColumns: "repeat(auto-fit, minmax(300px, 1fr))",
|
|
22
24
|
gap: "1rem",
|
|
23
|
-
padding: "
|
|
25
|
+
padding: "1rem 0",
|
|
26
|
+
textAlign: "left",
|
|
24
27
|
}}
|
|
25
28
|
>
|
|
26
|
-
<
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
<section
|
|
30
|
+
style={{
|
|
31
|
+
border: "1px solid #e5e7eb",
|
|
32
|
+
borderRadius: "12px",
|
|
33
|
+
padding: "1rem",
|
|
34
|
+
background: "#ffffff",
|
|
35
|
+
}}
|
|
36
|
+
>
|
|
37
|
+
<h2 style={{ marginTop: 0, marginBottom: "0.5rem" }}>Voice + Text</h2>
|
|
38
|
+
<p style={{ marginTop: 0, color: "#374151" }}>Agent Status: {state}</p>
|
|
39
|
+
<BarVisualizer
|
|
40
|
+
state={state}
|
|
41
|
+
barCount={5}
|
|
42
|
+
trackRef={audioTrack}
|
|
43
|
+
style={{ width: "100%", height: "80px" }}
|
|
44
|
+
/>
|
|
45
|
+
<div
|
|
46
|
+
style={{
|
|
47
|
+
marginTop: "1rem",
|
|
48
|
+
border: "1px solid #e5e7eb",
|
|
49
|
+
borderRadius: "10px",
|
|
50
|
+
overflow: "hidden",
|
|
51
|
+
minHeight: "340px",
|
|
52
|
+
}}
|
|
53
|
+
>
|
|
54
|
+
<Chat />
|
|
55
|
+
</div>
|
|
56
|
+
</section>
|
|
57
|
+
|
|
58
|
+
<aside
|
|
59
|
+
style={{
|
|
60
|
+
border: "1px solid #e5e7eb",
|
|
61
|
+
borderRadius: "12px",
|
|
62
|
+
padding: "1rem",
|
|
63
|
+
background: "#f8fafc",
|
|
64
|
+
display: "flex",
|
|
65
|
+
flexDirection: "column",
|
|
66
|
+
gap: "1rem",
|
|
67
|
+
}}
|
|
68
|
+
>
|
|
69
|
+
<h3 style={{ margin: 0 }}>Control Panel</h3>
|
|
70
|
+
|
|
71
|
+
<div>
|
|
72
|
+
<h4 style={{ margin: "0 0 0.5rem 0" }}>System Status</h4>
|
|
73
|
+
<p style={{ margin: 0, color: "#374151" }}>Connection State: {state}</p>
|
|
74
|
+
</div>
|
|
75
|
+
|
|
76
|
+
<div style={{ display: "flex", flexDirection: "column", gap: "0.75rem" }}>
|
|
77
|
+
<h4 style={{ margin: 0 }}>Model Settings</h4>
|
|
78
|
+
<label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
|
|
79
|
+
<span>Temperature: {temperature.toFixed(2)}</span>
|
|
80
|
+
<input
|
|
81
|
+
type="range"
|
|
82
|
+
min="0"
|
|
83
|
+
max="1"
|
|
84
|
+
step="0.01"
|
|
85
|
+
value={temperature}
|
|
86
|
+
onChange={(event) => setTemperature(Number(event.target.value))}
|
|
87
|
+
/>
|
|
88
|
+
</label>
|
|
89
|
+
<label style={{ display: "flex", flexDirection: "column", gap: "0.35rem" }}>
|
|
90
|
+
<span>Select Model</span>
|
|
91
|
+
<select
|
|
92
|
+
value={selectedModel}
|
|
93
|
+
onChange={(event) => setSelectedModel(event.target.value)}
|
|
94
|
+
style={{ padding: "0.5rem", borderRadius: "8px", border: "1px solid #d1d5db" }}
|
|
95
|
+
>
|
|
96
|
+
<option value="qwen3-vl:2b">qwen3-vl:2b</option>
|
|
97
|
+
<option value="qwen3-vl:4b">qwen3-vl:4b</option>
|
|
98
|
+
<option value="llama3.2:3b">llama3.2:3b</option>
|
|
99
|
+
</select>
|
|
100
|
+
</label>
|
|
101
|
+
</div>
|
|
102
|
+
|
|
103
|
+
<DisconnectButton>Disconnect</DisconnectButton>
|
|
104
|
+
</aside>
|
|
34
105
|
</div>
|
|
35
106
|
);
|
|
36
107
|
}
|
|
@@ -40,12 +111,23 @@ export default function Home() {
|
|
|
40
111
|
const [error, setError] = useState<string | null>(null);
|
|
41
112
|
const [connected, setConnected] = useState(false);
|
|
42
113
|
const [serverUrl, setServerUrl] = useState("");
|
|
114
|
+
const [showSecurityWarning, setShowSecurityWarning] = useState(false);
|
|
43
115
|
|
|
44
116
|
useEffect(() => {
|
|
45
117
|
if (typeof window !== "undefined") {
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
118
|
+
let envUrl = process.env.NEXT_PUBLIC_LIVEKIT_URL;
|
|
119
|
+
// Aggressively override if the baked-in variable is empty, localhost, or 127.0.0.1
|
|
120
|
+
if (!envUrl || envUrl.includes("127.0.0.1") || envUrl.includes("localhost")) {
|
|
121
|
+
const protocol = window.location.protocol === "https:" ? "wss:" : "ws:";
|
|
122
|
+
envUrl = `${protocol}//${window.location.hostname}:7880`;
|
|
123
|
+
}
|
|
124
|
+
setServerUrl(envUrl);
|
|
125
|
+
|
|
126
|
+
const isInsecureRemoteHost =
|
|
127
|
+
window.location.protocol === "http:" &&
|
|
128
|
+
window.location.hostname !== "localhost" &&
|
|
129
|
+
window.location.hostname !== "127.0.0.1";
|
|
130
|
+
setShowSecurityWarning(isInsecureRemoteHost);
|
|
49
131
|
}
|
|
50
132
|
}, []);
|
|
51
133
|
|
|
@@ -63,9 +145,27 @@ export default function Home() {
|
|
|
63
145
|
fetchToken();
|
|
64
146
|
}, []);
|
|
65
147
|
|
|
148
|
+
const securityBanner = showSecurityWarning ? (
|
|
149
|
+
<div
|
|
150
|
+
style={{
|
|
151
|
+
background: "#fee2e2",
|
|
152
|
+
border: "1px solid #ef4444",
|
|
153
|
+
color: "#991b1b",
|
|
154
|
+
borderRadius: "10px",
|
|
155
|
+
padding: "0.85rem 1rem",
|
|
156
|
+
marginBottom: "1rem",
|
|
157
|
+
fontWeight: 600,
|
|
158
|
+
}}
|
|
159
|
+
>
|
|
160
|
+
⚠️ Microphone access is blocked by browsers over HTTP on remote IPs. Use HTTPS, Ngrok,
|
|
161
|
+
or enable 'Insecure origins treated as secure' in chrome://flags.
|
|
162
|
+
</div>
|
|
163
|
+
) : null;
|
|
164
|
+
|
|
66
165
|
if (error) {
|
|
67
166
|
return (
|
|
68
167
|
<div style={{ padding: "2rem", textAlign: "center" }}>
|
|
168
|
+
{securityBanner}
|
|
69
169
|
<h1>Error</h1>
|
|
70
170
|
<p>{error}</p>
|
|
71
171
|
</div>
|
|
@@ -75,14 +175,16 @@ export default function Home() {
|
|
|
75
175
|
if (!token) {
|
|
76
176
|
return (
|
|
77
177
|
<div style={{ padding: "2rem", textAlign: "center" }}>
|
|
178
|
+
{securityBanner}
|
|
78
179
|
<p>Connecting to voice agent...</p>
|
|
79
180
|
</div>
|
|
80
181
|
);
|
|
81
182
|
}
|
|
82
183
|
|
|
83
184
|
return (
|
|
84
|
-
<main style={{ padding: "2rem", textAlign: "center" }}>
|
|
185
|
+
<main style={{ padding: "2rem", textAlign: "center", maxWidth: "1200px", margin: "0 auto" }}>
|
|
85
186
|
<h1><%= projectName %></h1>
|
|
187
|
+
{securityBanner}
|
|
86
188
|
{!connected && (
|
|
87
189
|
<button
|
|
88
190
|
type="button"
|