Compare commits

...

10 Commits

Author SHA1 Message Date
a6b98e4100 Merge branch 'phone-interface' 2025-12-17 11:36:58 +08:00
48cb450208 add gitignore 2025-12-17 11:36:27 +08:00
800aa700f9 make endcall button keeps when connection fail 2025-12-17 11:33:44 +08:00
2decf208b4 remove few components on frontend 2025-12-17 11:03:36 +08:00
b75fd71bc7 does not allow interrupt in important stage 2025-12-17 09:26:56 +08:00
e8ef7c6da7 bug fixed 2025-12-16 17:54:37 +08:00
f2fcbe485f return random phone number and id card number 2025-12-16 17:31:17 +08:00
e09e4b6930 a better push to talk layout 2025-12-16 15:56:46 +08:00
1774f550dd first version push to talk 2025-12-16 15:11:55 +08:00
9f05f067a6 fix end call bug 2025-12-16 11:41:06 +08:00
7 changed files with 862 additions and 149 deletions

58
.gitignore vendored
View File

@@ -6,27 +6,46 @@
.pnp.js
.yarn/install-state.gz
# pnpm
.pnpm-store/
.pnpm-debug.log*
# testing
/coverage
*.lcov
.nyc_output
# next.js
/.next/
/out/
.next/
out/
# production
/build
dist/
# misc
.DS_Store
*.pem
*.log
*.swp
*.swo
*~
# debug
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
# local env files
.env
.env*.local
.env.local
.env.development.local
.env.test.local
.env.production.local
# vercel
.vercel
@@ -34,3 +53,42 @@ yarn-error.log*
# typescript
*.tsbuildinfo
next-env.d.ts
# IDE
.vscode/
.idea/
*.sublime-project
*.sublime-workspace
*.code-workspace
# Python (for agents directory)
__pycache__/
*.py[cod]
*$py.class
*.so
.Python
env/
venv/
ENV/
.venv
pip-log.txt
pip-delete-this-directory.txt
.pytest_cache/
.coverage
htmlcov/
*.egg-info/
dist/
build/
*.egg
# OS
Thumbs.db
.DS_Store
.AppleDouble
.LSOverride
._*
# Temporary files
*.tmp
*.temp
.cache/

View File

