voicecc 1.2.7 → 1.2.8
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/dashboard/routes/agents.ts +41 -2
- package/dashboard/server.ts +4 -39
- package/package.json +1 -1
- package/voice-server/twilio_pipeline.py +26 -12
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
13
|
import { Hono } from "hono";
|
|
14
|
+
import twilioSdk from "twilio";
|
|
14
15
|
import {
|
|
15
16
|
listAgents,
|
|
16
17
|
getAgent,
|
|
@@ -21,6 +22,8 @@ import {
|
|
|
21
22
|
importAgent,
|
|
22
23
|
} from "../../server/services/agent-store.js";
|
|
23
24
|
import type { AgentConfig } from "../../server/services/agent-store.js";
|
|
25
|
+
import { readEnv } from "../../server/services/env.js";
|
|
26
|
+
import { getTunnelUrl } from "../../server/services/tunnel.js";
|
|
24
27
|
|
|
25
28
|
/** Base URL for the Python voice server API */
|
|
26
29
|
const VOICE_API_URL = process.env.VOICE_SERVER_URL ?? "http://localhost:7861";
|
|
@@ -155,11 +158,30 @@ export function agentsRoutes(): Hono {
|
|
|
155
158
|
app.post("/:id/call", async (c) => {
|
|
156
159
|
const id = c.req.param("id");
|
|
157
160
|
try {
|
|
161
|
+
const envVars = await readEnv();
|
|
162
|
+
const accountSid = envVars.TWILIO_ACCOUNT_SID;
|
|
163
|
+
const authToken = envVars.TWILIO_AUTH_TOKEN;
|
|
164
|
+
const userPhone = envVars.USER_PHONE_NUMBER;
|
|
165
|
+
const tunnelUrl = getTunnelUrl();
|
|
166
|
+
|
|
167
|
+
if (!accountSid || !authToken) {
|
|
168
|
+
return c.json({ error: "Twilio credentials not configured" }, 400);
|
|
169
|
+
}
|
|
170
|
+
if (!userPhone) {
|
|
171
|
+
return c.json({ error: "User phone number not configured" }, 400);
|
|
172
|
+
}
|
|
173
|
+
if (!tunnelUrl) {
|
|
174
|
+
return c.json({ error: "Tunnel is not running" }, 400);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
const token = crypto.randomUUID();
|
|
178
|
+
|
|
179
|
+
// Register the token with the Python voice server
|
|
158
180
|
const response = await fetch(`${VOICE_API_URL}/register-call`, {
|
|
159
181
|
method: "POST",
|
|
160
182
|
headers: { "Content-Type": "application/json" },
|
|
161
183
|
body: JSON.stringify({
|
|
162
|
-
token
|
|
184
|
+
token,
|
|
163
185
|
agent_id: id,
|
|
164
186
|
initial_prompt: "The user pressed the 'Call Me' button. Greet them and ask how you can help.",
|
|
165
187
|
}),
|
|
@@ -168,7 +190,24 @@ export function agentsRoutes(): Hono {
|
|
|
168
190
|
const data = await response.json();
|
|
169
191
|
throw new Error(data.error ?? "Voice server error");
|
|
170
192
|
}
|
|
171
|
-
|
|
193
|
+
|
|
194
|
+
// Place the actual Twilio call
|
|
195
|
+
const client = twilioSdk(accountSid, authToken);
|
|
196
|
+
const numbers = await client.incomingPhoneNumbers.list({ limit: 1 });
|
|
197
|
+
if (numbers.length === 0) {
|
|
198
|
+
return c.json({ error: "No Twilio phone numbers found on this account" }, 400);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const tunnelHost = tunnelUrl.replace(/^https?:\/\//, "");
|
|
202
|
+
const twiml = `<Response><Connect><Stream url="wss://${tunnelHost}/media/${token}?agentId=${id}" /></Connect></Response>`;
|
|
203
|
+
|
|
204
|
+
const call = await client.calls.create({
|
|
205
|
+
to: userPhone,
|
|
206
|
+
from: numbers[0].phoneNumber,
|
|
207
|
+
twiml,
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
return c.json({ success: true, callSid: call.sid });
|
|
172
211
|
} catch (err) {
|
|
173
212
|
return c.json({ error: (err as Error).message }, 400);
|
|
174
213
|
}
|
package/dashboard/server.ts
CHANGED
|
@@ -16,10 +16,9 @@ import { readFileSync } from "fs";
|
|
|
16
16
|
import { access } from "fs/promises";
|
|
17
17
|
import { join } from "path";
|
|
18
18
|
import { homedir } from "os";
|
|
19
|
-
import {
|
|
19
|
+
import { attachMediaProxy } from "./ws-proxy.js";
|
|
20
20
|
|
|
21
|
-
import type
|
|
22
|
-
import type { Duplex } from "stream";
|
|
21
|
+
import type http from "http";
|
|
23
22
|
|
|
24
23
|
import { claudeMdRoutes } from "./routes/claude-md.js";
|
|
25
24
|
import { conversationRoutes } from "./routes/conversations.js";
|
|
@@ -142,42 +141,8 @@ export async function startDashboard(): Promise<number> {
|
|
|
142
141
|
});
|
|
143
142
|
server.on("error", reject);
|
|
144
143
|
|
|
145
|
-
// Proxy /media/:token WebSocket upgrades to the Python server
|
|
146
|
-
|
|
147
|
-
server.on("upgrade", (req: IncomingMessage, socket: Duplex, head: Buffer) => {
|
|
148
|
-
const url = req.url ?? "";
|
|
149
|
-
const match = url.match(/^\/media\/([a-f0-9-]+)(?:\?.*)?$/);
|
|
150
|
-
if (!match) return; // Not a Twilio media WebSocket -- let it fall through
|
|
151
|
-
|
|
152
|
-
const targetWsUrl = VOICE_API_URL.replace(/^http/, "ws") + url;
|
|
153
|
-
const upstream = new WsWebSocket(targetWsUrl);
|
|
154
|
-
|
|
155
|
-
upstream.on("open", () => {
|
|
156
|
-
wss.handleUpgrade(req, socket, head, (clientWs) => {
|
|
157
|
-
// Bidirectional message proxy
|
|
158
|
-
clientWs.on("message", (data) => {
|
|
159
|
-
if (upstream.readyState === WsWebSocket.OPEN) {
|
|
160
|
-
upstream.send(data);
|
|
161
|
-
}
|
|
162
|
-
});
|
|
163
|
-
upstream.on("message", (data) => {
|
|
164
|
-
if (clientWs.readyState === WsWebSocket.OPEN) {
|
|
165
|
-
clientWs.send(data);
|
|
166
|
-
}
|
|
167
|
-
});
|
|
168
|
-
|
|
169
|
-
clientWs.on("close", () => upstream.close());
|
|
170
|
-
upstream.on("close", () => clientWs.close());
|
|
171
|
-
clientWs.on("error", () => upstream.close());
|
|
172
|
-
upstream.on("error", () => clientWs.close());
|
|
173
|
-
});
|
|
174
|
-
});
|
|
175
|
-
|
|
176
|
-
upstream.on("error", (err) => {
|
|
177
|
-
console.error(`[dashboard] Twilio WS proxy error: ${err.message}`);
|
|
178
|
-
socket.destroy();
|
|
179
|
-
});
|
|
180
|
-
});
|
|
144
|
+
// Proxy /media/:token WebSocket upgrades to the Python voice server
|
|
145
|
+
attachMediaProxy(server as unknown as http.Server, VOICE_API_URL);
|
|
181
146
|
});
|
|
182
147
|
|
|
183
148
|
setDashboardPort(port);
|
package/package.json
CHANGED
|
@@ -21,10 +21,16 @@ import os
|
|
|
21
21
|
import aiohttp
|
|
22
22
|
from fastapi import WebSocket
|
|
23
23
|
|
|
24
|
+
from pipecat.frames.frames import LLMFullResponseEndFrame, LLMFullResponseStartFrame
|
|
24
25
|
from pipecat.pipeline.pipeline import Pipeline
|
|
25
26
|
from pipecat.pipeline.runner import PipelineRunner
|
|
26
27
|
from pipecat.pipeline.task import PipelineParams, PipelineTask
|
|
27
|
-
from pipecat.
|
|
28
|
+
from pipecat.audio.vad.silero import SileroVADAnalyzer
|
|
29
|
+
from pipecat.processors.aggregators.llm_context import LLMContext
|
|
30
|
+
from pipecat.processors.aggregators.llm_response_universal import (
|
|
31
|
+
LLMContextAggregatorPair,
|
|
32
|
+
LLMUserAggregatorParams,
|
|
33
|
+
)
|
|
28
34
|
from pipecat.serializers.twilio import TwilioFrameSerializer
|
|
29
35
|
from pipecat.services.elevenlabs.stt import ElevenLabsSTTService
|
|
30
36
|
from pipecat.services.elevenlabs.tts import ElevenLabsTTSService
|
|
@@ -74,14 +80,18 @@ async def handle_twilio_websocket(websocket: WebSocket, call_token: str) -> None
|
|
|
74
80
|
while True:
|
|
75
81
|
message = await websocket.receive()
|
|
76
82
|
|
|
77
|
-
# Skip binary frames (early audio before start)
|
|
78
83
|
if message.get("type") == "websocket.disconnect":
|
|
79
84
|
logger.warning("[twilio] WebSocket disconnected before start event")
|
|
80
85
|
return
|
|
81
|
-
|
|
86
|
+
|
|
87
|
+
# Twilio may send frames as text or binary
|
|
88
|
+
raw = message.get("text") or (
|
|
89
|
+
message.get("bytes", b"").decode("utf-8") if message.get("bytes") else None
|
|
90
|
+
)
|
|
91
|
+
if not raw:
|
|
82
92
|
continue
|
|
83
93
|
|
|
84
|
-
msg = json.loads(
|
|
94
|
+
msg = json.loads(raw)
|
|
85
95
|
|
|
86
96
|
if msg.get("event") == "start":
|
|
87
97
|
start_data = msg.get("start", {})
|
|
@@ -224,8 +234,13 @@ async def _run_twilio_pipeline(
|
|
|
224
234
|
narration = NarrationProcessor()
|
|
225
235
|
|
|
226
236
|
# Context aggregator
|
|
227
|
-
context =
|
|
228
|
-
context_aggregator =
|
|
237
|
+
context = LLMContext()
|
|
238
|
+
context_aggregator = LLMContextAggregatorPair(
|
|
239
|
+
context,
|
|
240
|
+
user_params=LLMUserAggregatorParams(
|
|
241
|
+
vad_analyzer=SileroVADAnalyzer(),
|
|
242
|
+
),
|
|
243
|
+
)
|
|
229
244
|
|
|
230
245
|
# Pipeline
|
|
231
246
|
pipeline = Pipeline(
|
|
@@ -246,16 +261,15 @@ async def _run_twilio_pipeline(
|
|
|
246
261
|
params=PipelineParams(allow_interruptions=True),
|
|
247
262
|
)
|
|
248
263
|
|
|
249
|
-
#
|
|
250
|
-
|
|
251
|
-
async def
|
|
252
|
-
await asyncio.sleep(1) # Let the pipeline fully initialize
|
|
264
|
+
# Send initial prompt once the pipeline is fully ready
|
|
265
|
+
@task.event_handler("on_pipeline_started")
|
|
266
|
+
async def on_pipeline_started(task_ref, *args):
|
|
253
267
|
if llm_config.initial_prompt and not claude_llm._initial_prompt_sent:
|
|
254
268
|
claude_llm._initial_prompt_sent = True
|
|
255
269
|
await claude_llm._ensure_client()
|
|
270
|
+
await claude_llm.push_frame(LLMFullResponseStartFrame())
|
|
256
271
|
await claude_llm._send_to_claude(llm_config.initial_prompt)
|
|
257
|
-
|
|
258
|
-
asyncio.create_task(_send_initial_prompt())
|
|
272
|
+
await claude_llm.push_frame(LLMFullResponseEndFrame())
|
|
259
273
|
|
|
260
274
|
runner = PipelineRunner()
|
|
261
275
|
await runner.run(task)
|