Compare commits
10 Commits
33745b8b54
...
a6b98e4100
| Author | SHA1 | Date | |
|---|---|---|---|
| a6b98e4100 | |||
| 48cb450208 | |||
| 800aa700f9 | |||
| 2decf208b4 | |||
| b75fd71bc7 | |||
| e8ef7c6da7 | |||
| f2fcbe485f | |||
| e09e4b6930 | |||
| 1774f550dd | |||
| 9f05f067a6 |
58
.gitignore
vendored
58
.gitignore
vendored
@@ -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/
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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);
|
||||
}}
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user