@@ -4,6 +4,7 @@ import base64
import json
import logging
import os
import random
import sys
import re
from dataclasses import asdict, dataclass
@@ -31,6 +32,7 @@ from livekit.agents import (
cli,
get_job_context,
metrics,
RoomIO
)
from livekit.agents.llm import ImageContent, ToolError, function_tool
from typing import Any, List, Optional
@@ -64,7 +66,7 @@ DEFAULT_INSTRUCTIONS = """# 角色
# 能力
- 你具有调用工具操作前端界面系统的能力
- ask_image_capture工具被调用后会在系统播放拍摄的目标和需求所以你每次在调用它之前不需要重复引导用户拍摄什么
- ask_image_capture工具被调用后会在系统播放拍摄的目标和需求所以你每次在调用它之前不需要重复引导用户拍摄什么而是使用ask_image_capture来传递拍摄需求
# 任务
你的职责是全流程引导用户完成:事故信息采集 -> 现场证据拍照 -> 驾驶员信息核实。
@@ -73,6 +75,7 @@ DEFAULT_INSTRUCTIONS = """# 角色
- 在事故信息采集阶段询问是否有人受伤请求用户简单描述事故情况询问事故发生时间并通过复述标准化时间xx年xx月xx日xx时xx分向用户确认询问事故车辆数量询问事故发生的原因例如追尾、刮擦、碰撞等。采集完成后进入现场证据拍照阶段
- 如果用户回答已包含需要问题的答案,改为与用户确认答案是否正确
- 采集完成之后进入现场证据拍照阶段
- 这个阶段不使用ask_important_question和ask_image_capture工具
## 现场证据拍照阶段
- 在现场证据拍照阶段使用askImageCapture工具引导用户依次拍摄照片1. 第一辆车的车牌2. 第一辆车的碰撞位置3. 第一辆车的驾驶员正脸;
@@ -100,9 +103,11 @@ DEFAULT_INSTRUCTIONS = """# 角色
- 一次询问一个问题
- 不要在你的回复中使用 emojis, asterisks, markdown, 或其他特殊字符
- 不同阶段直接的过渡语句自然
- 你已经说过下面的开场白所以不需要重复说:“您好,这里是无锡交警,我将为您远程处理交通事故。请将人员撤离至路侧安全区域,开启危险报警双闪灯、放置三角警告牌、做好安全防护,谨防二次事故伤害。若您已经准备好了,请点击继续办理,如需人工服务,请说转人工。”
- 你已经说过下面的开场白,用户点击继续办理说明已经认可,所以不需要重复说:“您好,这里是无锡交警,我将为您远程处理交通事故。请将人员撤离至路侧安全区域,开启危险报警双闪灯、放置三角警告牌、做好安全防护,谨防二次事故伤害。若您已经准备好了,请点击继续办理,如需人工服务,请说转人工。”
"""
DEFAULT_TALKING_MODE = 'push_to_talk'
# ## 黄金对话路径示例 GOLDEN_CONVERSATION_PATH
# ```
@@ -477,7 +482,7 @@ class MyAgent(Agent):
self._image_event.clear()
# Speak the capture prompt so the user hears what to do
self.session.say(prompt, allow_interruptions=True)
self.session.say(prompt, allow_interruptions=False)
# Ask for image capture and wait for user to capture/upload
response = await room.local_participant.perform_rpc(
@@ -605,13 +610,26 @@ class MyAgent(Agent):
f"│ plate: \"{normalized_plate}\"\n"
"└───────────────"
)
# Dummy fixed response (placeholder backend)
return {
# Generate random mobile number (11 digits: 1[3-9] + 9 random digits)
mobile_prefix = random.choice(['13', '14', '15', '16', '17', '18', '19'])
mobile_suffix = ''.join([str(random.randint(0, 9)) for _ in range(9)])
random_mobile = f"{mobile_prefix}{mobile_suffix}"
result = {
"success": True,
"plate": normalized_plate,
"mobile": "13800001234",
"mobile": random_mobile,
}
await self._send_chat_message(
"┌─✅ Result: get_mobile_by_plate\n"
f"│ plate: \"{normalized_plate}\"\n"
f"│ mobile: \"{random_mobile}\"\n"
"└───────────────"
)
return result
@function_tool()
async def get_id_card_by_plate(
self,
@@ -630,13 +648,35 @@ class MyAgent(Agent):
f"│ plate: \"{normalized_plate}\"\n"
"└───────────────"
)
# Dummy fixed response (placeholder backend)
return {
# Generate random ID card number (18 digits: 6-digit area code + 8-digit birth date + 3-digit sequence + 1 check digit)
# Area code: random 6 digits (typically 110000-659999 for Chinese ID cards)
area_code = random.randint(110000, 659999)
# Birth date: random date between 1950-01-01 and 2000-12-31
year = random.randint(1950, 2000)
month = random.randint(1, 12)
day = random.randint(1, 28) # Use 28 to avoid month-specific day issues
birth_date = f"{year:04d}{month:02d}{day:02d}"
# Sequence number: 3 random digits
sequence = random.randint(100, 999)
# Check digit: random digit or X (10% chance of X)
check_digit = 'X' if random.random() < 0.1 else str(random.randint(0, 9))
random_id_card = f"{area_code}{birth_date}{sequence}{check_digit}"
result = {
"success": True,
"plate": normalized_plate,
"id_card": "320101198001011234",
"id_card": random_id_card,
}
await self._send_chat_message(
"┌─✅ Result: get_id_card_by_plate\n"
f"│ plate: \"{normalized_plate}\"\n"
f"│ id_card: \"{random_id_card}\"\n"
"└───────────────"
)
return result
@function_tool()
async def validate_mobile_number(
self,
@@ -657,17 +697,33 @@ class MyAgent(Agent):
)
is_valid = bool(re.fullmatch(r"1[3-9]\\d{9}", normalized))
if is_valid:
return {
result = {
"success": True,
"valid": True,
"mobile": normalized,
}
return {
await self._send_chat_message(
"┌─✅ Result: validate_mobile_number\n"
f"│ mobile: \"{normalized}\"\n"
f"│ valid: true\n"
"└───────────────"
)
return result
result = {
"success": True,
"valid": False,
"mobile": normalized,
"error": "手机号格式不正确应为1[3-9]开头的11位数字",
}
await self._send_chat_message(
"┌─✅ Result: validate_mobile_number\n"
f"│ mobile: \"{normalized}\"\n"
f"│ valid: false\n"
f"│ error: \"{result['error']}\"\n"
"└───────────────"
)
return result
@function_tool()
async def validate_id_card_number(
@@ -689,25 +745,44 @@ class MyAgent(Agent):
)
is_valid = bool(re.fullmatch(r"(\\d{17}[\\dX]|\\d{15})", normalized))
if is_valid:
return {
result = {
"success": True,
"valid": True,
"id_card": normalized,
}
return {
await self._send_chat_message(
"┌─✅ Result: validate_id_card_number\n"
f"│ id_card: \"{normalized}\"\n"
f"│ valid: true\n"
"└───────────────"
)
return result
result = {
"success": True,
"valid": False,
"id_card": normalized,
"error": "身份证格式不正确应为18位末位可为X或15位数字",
}
await self._send_chat_message(
"┌─✅ Result: validate_id_card_number\n"
f"│ id_card: \"{normalized}\"\n"
f"│ valid: false\n"
f"│ error: \"{result['error']}\"\n"
"└───────────────"
)
return result
@function_tool()
async def enter_hand_off_to_human_mode(
self,
context: RunContext,
):
"""切换到“转人工”模式(前端电话界面进入人工处理)。返回成功/失败。"""
await self._send_chat_message("🔨 Call: enter_hand_off_to_human_mode")
"""切换到"转人工"模式(前端电话界面进入人工处理)。返回成功/失败。"""
await self._send_chat_message(
"┌─🔨 Call: enter_hand_off_to_human_mode\n"
"└───────────────"
)
try:
room = get_job_context().room
participant_identity = next(iter(room.remote_participants))
@@ -718,10 +793,21 @@ class MyAgent(Agent):
response_timeout=5.0,
)
logger.info(f"Entered hand off to human mode: {response}")
await self._send_chat_message(f"✅ Result: enter_hand_off_to_human_mode\n • status: success")
await self._send_chat_message(
"┌─✅ Result: enter_hand_off_to_human_mode\n"
f"│ status: success\n"
f"│ response: {response}\n"
"└───────────────"
)
return response
except Exception as e:
logger.error(f"Failed to enter hand off to human mode: {e}")
await self._send_chat_message(
"┌─❌ Result: enter_hand_off_to_human_mode\n"
f"│ status: error\n"
f"│ error: \"{str(e)}\"\n"
"└───────────────"
)
raise ToolError(f"Unable to enter hand off to human mode: {str(e)}")
@function_tool()
@@ -730,7 +816,10 @@ class MyAgent(Agent):
context: RunContext,
):
"""挂断当前通话(结束会话),返回成功/失败。"""
await self._send_chat_message("🔨 Call: hang_up_call")
await self._send_chat_message(
"┌─🔨 Call: hang_up_call\n"
"└───────────────"
)
try:
room = get_job_context().room
participant_identity = next(iter(room.remote_participants))
@@ -741,14 +830,25 @@ class MyAgent(Agent):
response_timeout=5.0,
)
logger.info(f"Hung up call: {response}")
await self._send_chat_message(f"✅ Result: hang_up_call\n • status: disconnected")
await self._send_chat_message(
"┌─✅ Result: hang_up_call\n"
f"│ status: disconnected\n"
f"│ response: {response}\n"
"└───────────────"
)
return response
except Exception as e:
logger.error(f"Failed to hang up call: {e}")
await self._send_chat_message(
"┌─❌ Result: hang_up_call\n"
f"│ status: error\n"
f"│ error: \"{str(e)}\"\n"
"└───────────────"
)
raise ToolError(f"Unable to hang up call: {str(e)}")
@function_tool()
async def ask_important_question(self, context: RunContext, message: str, options: Optional[List[str]] = None):
async def ask_important_question(self, context: RunContext, message: str, options: Optional[List[str]] | str = None):
"""询问关键问题并等待用户选择选项,返回用户的选择结果。
参数:
@@ -758,7 +858,12 @@ class MyAgent(Agent):
返回:
str: 用户选择的文本内容。
"""
await self._send_chat_message(f"🔨 Call: ask_important_question\n • message: \"{message}\"\n • options: {options}")
await self._send_chat_message(
"┌─🔨 Call: ask_important_question\n"
f"│ message: \"{message}\"\n"
f"│ options: {options}\n"
"└───────────────"
)
try:
room = get_job_context().room
participant_identity = next(iter(room.remote_participants))
@@ -781,7 +886,7 @@ class MyAgent(Agent):
payload_data["options"] = options
# Speak the message
speech_handle = self.session.say(message, allow_interruptions=True)
speech_handle = self.session.say(message, allow_interruptions=False)
# Wait for user selection with longer timeout since user needs time to respond
response = await room.local_participant.perform_rpc(
@@ -804,7 +909,11 @@ class MyAgent(Agent):
user_selection = response_data.get("selection", "确认")
logger.info(f"User selected: {user_selection}")
await self._send_chat_message(f"✅ Result: ask_important_question\n • selection: \"{user_selection}\"")
await self._send_chat_message(
"┌─✅ Result: ask_important_question\n"
f"│ selection: \"{user_selection}\"\n"
"└───────────────"
)
return f"用户选择了: {user_selection}"
except json.JSONDecodeError:
logger.error(f"Failed to parse response: {response}")
@@ -921,6 +1030,7 @@ async def entrypoint(ctx: JobContext, avatar_dispatcher_url: str = None, vision_
logger.info("Using default DeepSeek backend")
llm = openai.LLM.with_deepseek(
model='deepseek-chat',
temperature=0.1
)
session = AgentSession(
@@ -953,6 +1063,8 @@ async def entrypoint(ctx: JobContext, avatar_dispatcher_url: str = None, vision_
# Increase the maximum number of function calls per turn to avoid hitting the limit
max_tool_steps=15,
)
room_io = RoomIO(session, room=ctx.room)
await room_io.start()
# log metrics as they are emitted, and total usage after session is over
usage_collector = metrics.UsageCollector()
@@ -1011,6 +1123,59 @@ async def entrypoint(ctx: JobContext, avatar_dispatcher_url: str = None, vision_
room_output_options=RoomOutputOptions(transcription_enabled=True),
)
# disable input audio at the start
_talking_mode = DEFAULT_TALKING_MODE
if _talking_mode == "push_to_talk":
session.input.set_audio_enabled(False)
else:
session.input.set_audio_enabled(True)
@ctx.room.local_participant.register_rpc_method("start_turn")
async def start_turn(data: rtc.RpcInvocationData):
try:
session.interrupt()
except RuntimeError as e:
logger.error(f"Failed to interrupt session: {e}")
# Raise RPC error so client can detect interrupt failure
# Use ERROR_INTERNAL (code 13) to indicate application error
raise rtc.RpcError(
code=13, # ERROR_INTERNAL
message="Application error in method handler"
)
session.clear_user_turn()
# listen to the caller if multi-user
room_io.set_participant(data.caller_identity)
session.input.set_audio_enabled(True)
@ctx.room.local_participant.register_rpc_method("end_turn")
async def end_turn(data: rtc.RpcInvocationData):
session.input.set_audio_enabled(False)
session.commit_user_turn(
# the timeout for the final transcript to be received after committing the user turn
# increase this value if the STT is slow to respond
transcript_timeout=10.0,
# the duration of the silence to be appended to the STT to make it generate the final transcript
stt_flush_duration=2.0,
)
@ctx.room.local_participant.register_rpc_method("cancel_turn")
async def cancel_turn(data: rtc.RpcInvocationData):
session.input.set_audio_enabled(False)
session.clear_user_turn()
logger.info("cancel turn")
@ctx.room.local_participant.register_rpc_method("switch_ptt_and_rt")
async def switch_ptt_and_rt(data: rtc.RpcInvocationData):
nonlocal _talking_mode
_talking_mode = "push_to_talk" if _talking_mode == "realtime" else "realtime"
if _talking_mode == "push_to_talk":
session.input.set_audio_enabled(False)
else:
session.input.set_audio_enabled(True)
return json.dumps({"success": True, "mode": _talking_mode})
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--avatar-url", type=str, default=None, help="Avatar dispatcher URL")

View File

@@ -5,13 +5,14 @@ import {
BarVisualizer,
useConnectionState,
useLocalParticipant,
useParticipantAttributes,
useRoomContext,
useTracks,
useVoiceAssistant,
VideoTrack,
} from "@livekit/components-react";
import { ConnectionState, Track, LocalParticipant, Room } from "livekit-client";
import { useEffect, useMemo, useState, useRef } from "react";
import { useEffect, useMemo, useState, useRef, useCallback } from "react";
import { BatteryIcon, ImageIcon, MicIcon, MicOffIcon, PhoneIcon, PhoneOffIcon, WifiIcon, SwitchCameraIcon, VoiceIcon, CheckIcon } from "./icons";
import { useToast } from "@/components/toast/ToasterProvider";
@@ -43,6 +44,9 @@ export function PhoneSimulator({
const { localParticipant, isMicrophoneEnabled: isMicEnabled } = useLocalParticipant();
const tracks = useTracks();
const voiceAssistant = useVoiceAssistant();
const agentAttributes = useParticipantAttributes({
participant: voiceAssistant.agent,
});
const fileInputRef = useRef<HTMLInputElement>(null);
const phoneContainerRef = useRef<HTMLDivElement>(null);
const visualizerRef = useRef<HTMLDivElement>(null);
@@ -59,6 +63,10 @@ export function PhoneSimulator({
const isAgentSpeaking = voiceAssistant.state === "speaking";
const wasMicEnabledRef = useRef(false);
const lastPhoneMode = useRef(phoneMode);
const [isPushToTalkActive, setIsPushToTalkActive] = useState(false);
const [interruptRejected, setInterruptRejected] = useState(false);
const [isPushToTalkMode, setIsPushToTalkMode] = useState(true); // false = realtime mode, true = PTT mode (default)
const pushToTalkButtonRef = useRef<HTMLButtonElement>(null);
useEffect(() => {
const voiceAttr = config.settings.attributes?.find(a => a.key === "voice");
@@ -156,11 +164,19 @@ export function PhoneSimulator({
const enteringMode = (mode: typeof phoneMode) =>
phoneMode === mode && lastPhoneMode.current !== mode;
// Only proceed if connected and localParticipant is available
if (roomState !== ConnectionState.Connected || !localParticipant) return;
const updateMicState = async () => {
// Entering important message / capture / hand_off: remember mic state and mute if needed
if (enteringMode("important_message") || enteringMode("capture") || enteringMode("hand_off")) {
wasMicEnabledRef.current = isMicEnabled;
if (isMicEnabled) {
localParticipant.setMicrophoneEnabled(false);
try {
await localParticipant.setMicrophoneEnabled(false);
} catch (error) {
console.error("Failed to disable microphone:", error);
}
}
}
// Exiting important message mode or hand off mode or capture mode
@@ -170,7 +186,11 @@ export function PhoneSimulator({
(phoneMode !== "capture" && lastPhoneMode.current === "capture")
) {
// Restore mic to previous state
localParticipant.setMicrophoneEnabled(wasMicEnabledRef.current);
try {
await localParticipant.setMicrophoneEnabled(wasMicEnabledRef.current);
} catch (error) {
console.error("Failed to restore microphone:", error);
}
// If exiting capture mode, clear processing image
if (lastPhoneMode.current === "capture") {
@@ -180,11 +200,17 @@ export function PhoneSimulator({
}
// Enforce mic off in important message mode, hand off mode, or capture mode
else if ((phoneMode === "important_message" || phoneMode === "hand_off" || phoneMode === "capture") && isMicEnabled) {
localParticipant.setMicrophoneEnabled(false);
try {
await localParticipant.setMicrophoneEnabled(false);
} catch (error) {
console.error("Failed to disable microphone:", error);
}
}
};
updateMicState();
lastPhoneMode.current = phoneMode;
}, [phoneMode, isMicEnabled, localParticipant]);
}, [phoneMode, isMicEnabled, localParticipant, roomState]);
useEffect(() => {
const updateTime = () => {
@@ -210,15 +236,36 @@ export function PhoneSimulator({
);
const handleMicToggle = async () => {
if (roomState !== ConnectionState.Connected || !localParticipant) return;
try {
if (isMicEnabled) {
await localParticipant.setMicrophoneEnabled(false);
} else {
await localParticipant.setMicrophoneEnabled(true);
}
} catch (error) {
console.error("Failed to toggle microphone:", error);
// Silently handle the error to avoid disrupting user experience
}
};
const handleDisconnect = () => {
try {
// Only disconnect if we're actually connected
if (roomState === ConnectionState.Connected || roomState === ConnectionState.Connecting) {
onDisconnect();
}
} catch (error) {
// Silently handle any errors during disconnect
console.warn("Error during disconnect:", error);
// Still try to call onDisconnect to ensure cleanup
try {
onDisconnect();
} catch (e) {
// Ignore secondary errors
}
}
};
const validateImageFile = (file: File) => {
@@ -407,6 +454,225 @@ export function PhoneSimulator({
setShowVoiceMenu(!showVoiceMenu);
};
const handleModeSwitch = async () => {
if (!room || !voiceAssistant.agent) return;
try {
await room.localParticipant.performRpc({
destinationIdentity: voiceAssistant.agent.identity,
method: "switch_ptt_and_rt",
payload: "",
});
// Toggle mode on success
setIsPushToTalkMode(prev => !prev);
} catch (error: any) {
console.error("Failed to switch mode:", error);
// Don't show error toast for mode switch failures, just log
}
};
// Check if agent supports push-to-talk (optional check, button will show regardless)
const supportsPushToTalk = useMemo(() => {
if (!voiceAssistant.agent || !agentAttributes.attributes) return false;
return agentAttributes.attributes["push-to-talk"] === "1";
}, [voiceAssistant.agent, agentAttributes.attributes]);
const handlePushToTalkStart = async () => {
if (!room || !voiceAssistant.agent || isPushToTalkActive) return;
// Reset interrupt rejection state
setInterruptRejected(false);
try {
await room.localParticipant.performRpc({
destinationIdentity: voiceAssistant.agent.identity,
method: "start_turn",
payload: "",
});
setIsPushToTalkActive(true);
setInterruptRejected(false);
} catch (error: any) {
// Prevent error from propagating to React error boundary
// by handling all expected errors here
setIsPushToTalkActive(false);
const errorMessage = error?.message || "";
const errorCode = error?.code;
// Check for "Method not supported at destination" - this happens when RPC methods aren't registered yet
// This can occur on first call before agent is fully ready, so we silently ignore it
if (errorMessage.includes("Method not supported at destination") ||
errorMessage.includes("method not found") ||
errorCode === 12) { // METHOD_NOT_FOUND
// Silently ignore - the method will be available after first turn
console.log("RPC method not ready yet, will be available after first turn");
return;
}
// Check for "Application error in method handler" - this indicates interrupt failed
// This error is raised when session.interrupt() fails in the agent
// We handle this gracefully by showing "不允许打断" on the button, so we don't log it as an error
if (errorMessage.includes("Application error in method handler") ||
errorMessage.includes("Application error") ||
errorCode === 13 || // ERROR_INTERNAL (RpcErrorCode.ERROR_INTERNAL)
(isAgentSpeaking && errorMessage.includes("interrupt"))) {
// Suppress error logging for expected interrupt failures
// Only log at debug level to avoid error popups
if (process.env.NODE_ENV === 'development') {
console.log("Interrupt rejected (expected behavior):", errorMessage);
}
setInterruptRejected(true);
// Clear the rejection message after 3 seconds
setTimeout(() => setInterruptRejected(false), 3000);
// Explicitly prevent error from propagating
error.preventDefault?.();
error.stopPropagation?.();
return;
}
// Check if agent is speaking and the error suggests interruption was rejected
if (isAgentSpeaking) {
// Check for common rejection indicators
if (errorMessage.includes("reject") ||
errorMessage.includes("not allowed") ||
errorCode === 403 || // Forbidden
errorCode === 409) { // Conflict
// Suppress error logging for expected rejections
if (process.env.NODE_ENV === 'development') {
console.log("Interrupt rejected:", errorMessage);
}
setInterruptRejected(true);
// Clear the rejection message after 3 seconds
setTimeout(() => setInterruptRejected(false), 3000);
return;
}
}
// Only log and show error for unexpected errors
console.error("Unexpected error in push-to-talk:", error);
const defaultErrorMessage = "Agent does not support push-to-talk. Make sure your agent has the push-to-talk RPC methods (start_turn, end_turn, cancel_turn) registered.";
setToastMessage({ message: defaultErrorMessage, type: "error" });
}
};
const handlePushToTalkEnd = useCallback(async () => {
// Always clear interrupt rejection state when button is released
setInterruptRejected(false);
if (!room || !voiceAssistant.agent || !isPushToTalkActive) return;
try {
await room.localParticipant.performRpc({
destinationIdentity: voiceAssistant.agent.identity,
method: "end_turn",
payload: "",
});
setIsPushToTalkActive(false);
} catch (error: any) {
console.error("Failed to end turn:", error);
// Don't show error toast on end_turn failure as it might be called during cleanup
setIsPushToTalkActive(false);
}
}, [room, voiceAssistant.agent, isPushToTalkActive]);
const handlePushToTalkCancel = useCallback(async () => {
// Always clear interrupt rejection state when button is cancelled
setInterruptRejected(false);
if (!room || !voiceAssistant.agent || !isPushToTalkActive) return;
try {
await room.localParticipant.performRpc({
destinationIdentity: voiceAssistant.agent.identity,
method: "cancel_turn",
payload: "",
});
setIsPushToTalkActive(false);
} catch (error) {
console.error("Failed to cancel turn:", error);
setIsPushToTalkActive(false);
}
}, [room, voiceAssistant.agent, isPushToTalkActive]);
// Handle mouse events for push-to-talk
const handlePushToTalkMouseDown = (e: React.MouseEvent) => {
e.preventDefault();
handlePushToTalkStart();
};
const handlePushToTalkMouseUp = (e: React.MouseEvent) => {
e.preventDefault();
handlePushToTalkEnd();
};
// Handle touch events for push-to-talk
const handlePushToTalkTouchStart = (e: React.TouchEvent) => {
e.preventDefault();
handlePushToTalkStart();
};
const handlePushToTalkTouchEnd = (e: React.TouchEvent) => {
e.preventDefault();
handlePushToTalkEnd();
};
// Handle window blur, escape key, and global mouse/touch events to cancel/end push-to-talk
useEffect(() => {
if (!isPushToTalkActive) return;
const handleBlur = () => {
handlePushToTalkCancel();
};
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") {
handlePushToTalkCancel();
}
};
// Handle global mouseup/touchend to end push-to-talk even if released outside button
const handleGlobalMouseUp = () => {
// Clear interrupt rejection state immediately when button is released
setInterruptRejected(false);
handlePushToTalkEnd();
};
const handleGlobalTouchEnd = () => {
// Clear interrupt rejection state immediately when button is released
setInterruptRejected(false);
handlePushToTalkEnd();
};
window.addEventListener("blur", handleBlur);
window.addEventListener("keydown", handleKeyDown);
window.addEventListener("mouseup", handleGlobalMouseUp);
window.addEventListener("touchend", handleGlobalTouchEnd);
return () => {
window.removeEventListener("blur", handleBlur);
window.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("mouseup", handleGlobalMouseUp);
window.removeEventListener("touchend", handleGlobalTouchEnd);
};
}, [isPushToTalkActive, handlePushToTalkCancel, handlePushToTalkEnd]);
// Clean up push-to-talk state on disconnect
useEffect(() => {
if (roomState === ConnectionState.Disconnected && isPushToTalkActive) {
setIsPushToTalkActive(false);
setInterruptRejected(false);
}
}, [roomState, isPushToTalkActive]);
// Reset interrupt rejection when agent stops speaking
useEffect(() => {
if (!isAgentSpeaking && interruptRejected) {
// Clear rejection state when agent finishes speaking
const timer = setTimeout(() => setInterruptRejected(false), 1000);
return () => clearTimeout(timer);
}
}, [isAgentSpeaking, interruptRejected]);
const handleFileChange = (event: React.ChangeEvent<HTMLInputElement>) => {
const file = event.target.files?.[0];
if (file && onCapture) {
@@ -449,7 +715,7 @@ export function PhoneSimulator({
>
<PhoneIcon className="w-8 h-8" />
</div>
<span className="font-medium text-white">Call Agent</span>
<span className="font-medium text-white"></span>
</button>
<div className="relative">
@@ -459,7 +725,7 @@ export function PhoneSimulator({
>
<VoiceIcon className="w-3 h-3" />
<span>
{currentVoiceId === "BV001_streaming" ? "Female Voice" : "Male Voice"}
{currentVoiceId === "BV001_streaming" ? "女性声音" : "男性声音"}
</span>
</button>
{showVoiceMenu && (
@@ -479,7 +745,7 @@ export function PhoneSimulator({
: "text-white"
}`}
>
<span>Female Voice</span>
<span></span>
{currentVoiceId === "BV001_streaming" && <CheckIcon />}
</button>
<button
@@ -494,7 +760,7 @@ export function PhoneSimulator({
: "text-white"
}`}
>
<span>Male Voice</span>
<span></span>
{currentVoiceId === "BV002_streaming" && (
<CheckIcon />
)}
@@ -793,6 +1059,106 @@ export function PhoneSimulator({
</div>
) : (
<div className="absolute bottom-[5%] left-0 w-full px-[8%] z-40">
<div className="w-full flex flex-col items-center justify-center gap-4">
{/* Mode Toggle Switch */}
{phoneMode !== "important_message" && phoneMode !== "hand_off" && voiceAssistant.agent && (
<div className="flex items-center gap-3 mb-2">
<span className={`text-xs font-medium transition-colors ${isPushToTalkMode ? "text-white" : "text-gray-400"}`}>
</span>
<button
onClick={handleModeSwitch}
className={`relative inline-flex h-6 w-11 items-center rounded-full transition-colors focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 ${
!isPushToTalkMode ? "bg-blue-500" : "bg-gray-600"
}`}
role="switch"
aria-checked={!isPushToTalkMode}
title={isPushToTalkMode ? "切换到实时对话模式" : "切换到按下说话模式"}
>
<span
className={`inline-block h-4 w-4 transform rounded-full bg-white transition-transform ${
!isPushToTalkMode ? "translate-x-6" : "translate-x-1"
}`}
/>
</button>
<span className={`text-xs font-medium transition-colors ${!isPushToTalkMode ? "text-white" : "text-gray-400"}`}>
</span>
</div>
)}
{/* Push-to-Talk Mode Layout */}
{isPushToTalkMode && phoneMode !== "hand_off" && voiceAssistant.agent && (
<div className="w-full flex items-center justify-center gap-8">
{/* Camera Switch Button - Left (hidden in important_message mode) */}
{phoneMode !== "important_message" && (
<div className="relative">
<button
className="p-4 rounded-full bg-gray-800/50 text-white hover:bg-gray-800/70 transition-colors"
onClick={handleSwitchCamera}
>
<SwitchCameraIcon className="w-6 h-6" />
</button>
{showCameraMenu && (
<div className="absolute bottom-full mb-2 left-0 bg-gray-900 border border-gray-800 rounded-lg shadow-xl py-2 w-48 z-50">
{cameras.length === 0 ? (
<div className="px-4 py-2 text-gray-500 text-sm">
No cameras found
</div>
) : (
cameras.map((device) => (
<button
key={device.deviceId}
onClick={() => handleSelectCamera(device.deviceId)}
className="w-full text-left px-4 py-2 text-sm text-white hover:bg-gray-800 transition-colors truncate"
>
{device.label ||
`Camera ${cameras.indexOf(device) + 1}`}
</button>
))
)}
</div>
)}
</div>
)}
{/* Large Push-to-Talk Button - Center (hidden in important_message mode) */}
{phoneMode !== "important_message" && (
<button
ref={pushToTalkButtonRef}
className={`w-24 h-24 rounded-full backdrop-blur-md transition-all flex flex-col items-center justify-center gap-2 aspect-square ${
interruptRejected
? "bg-red-500/70 text-white"
: isPushToTalkActive
? "bg-green-500 text-white scale-110 shadow-lg shadow-green-500/50"
: "bg-blue-500/70 text-white hover:bg-blue-500/90"
}`}
style={{ borderRadius: '50%' }}
onMouseDown={handlePushToTalkMouseDown}
onMouseUp={handlePushToTalkMouseUp}
onTouchStart={handlePushToTalkTouchStart}
onTouchEnd={handlePushToTalkTouchEnd}
title={supportsPushToTalk ? "Push to Talk" : "Push to Talk (may not be supported by this agent)"}
>
<MicIcon className="w-8 h-8" />
<span className="text-xs font-medium">
{interruptRejected ? "不允许打断" : "按住说话"}
</span>
</button>
)}
{/* End Call Button - Right (always shown in PTT mode) */}
<button
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={handleDisconnect}
>
<PhoneOffIcon className="w-6 h-6" />
</button>
</div>
)}
{/* Realtime Mode Layout */}
{!isPushToTalkMode && (
<div className="w-full flex items-center justify-center gap-8">
{phoneMode !== "important_message" && phoneMode !== "hand_off" && (
<button
@@ -811,6 +1177,7 @@ export function PhoneSimulator({
</button>
)}
{/* End Call Button */}
<button
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={handleDisconnect}
@@ -818,6 +1185,34 @@ export function PhoneSimulator({
<PhoneOffIcon className="w-6 h-6" />
</button>
</div>
)}
{/* Hand Off Mode - Show only End Call Button */}
{phoneMode === "hand_off" && (
<div className="w-full flex items-center justify-center">
<button
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={handleDisconnect}
>
<PhoneOffIcon className="w-6 h-6" />
</button>
</div>
)}
{/* Fallback: Show End Call Button when in push-to-talk mode but no agent/audio */}
{phoneMode === "normal" &&
isPushToTalkMode &&
!voiceAssistant.agent && (
<div className="w-full flex items-center justify-center">
<button
className="p-4 rounded-full bg-red-500 text-white hover:bg-red-600 transition-colors"
onClick={handleDisconnect}
>
<PhoneOffIcon className="w-6 h-6" />
</button>
</div>
)}
</div>
</div>
)
)}

View File

@@ -76,10 +76,67 @@ export default function Playground({
const [rpcPayload, setRpcPayload] = useState("");
const [showRpc, setShowRpc] = useState(false);
useEffect(() => {
// Clean up RPC resolvers before disconnecting to prevent errors
const cleanupRpcResolvers = useCallback(() => {
// Clean up any pending important message RPC
if (importantMessageResolverRef.current) {
const resolver = importantMessageResolverRef.current;
importantMessageResolverRef.current = null;
try {
// Only resolve if room is still connected to avoid RPC errors
if (roomState === ConnectionState.Connected) {
resolver("disconnected");
}
} catch (error) {
// Ignore errors during cleanup - room might be disconnecting
}
}
// Clean up any pending image capture RPC
if (imageCaptureResolverRef.current) {
const resolver = imageCaptureResolverRef.current;
imageCaptureResolverRef.current = null;
try {
// Only resolve if room is still connected to avoid RPC errors
if (roomState === ConnectionState.Connected) {
resolver(JSON.stringify({ error: "disconnected" }));
}
} catch (error) {
// Ignore errors during cleanup - room might be disconnecting
}
}
}, [roomState]);
// Wrapper for disconnect that cleans up RPC resolvers first
const handleDisconnect = useCallback(() => {
cleanupRpcResolvers();
try {
onConnect(false);
} catch (error) {
// Silently handle any errors during disconnect
console.warn("Error during disconnect:", error);
}
}, [onConnect, cleanupRpcResolvers]);
useEffect(() => {
if (roomState === ConnectionState.Connected && localParticipant) {
try {
localParticipant.setCameraEnabled(config.settings.inputs.camera);
localParticipant.setMicrophoneEnabled(config.settings.inputs.mic);
} catch (error) {
console.error("Failed to set camera/microphone:", error);
// Retry after a short delay if connection might not be fully ready
const retryTimeout = setTimeout(() => {
if (roomState === ConnectionState.Connected && localParticipant) {
try {
localParticipant.setCameraEnabled(config.settings.inputs.camera);
localParticipant.setMicrophoneEnabled(config.settings.inputs.mic);
} catch (retryError) {
console.error("Failed to set camera/microphone on retry:", retryError);
}
}
}, 500);
return () => clearTimeout(retryTimeout);
}
}
}, [config.settings.inputs.camera, config.settings.inputs.mic, localParticipant, roomState]);
@@ -145,7 +202,7 @@ export default function Playground({
'hangUpCall',
async () => {
// Disconnect the call
onConnect(false);
handleDisconnect();
return JSON.stringify({ success: true });
}
);
@@ -179,7 +236,7 @@ export default function Playground({
});
}
);
}, [localParticipant, roomState, onConnect]);
}, [localParticipant, roomState, handleDisconnect]);
useEffect(() => {
if (roomState === ConnectionState.Connected) {
@@ -422,6 +479,7 @@ export default function Playground({
]);
const instructionsContent = (
<>
<ConfigurationPanelItem title="Instructions">
<textarea
className="w-full bg-gray-950 text-white text-sm p-3 rounded-md border border-gray-800 focus:border-gray-600 focus:outline-none transition-colors resize-none disabled:opacity-50 disabled:cursor-not-allowed"
@@ -437,6 +495,18 @@ export default function Playground({
disabled={roomState !== ConnectionState.Disconnected}
/>
</ConfigurationPanelItem>
<ConfigurationPanelItem title="Color">
<ColorPicker
colors={themeColors}
selectedColor={config.settings.theme_color}
onSelect={(color) => {
const userSettings = { ...config.settings };
userSettings.theme_color = color;
setUserSettings(userSettings);
}}
/>
</ConfigurationPanelItem>
</>
);
const handleRpcCall = useCallback(async () => {
@@ -459,13 +529,13 @@ export default function Playground({
const settingsTileContent = useMemo(() => {
return (
<div className="flex flex-col h-full w-full items-start overflow-y-auto">
{config.description && (
{/* {config.description && (
<ConfigurationPanelItem title="Description">
{config.description}
</ConfigurationPanelItem>
)}
)} */}
<ConfigurationPanelItem title="Room">
{/* <ConfigurationPanelItem title="Room">
<div className="flex flex-col gap-2">
<EditableNameValueRow
name="Room name"
@@ -499,9 +569,9 @@ export default function Playground({
}
/>
</div>
</ConfigurationPanelItem>
</ConfigurationPanelItem> */}
<ConfigurationPanelItem title="Agent">
{/* <ConfigurationPanelItem title="Agent">
<div className="flex flex-col gap-2">
<EditableNameValueRow
name="Agent name"
@@ -564,9 +634,9 @@ export default function Playground({
.
</p>
</div>
</ConfigurationPanelItem>
</ConfigurationPanelItem> */}
<ConfigurationPanelItem title="User">
{/* <ConfigurationPanelItem title="User">
<div className="flex flex-col gap-2">
<EditableNameValueRow
name="Name"
@@ -618,7 +688,7 @@ export default function Playground({
connectionState={roomState}
/>
</div>
</ConfigurationPanelItem>
</ConfigurationPanelItem> */}
{roomState === ConnectionState.Connected &&
config.settings.inputs.screen && (
@@ -668,19 +738,6 @@ export default function Playground({
<AudioInputTile trackRef={localMicTrack} />
</ConfigurationPanelItem>
)}
<div className="w-full">
<ConfigurationPanelItem title="Color">
<ColorPicker
colors={themeColors}
selectedColor={config.settings.theme_color}
onSelect={(color) => {
const userSettings = { ...config.settings };
userSettings.theme_color = color;
setUserSettings(userSettings);
}}
/>
</ConfigurationPanelItem>
</div>
{config.show_qr && (
<div className="w-full">
<ConfigurationPanelItem title="QR Code">
@@ -691,7 +748,6 @@ export default function Playground({
</div>
);
}, [
config.description,
config.settings,
config.show_qr,
localParticipant,
@@ -721,7 +777,7 @@ export default function Playground({
>
<PhoneSimulator
onConnect={() => onConnect(true)}
onDisconnect={() => onConnect(false)}
onDisconnect={handleDisconnect}
phoneMode={phoneMode}
capturePrompt={capturePrompt}
importantMessage={importantMessage}
@@ -785,20 +841,24 @@ export default function Playground({
return (
<>
<PlaygroundHeader
{/* <PlaygroundHeader
title={config.title}
logo={logo}
githubLink={config.github_link}
height={headerHeight}
accentColor={config.settings.theme_color}
connectionState={roomState}
onConnectClicked={() =>
onConnect(roomState === ConnectionState.Disconnected)
onConnectClicked={() => {
if (roomState === ConnectionState.Disconnected) {
onConnect(true);
} else {
handleDisconnect();
}
/>
}}
/> */}
<div
className={`flex gap-4 py-4 grow w-full selection:bg-${config.settings.theme_color}-900`}
style={{ height: `calc(100% - ${headerHeight}px)` }}
style={{ height: `100%` }}
>
<div className="flex flex-col grow basis-1/2 gap-4 h-full lg:hidden">
<PlaygroundTabbedTile
@@ -821,7 +881,7 @@ export default function Playground({
>
<PhoneSimulator
onConnect={() => onConnect(true)}
onDisconnect={() => onConnect(false)}
onDisconnect={handleDisconnect}
phoneMode={phoneMode}
capturePrompt={capturePrompt}
importantMessage={importantMessage}
@@ -865,14 +925,14 @@ export default function Playground({
</PlaygroundTile>
</div>
)}
<PlaygroundTile
{/* <PlaygroundTile
padding={false}
backgroundColor="gray-950"
className="h-full w-full basis-1/4 items-start overflow-y-auto hidden max-w-[480px] lg:flex"
childrenClassName="h-full grow items-start"
>
{settingsTileContent}
</PlaygroundTile>
</PlaygroundTile> */}
</div>
</>
);

View File

@@ -100,7 +100,18 @@ export const PlaygroundTabbedTile: React.FC<PlaygroundTabbedTileProps> = ({
padding: `${contentPadding * 4}px`,
}}
>
{tabs[activeTab].content}
{tabs.map((tab, index) => (
<div
key={index}
style={{
display: index === activeTab ? 'block' : 'none',
height: '100%',
width: '100%',
}}
>
{tab.content}
</div>
))}
</div>
</div>
);

View File

@@ -108,6 +108,16 @@ export function HomeInner() {
token={token}
connect={shouldConnect}
onError={(e) => {
// Filter out expected errors from push-to-talk interrupt failures
// These are handled gracefully in the PhoneSimulator component
if (e.message?.includes("Application error in method handler") ||
e.message?.includes("Method not supported at destination")) {
// Silently ignore - these are expected and handled in PhoneSimulator
if (process.env.NODE_ENV === 'development') {
console.log("Filtered expected error:", e.message);
}
return;
}
setToastMessage({ message: e.message, type: "error" });
console.error(e);
}}

View File

@@ -11,7 +11,7 @@ import {
Track,
TranscriptionSegment,
} from "livekit-client";
import { useEffect, useState } from "react";
import { useEffect, useState, useRef } from "react";
export function TranscriptionTile({
agentAudioTrack,
@@ -30,39 +30,51 @@ export function TranscriptionTile({
participant: localParticipant.localParticipant,
});
const [transcripts, setTranscripts] = useState<Map<string, ChatMessageType>>(
new Map(),
);
const [messages, setMessages] = useState<ChatMessageType[]>([]);
const { chatMessages, send: sendChat } = useChat();
const transcriptMapRef = useRef<Map<string, ChatMessageType>>(new Map());
// store transcripts
// Build messages from segments and chat - always rebuild from current state
useEffect(() => {
const transcriptMap = transcriptMapRef.current;
// Process agent segments - update existing or add new
if (agentAudioTrack) {
agentMessages.segments.forEach((s) =>
transcripts.set(
agentMessages.segments.forEach((s) => {
const existing = transcriptMap.get(s.id);
transcriptMap.set(
s.id,
segmentToChatMessage(
s,
transcripts.get(s.id),
existing,
agentAudioTrack.participant,
),
),
);
});
}
localMessages.segments.forEach((s) =>
transcripts.set(
// Process local segments - update existing or add new
localMessages.segments.forEach((s) => {
const existing = transcriptMap.get(s.id);
transcriptMap.set(
s.id,
segmentToChatMessage(
s,
transcripts.get(s.id),
existing,
localParticipant.localParticipant,
),
),
);
});
const allMessages = Array.from(transcripts.values());
// Build all messages
const allMessages: ChatMessageType[] = [];
// Add all transcript messages
transcriptMap.forEach((msg) => {
allMessages.push(msg);
});
// Add chat messages
for (const msg of chatMessages) {
const isAgent = agentAudioTrack
? msg.from?.identity === agentAudioTrack.participant?.identity
@@ -79,6 +91,7 @@ export function TranscriptionTile({
name = "Unknown";
}
}
allMessages.push({
name,
message: msg.message,
@@ -86,10 +99,11 @@ export function TranscriptionTile({
isSelf: isSelf,
});
}
// Sort by timestamp
allMessages.sort((a, b) => a.timestamp - b.timestamp);
setMessages(allMessages);
}, [
transcripts,
chatMessages,
localParticipant.localParticipant,
agentAudioTrack?.participant,