feat: add configurable client tools and photo input

This commit is contained in:
Xin Wang
2026-07-30 19:06:03 +08:00
parent 510a277b5a
commit 913435785e
24 changed files with 1802 additions and 139 deletions

View File

@@ -237,6 +237,12 @@ async def sync_mcp_tools(
"remote_tool_name": remote_name, "remote_tool_name": remote_name,
"input_schema": remote.get("input_schema") or {}, "input_schema": remote.get("input_schema") or {},
"schema_hash": remote.get("schema_hash") or "", "schema_hash": remote.get("schema_hash") or "",
"allow_interruptions": previous_config.get(
"allow_interruptions", True
),
"execution_mode": previous_config.get(
"execution_mode", "immediate"
),
"dynamic_variable_assignments": previous_config.get( "dynamic_variable_assignments": previous_config.get(
"dynamic_variable_assignments" "dynamic_variable_assignments"
) )

View File

@@ -19,12 +19,13 @@ ModelType = Literal["LLM", "ASR", "TTS", "Realtime", "Embedding", "Agent"]
AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"] AssistantType = Literal["prompt", "workflow", "dify", "fastgpt", "opencode"]
TurnEndStrategy = Literal["silence", "smart_turn"] TurnEndStrategy = Literal["silence", "smart_turn"]
KnowledgeRetrievalMode = Literal["automatic", "on_demand"] KnowledgeRetrievalMode = Literal["automatic", "on_demand"]
ToolType = Literal["end_call", "http", "mcp"] ToolType = Literal["end_call", "http", "mcp", "client"]
ToolStatus = Literal["active", "archived", "draft"] ToolStatus = Literal["active", "archived", "draft"]
McpServerStatus = Literal["active", "archived", "draft"] McpServerStatus = Literal["active", "archived", "draft"]
McpTransport = Literal["streamable_http", "sse"] McpTransport = Literal["streamable_http", "sse"]
ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"] ToolParameterType = Literal["string", "number", "integer", "boolean", "object", "array"]
ToolParameterLocation = Literal["path", "query", "body", "header"] ToolParameterLocation = Literal["path", "query", "body", "header"]
ToolExecutionMode = Literal["immediate", "async"]
DynamicVariableType = Literal["string", "number", "boolean"] DynamicVariableType = Literal["string", "number", "boolean"]
# 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵 # 外部应用类型:其 config.apiKey 是该助手私有密钥,读时打码 / 写时哨兵
@@ -193,6 +194,8 @@ class EndCallToolConfig(CamelModel):
class HttpToolConfig(CamelModel): class HttpToolConfig(CamelModel):
allow_interruptions: bool = True
execution_mode: ToolExecutionMode = "immediate"
method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "GET" method: Literal["GET", "POST", "PUT", "PATCH", "DELETE"] = "GET"
url: str url: str
timeout_seconds: int = Field(default=15, ge=1, le=120) timeout_seconds: int = Field(default=15, ge=1, le=120)
@@ -221,7 +224,30 @@ class HttpToolDefinition(CamelModel):
config: HttpToolConfig config: HttpToolConfig
class ClientToolConfig(CamelModel):
allow_interruptions: bool = True
execution_mode: ToolExecutionMode = "async"
wait_for_response: bool = True
parameters: list[ToolParameter] = Field(default_factory=list)
timeout_seconds: int = Field(default=3, ge=1, le=30)
dynamic_variable_assignments: dict[str, str] = Field(default_factory=dict)
@model_validator(mode="after")
def validate_result_assignments(self):
if not self.wait_for_response and self.dynamic_variable_assignments:
raise ValueError("Client Tool 不等待响应时不能配置结果变量赋值")
return self
class ClientToolDefinition(CamelModel):
schema_version: int = 1
type: Literal["client"] = "client"
config: ClientToolConfig = Field(default_factory=ClientToolConfig)
class McpToolConfig(CamelModel): class McpToolConfig(CamelModel):
allow_interruptions: bool = True
execution_mode: ToolExecutionMode = "immediate"
remote_tool_name: str = Field(min_length=1, max_length=255) remote_tool_name: str = Field(min_length=1, max_length=255)
input_schema: dict[str, Any] = Field(default_factory=dict) input_schema: dict[str, Any] = Field(default_factory=dict)
schema_hash: str = "" schema_hash: str = ""
@@ -235,7 +261,12 @@ class McpToolDefinition(CamelModel):
ToolDefinition = Annotated[ ToolDefinition = Annotated[
Union[EndCallToolDefinition, HttpToolDefinition, McpToolDefinition], Union[
EndCallToolDefinition,
HttpToolDefinition,
ClientToolDefinition,
McpToolDefinition,
],
Field(discriminator="type"), Field(discriminator="type"),
] ]

View File

@@ -17,6 +17,7 @@ from pipecat.adapters.schemas.function_schema import FunctionSchema
from pipecat.frames.frames import Frame from pipecat.frames.frames import Frame
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
from services.client_tools import ClientToolPort
GREETING_CONTEXT_MARKER = "[会话事实:助手开场白已播放]" GREETING_CONTEXT_MARKER = "[会话事实:助手开场白已播放]"
@@ -74,6 +75,7 @@ class BrainRuntime:
set_system_prompt: Callable[[str], None] set_system_prompt: Callable[[str], None]
set_tools: Callable[[list[FunctionSchema] | None], None] set_tools: Callable[[list[FunctionSchema] | None], None]
call_end: CallEndPort call_end: CallEndPort
client_tools: ClientToolPort | None = None
worker: Any = None worker: Any = None
context_aggregator: Any = None context_aggregator: Any = None
transport: Any = None transport: Any = None

View File

@@ -20,6 +20,7 @@ from services.runtime_variables import (
DynamicVariableStore, DynamicVariableStore,
) )
from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
class PromptBrain(BaseBrain): class PromptBrain(BaseBrain):
@@ -50,17 +51,23 @@ class PromptBrain(BaseBrain):
async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None: async def setup(self, cfg: AssistantConfig, runtime: BrainRuntime) -> None:
self._runtime = runtime self._runtime = runtime
self._tools.set_client_tools(runtime.client_tools)
self._waiting_for_generated_end_speech = False self._waiting_for_generated_end_speech = False
schemas: list[FunctionSchema] = [] schemas: list[FunctionSchema] = []
for tool in cfg.tools: for tool in cfg.tools:
if tool.type == "end_call": if tool.type == "end_call":
schema, handler = self._make_end_call_tool(tool, runtime) schema, handler = self._make_end_call_tool(tool, runtime)
elif tool.type in {"http", "mcp"}: elif tool.type in {"http", "mcp", "client"}:
schema, handler = self._make_remote_tool(tool, runtime) schema, handler = self._make_remote_tool(tool, runtime)
else: else:
continue continue
schemas.append(schema) schemas.append(schema)
runtime.llm.register_function(tool.function_name, handler) policy = policy_for_tool(tool)
runtime.llm.register_function(
tool.function_name,
handler,
cancel_on_interruption=policy.cancel_on_interruption,
)
runtime.set_tools(schemas) runtime.set_tools(schemas)
def record_user_message(self, content: str) -> None: def record_user_message(self, content: str) -> None:
@@ -99,16 +106,27 @@ class PromptBrain(BaseBrain):
def _make_remote_tool(self, tool, runtime: BrainRuntime): def _make_remote_tool(self, tool, runtime: BrainRuntime):
properties, required = self._tools.schema_parts(tool) properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool) self._tools.register_secrets(tool)
policy = policy_for_tool(tool)
async def call_http(params: FunctionCallParams) -> None: async def return_result(params: FunctionCallParams, result: dict) -> None:
if not policy.runs_llm_after_result:
await params.result_callback(
result,
properties=FunctionCallResultProperties(run_llm=False),
)
else:
await params.result_callback(result)
async def call_tool(params: FunctionCallParams) -> None:
try: try:
result = await self._tools.execute(tool, dict(params.arguments or {})) result = await self._tools.execute(tool, dict(params.arguments or {}))
if result["updated_variables"]: if result["updated_variables"]:
self._refresh_prompt() self._refresh_prompt()
await params.result_callback(result) await return_result(params, result)
except (ToolExecutionError, ValueError) as exc: except (ToolExecutionError, ValueError) as exc:
await params.result_callback( await return_result(
{"status": "error", "message": f"工具调用失败: {exc}"} params,
{"status": "error", "message": f"工具调用失败: {exc}"},
) )
schema = FunctionSchema( schema = FunctionSchema(
@@ -117,7 +135,7 @@ class PromptBrain(BaseBrain):
properties=properties, properties=properties,
required=required, required=required,
) )
return schema, call_http return schema, call_tool
def _make_end_call_tool(self, tool, runtime: BrainRuntime): def _make_end_call_tool(self, tool, runtime: BrainRuntime):
config = (tool.definition or {}).get("config") or {} config = (tool.definition or {}).get("config") or {}

View File

@@ -4,6 +4,7 @@ from __future__ import annotations
import asyncio import asyncio
from copy import deepcopy from copy import deepcopy
from dataclasses import replace
from typing import Any from typing import Any
from loguru import logger from loguru import logger
@@ -23,10 +24,15 @@ from pipecat.frames.frames import (
) )
from pipecat.processors.aggregators.llm_context import LLMContext from pipecat.processors.aggregators.llm_context import LLMContext
from pipecat.processors.frame_processor import FrameProcessor from pipecat.processors.frame_processor import FrameProcessor
from pipecat.services.llm_service import (
FunctionCallParams,
FunctionCallResultProperties,
)
from services.brains.base import BaseBrain, BrainRuntime, BrainSpec from services.brains.base import BaseBrain, BrainRuntime, BrainSpec
from services.knowledge import search as search_knowledge from services.knowledge import search as search_knowledge
from services.runtime_variables import DynamicVariableStore from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutionError, ToolExecutor from services.tool_executor import ToolExecutionError, ToolExecutor
from services.tool_policy import policy_for_tool
from services.workflow.agent import WorkflowAgentStage from services.workflow.agent import WorkflowAgentStage
from services.workflow.models import ( from services.workflow.models import (
RouteStatus, RouteStatus,
@@ -42,6 +48,38 @@ from services.workflow_router import WorkflowLLMRouter
MAX_AUTOMATIC_HOPS = 50 MAX_AUTOMATIC_HOPS = 50
class ConfiguredFlowManager(FlowManager):
"""Preserve Flow transitions while suppressing late async-tool replies."""
async def _create_transition_func(self, name, handler):
transition = await super()._create_transition_func(name, handler)
if not getattr(handler, "_suppress_followup_llm", False):
return transition
async def configured_transition(params: FunctionCallParams) -> None:
original_callback = params.result_callback
async def result_callback(result, *, properties=None):
if properties and properties.on_context_updated:
# Deterministic Workflow transitions already use run_llm=False
# and must retain their context-updated callback.
configured_properties = properties
elif properties:
configured_properties = replace(properties, run_llm=False)
else:
configured_properties = FunctionCallResultProperties(
run_llm=False
)
await original_callback(
result,
properties=configured_properties,
)
await transition(replace(params, result_callback=result_callback))
return configured_transition
class WorkflowBrain(BaseBrain): class WorkflowBrain(BaseBrain):
spec = BrainSpec( spec = BrainSpec(
type="workflow", type="workflow",
@@ -100,7 +138,7 @@ class WorkflowBrain(BaseBrain):
self._cfg = cfg self._cfg = cfg
self._runtime = runtime self._runtime = runtime
self._store = DynamicVariableStore.from_config(cfg) self._store = DynamicVariableStore.from_config(cfg)
self._tools = ToolExecutor(self._store) self._tools = ToolExecutor(self._store, client_tools=runtime.client_tools)
self._tool_by_id = {tool.id: tool for tool in cfg.tools} self._tool_by_id = {tool.id: tool for tool in cfg.tools}
self._router = WorkflowLLMRouter(cfg) self._router = WorkflowLLMRouter(cfg)
self._edge_evaluator = WorkflowEdgeEvaluator( self._edge_evaluator = WorkflowEdgeEvaluator(
@@ -120,7 +158,7 @@ class WorkflowBrain(BaseBrain):
self._ended = False self._ended = False
self._greeting_context_message = None self._greeting_context_message = None
self._startup_waiting_for_greeting = False self._startup_waiting_for_greeting = False
self._manager = FlowManager( self._manager = ConfiguredFlowManager(
worker=runtime.worker, worker=runtime.worker,
llm=runtime.llm, llm=runtime.llm,
context_aggregator=runtime.context_aggregator, context_aggregator=runtime.context_aggregator,
@@ -328,7 +366,7 @@ class WorkflowBrain(BaseBrain):
functions: list[FlowsFunctionSchema] = [] functions: list[FlowsFunctionSchema] = []
for tool_id in stage.tool_ids: for tool_id in stage.tool_ids:
tool = self._tool_by_id.get(str(tool_id)) tool = self._tool_by_id.get(str(tool_id))
if tool and tool.type in {"http", "mcp"}: if tool and tool.type in {"http", "mcp", "client"}:
functions.append(self._flow_tool(tool, node_id)) functions.append(self._flow_tool(tool, node_id))
knowledge_function = self._knowledge_function(node_id) knowledge_function = self._knowledge_function(node_id)
if knowledge_function: if knowledge_function:
@@ -409,6 +447,7 @@ class WorkflowBrain(BaseBrain):
def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema: def _flow_tool(self, tool: RuntimeTool, node_id: str) -> FlowsFunctionSchema:
properties, required = self._tools.schema_parts(tool) properties, required = self._tools.schema_parts(tool)
self._tools.register_secrets(tool) self._tools.register_secrets(tool)
policy = policy_for_tool(tool)
async def handler(args, _flow_manager): async def handler(args, _flow_manager):
transition_id = self._state.transition_id transition_id = self._state.transition_id
@@ -456,13 +495,21 @@ class WorkflowBrain(BaseBrain):
) )
return result return result
if not policy.runs_llm_after_result:
setattr(handler, "_suppress_followup_llm", True)
return FlowsFunctionSchema( return FlowsFunctionSchema(
name=tool.function_name, name=tool.function_name,
description=tool.description or f"调用 {tool.name}", description=tool.description or f"调用 {tool.name}",
properties=properties, properties=properties,
required=required, required=required,
handler=handler, handler=handler,
cancel_on_interruption=True, cancel_on_interruption=policy.cancel_on_interruption,
timeout_secs=(
float(((tool.definition or {}).get("config") or {}).get("timeout_seconds") or 3)
if tool.type == "client"
else None
),
) )
def _flow_managed_transition_config( def _flow_managed_transition_config(

View File

@@ -0,0 +1,136 @@
"""Conversation-scoped bridge for tools implemented by the connected client."""
from __future__ import annotations
import asyncio
from typing import Any, Protocol
from loguru import logger
from pipecat.frames.frames import (
EndFrame,
InputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
class ClientToolError(RuntimeError):
"""Raised when a client tool cannot be delivered or completed."""
class ClientToolPort(Protocol):
async def call(
self,
function_name: str,
arguments: dict[str, Any],
*,
timeout_seconds: float,
wait_for_response: bool = True,
) -> dict[str, Any]: ...
class ClientToolBroker(FrameProcessor):
"""Send client tool calls and correlate their app-message results."""
def __init__(self) -> None:
super().__init__()
self._pending: dict[str, asyncio.Future[dict[str, Any]]] = {}
async def call(
self,
function_name: str,
arguments: dict[str, Any],
*,
timeout_seconds: float,
wait_for_response: bool = True,
) -> dict[str, Any]:
from uuid import uuid4
tool_call_id = f"client_{uuid4().hex}"
message = {
"type": "client-tool-call",
"tool_call_id": tool_call_id,
"function_name": function_name,
"arguments": dict(arguments),
"wait_for_response": wait_for_response,
}
if not wait_for_response:
try:
await self.push_frame(
OutputTransportMessageUrgentFrame(message=message)
)
except Exception as exc:
raise ClientToolError(
f"客户端工具调用发送失败: {function_name}"
) from exc
return {
"status": "ok",
"data": {"dispatched": True},
}
loop = asyncio.get_running_loop()
future: asyncio.Future[dict[str, Any]] = loop.create_future()
self._pending[tool_call_id] = future
try:
await self.push_frame(
OutputTransportMessageUrgentFrame(message=message)
)
return await asyncio.wait_for(future, timeout=timeout_seconds)
except TimeoutError as exc:
raise ClientToolError(f"客户端工具调用超时: {function_name}") from exc
except ClientToolError:
raise
except Exception as exc:
raise ClientToolError(f"客户端工具调用失败: {function_name}") from exc
finally:
self._pending.pop(tool_call_id, None)
async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction)
if isinstance(frame, EndFrame):
self._fail_pending("会话已结束")
await self.push_frame(frame, direction)
return
if not isinstance(frame, InputTransportMessageFrame):
await self.push_frame(frame, direction)
return
message = frame.message
if not isinstance(message, dict) or message.get("type") != "client-tool-result":
await self.push_frame(frame, direction)
return
tool_call_id = str(message.get("tool_call_id") or "")
future = self._pending.get(tool_call_id)
if future is None or future.done():
logger.debug(f"忽略未知或过期的客户端工具结果: {tool_call_id}")
return
status = str(message.get("status") or "error")
if status == "ok":
future.set_result(
{
"status": "ok",
"data": message.get("data"),
}
)
else:
future.set_result(
{
"status": "error",
"message": str(message.get("message") or "客户端工具执行失败"),
"data": message.get("data"),
}
)
def _fail_pending(self, message: str) -> None:
for future in self._pending.values():
if not future.done():
future.set_exception(ClientToolError(message))
self._pending.clear()
async def cleanup(self) -> None:
self._fail_pending("客户端工具通道已关闭")
await super().cleanup()

View File

@@ -30,6 +30,8 @@ from services.pipecat.service_factory import (
) )
from db.session import SessionLocal from db.session import SessionLocal
from services.knowledge import search as search_knowledge from services.knowledge import search as search_knowledge
from services.client_tools import ClientToolBroker
from services.tool_policy import policy_for_tool
from services.vision import ( from services.vision import (
VISION_ANALYSIS_SYSTEM_PROMPT, VISION_ANALYSIS_SYSTEM_PROMPT,
VISION_SYSTEM_HINT, VISION_SYSTEM_HINT,
@@ -44,6 +46,7 @@ from pipecat.adapters.schemas.tools_schema import ToolsSchema
from pipecat.flows import FlowsFunctionSchema from pipecat.flows import FlowsFunctionSchema
from pipecat.frames.frames import ( from pipecat.frames.frames import (
EndFrame, EndFrame,
LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame, OutputTransportMessageUrgentFrame,
UserImageRawFrame, UserImageRawFrame,
UserImageRequestFrame, UserImageRequestFrame,
@@ -57,9 +60,6 @@ from pipecat.processors.aggregators.llm_response_universal import (
) )
from pipecat.processors.frame_processor import FrameDirection, FrameProcessor from pipecat.processors.frame_processor import FrameDirection, FrameProcessor
from pipecat.services.llm_service import FunctionCallParams from pipecat.services.llm_service import FunctionCallParams
from pipecat.turns.user_mute.function_call_user_mute_strategy import (
FunctionCallUserMuteStrategy,
)
from services.pipecat.turn_config import ( from services.pipecat.turn_config import (
ConfigurableLLMUserAggregator, ConfigurableLLMUserAggregator,
create_user_turn_strategies, create_user_turn_strategies,
@@ -73,11 +73,13 @@ from services.pipecat.processors import (
KnowledgeRetrievalProcessor, KnowledgeRetrievalProcessor,
PassthroughLLMAssistantAggregator, PassthroughLLMAssistantAggregator,
RealtimeDynamicVariableProcessor, RealtimeDynamicVariableProcessor,
RealtimeTextInputProcessor, RealtimeUserInputProcessor,
TextInputProcessor, UserInput,
UserInputProcessor,
UserTurnRoutingProcessor, UserTurnRoutingProcessor,
VisionCaptureProcessor, VisionCaptureProcessor,
WorkflowAggregatorPair, WorkflowAggregatorPair,
ToolInterruptionUserMuteStrategy,
) )
from services.pipecat.workflow_services import ( from services.pipecat.workflow_services import (
WorkflowServiceController, WorkflowServiceController,
@@ -319,6 +321,14 @@ async def run_pipeline(
) )
) )
input_state = {"enabled": True} input_state = {"enabled": True}
tool_interruption_strategy = ToolInterruptionUserMuteStrategy(
{
tool.function_name: policy_for_tool(tool).execution_mode
for tool in cfg.tools
if tool.type in {"http", "mcp", "client"}
and not policy_for_tool(tool).allow_interruptions
}
)
# LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。 # LLM 槽由大脑提供:本地模型或 Dify/FastGPT 外部托管适配器。
llm = brain.build_llm( llm = brain.build_llm(
config_with_resource(cfg, default_llm_resource) config_with_resource(cfg, default_llm_resource)
@@ -335,10 +345,10 @@ async def run_pipeline(
params=LLMUserAggregatorParams( params=LLMUserAggregatorParams(
vad_analyzer=create_vad_analyzer(cfg.turnConfig), vad_analyzer=create_vad_analyzer(cfg.turnConfig),
user_mute_strategies=[ user_mute_strategies=[
FunctionCallUserMuteStrategy(),
CallEndingUserMuteStrategy( CallEndingUserMuteStrategy(
lambda: call_end.ending or not input_state["enabled"] lambda: call_end.ending or not input_state["enabled"]
), ),
tool_interruption_strategy,
], ],
user_turn_strategies=create_user_turn_strategies( user_turn_strategies=create_user_turn_strategies(
cfg.turnConfig, cfg.turnConfig,
@@ -348,9 +358,14 @@ async def run_pipeline(
) )
user_turn_router = UserTurnRoutingProcessor(brain) user_turn_router = UserTurnRoutingProcessor(brain)
assistant_aggregator = PassthroughLLMAssistantAggregator(context) assistant_aggregator = PassthroughLLMAssistantAggregator(context)
text_input = TextInputProcessor( user_input = UserInputProcessor(
should_ignore_input=lambda: call_end.ending or not input_state["enabled"] should_ignore_input=lambda: (
call_end.ending
or not input_state["enabled"]
or tool_interruption_strategy.is_muted
)
) )
client_tools = ClientToolBroker()
vision_capture = VisionCaptureProcessor() vision_capture = VisionCaptureProcessor()
knowledge_retrieval = KnowledgeRetrievalProcessor( knowledge_retrieval = KnowledgeRetrievalProcessor(
automatic_knowledge_id, automatic_knowledge_id,
@@ -471,6 +486,24 @@ async def run_pipeline(
flow_global_functions = [] flow_global_functions = []
workflow_vision_function = None workflow_vision_function = None
def active_vision_config() -> AssistantConfig:
if cfg.type != "workflow":
return cfg
if not workflow_vision_scope.get("enabled"):
raise ValueError("当前 Workflow Agent 节点未启用视觉能力")
vision_resource_id = str(
workflow_vision_scope.get("vision_model_resource_id") or ""
)
llm_resource_id = str(workflow_vision_scope.get("llm_resource_id") or "")
resource_id = vision_resource_id or llm_resource_id
resource = cfg.workflow_model_resources.get(resource_id)
if resource:
return config_with_vision_resource(cfg, resource)
if not vision_resource_id:
return config_with_main_llm_as_vision(cfg)
raise ValueError(f"视觉模型资源未加载:{vision_resource_id}")
if cfg.type == "workflow" and vision_enabled: if cfg.type == "workflow" and vision_enabled:
async def flow_fetch_user_image(args, _flow_manager): async def flow_fetch_user_image(args, _flow_manager):
if not workflow_vision_scope.get("enabled"): if not workflow_vision_scope.get("enabled"):
@@ -492,23 +525,9 @@ async def run_pipeline(
function_name=VISION_TOOL_NAME, function_name=VISION_TOOL_NAME,
) )
try: try:
vision_resource_id = str(
workflow_vision_scope.get("vision_model_resource_id") or ""
)
llm_resource_id = str(
workflow_vision_scope.get("llm_resource_id") or ""
)
resource_id = vision_resource_id or llm_resource_id
resource = cfg.workflow_model_resources.get(resource_id)
if resource:
vision_cfg = config_with_vision_resource(cfg, resource)
elif not vision_resource_id:
vision_cfg = config_with_main_llm_as_vision(cfg)
else:
raise ValueError(f"视觉模型资源未加载:{vision_resource_id}")
frame = await vision_capture.request_image(llm, request) frame = await vision_capture.request_image(llm, request)
observation = await _analyze_image_with_vision_model( observation = await _analyze_image_with_vision_model(
vision_cfg, active_vision_config(),
frame, frame,
question, question,
) )
@@ -558,8 +577,9 @@ async def run_pipeline(
pipeline = Pipeline( pipeline = Pipeline(
[ [
transport.input(), transport.input(),
client_tools,
vision_capture, vision_capture,
text_input, user_input,
stt_processor, stt_processor,
user_aggregator, user_aggregator,
user_turn_router, user_turn_router,
@@ -638,6 +658,7 @@ async def run_pipeline(
set_system_prompt=set_system_prompt, set_system_prompt=set_system_prompt,
set_tools=set_visible_tools, set_tools=set_visible_tools,
call_end=call_end, call_end=call_end,
client_tools=client_tools,
worker=worker, worker=worker,
context_aggregator=WorkflowAggregatorPair( context_aggregator=WorkflowAggregatorPair(
user_aggregator, user_aggregator,
@@ -654,17 +675,78 @@ async def run_pipeline(
), ),
) )
async def submit_user_input(value: UserInput) -> None:
if not value.has_camera_frame:
if not value.run_immediately:
brain.record_user_message(value.text)
await worker.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "user", "content": value.text}],
run_llm=value.run_immediately,
)
)
return
if not value.run_immediately:
raise ValueError("P0 图片输入必须立即触发回复")
if not vision_enabled:
raise ValueError("当前助手未启用视觉能力")
user_id = vision_state.get("client_id")
if not user_id:
raise ValueError("当前没有可用的摄像头视频流")
analysis_cfg = None if vision_native_mode else active_vision_config()
request = UserImageRequestFrame(
user_id=user_id,
text=value.prompt_text,
append_to_context=False,
)
try:
image_frame = await vision_capture.request_image(llm, request)
except asyncio.TimeoutError as exc:
raise ValueError("等待摄像头视频帧超时") from exc
if vision_native_mode:
image_frame.text = value.prompt_text
image_frame.append_to_context = True
image_frame.request = None
await worker.queue_frame(image_frame)
return
try:
assert analysis_cfg is not None
observation = await _analyze_image_with_vision_model(
analysis_cfg,
image_frame,
value.prompt_text,
)
except Exception as exc:
logger.exception(f"用户图片视觉分析失败: {exc}")
raise ValueError("视觉理解暂时不可用") from exc
content = (
f"{value.prompt_text}\n\n"
"[视觉模型对用户刚提交图片的观察]\n"
f"{observation or '视觉模型没有返回有效观察结果。'}"
)
await worker.queue_frame(
LLMMessagesAppendFrame(
messages=[{"role": "user", "content": content}],
run_llm=True,
)
)
bind_cascade_pipeline_events( bind_cascade_pipeline_events(
transport=transport, transport=transport,
worker=worker, worker=worker,
brain=brain, brain=brain,
context=context, context=context,
text_input=text_input, text_input=user_input,
user_aggregator=user_aggregator, user_aggregator=user_aggregator,
assistant_aggregator=assistant_aggregator, assistant_aggregator=assistant_aggregator,
greeting=greeting, greeting=greeting,
vision_enabled=vision_enabled, vision_enabled=vision_enabled,
vision_state=vision_state, vision_state=vision_state,
submit_user_input=submit_user_input,
) )
runner = WorkerRunner(handle_sigint=False) runner = WorkerRunner(handle_sigint=False)
run_status = "completed" run_status = "completed"
@@ -694,7 +776,7 @@ async def run_realtime_pipeline(
instructions=brain.system_prompt(cfg), instructions=brain.system_prompt(cfg),
) )
input_sample_rate, output_sample_rate = realtime_audio_sample_rates(cfg) input_sample_rate, output_sample_rate = realtime_audio_sample_rates(cfg)
text_input = RealtimeTextInputProcessor() user_input = RealtimeUserInputProcessor()
dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime) dynamic_variables = RealtimeDynamicVariableProcessor(brain, cfg, realtime)
greeting = await brain.greeting(cfg) greeting = await brain.greeting(cfg)
@@ -708,7 +790,7 @@ async def run_realtime_pipeline(
pipeline = Pipeline( pipeline = Pipeline(
[ [
transport.input(), transport.input(),
text_input, user_input,
realtime, realtime,
dynamic_variables, dynamic_variables,
ConversationHistoryProcessor(recorder), ConversationHistoryProcessor(recorder),
@@ -729,7 +811,7 @@ async def run_realtime_pipeline(
transport=transport, transport=transport,
worker=worker, worker=worker,
realtime=realtime, realtime=realtime,
text_input=text_input, text_input=user_input,
greeting=greeting, greeting=greeting,
) )
runner = WorkerRunner(handle_sigint=False) runner = WorkerRunner(handle_sigint=False)

View File

@@ -1,12 +1,13 @@
"""Event registration for cascade and realtime conversation pipelines.""" """Event registration for cascade and realtime conversation pipelines."""
from collections.abc import Awaitable, Callable
from loguru import logger from loguru import logger
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStartedSpeakingFrame, BotStartedSpeakingFrame,
BotStoppedSpeakingFrame, BotStoppedSpeakingFrame,
EndFrame, EndFrame,
LLMMessagesAppendFrame,
OutputTransportMessageUrgentFrame, OutputTransportMessageUrgentFrame,
TTSSpeakFrame, TTSSpeakFrame,
) )
@@ -15,6 +16,7 @@ from pipecat.runner.utils import (
maybe_capture_participant_camera, maybe_capture_participant_camera,
) )
from pipecat.utils.time import time_now_iso8601 from pipecat.utils.time import time_now_iso8601
from services.pipecat.processors import UserInput
def bind_cascade_pipeline_events( def bind_cascade_pipeline_events(
@@ -29,10 +31,11 @@ def bind_cascade_pipeline_events(
greeting: str, greeting: str,
vision_enabled: bool, vision_enabled: bool,
vision_state: dict[str, str | None], vision_state: dict[str, str | None],
submit_user_input: Callable[[UserInput], Awaitable[None]] | None = None,
) -> None: ) -> None:
"""Connect processors to transport events without owning pipeline assembly.""" """Connect processors to transport events without owning pipeline assembly."""
pending_text_inputs: list[str] = [] pending_user_inputs: list[UserInput] = []
greeting_transcript_sent = False greeting_transcript_sent = False
greeting_timestamp = "" greeting_timestamp = ""
greeting_playback_pending = False greeting_playback_pending = False
@@ -72,14 +75,33 @@ def bind_cascade_pipeline_events(
) )
) )
async def append_user_text_to_context(text: str, *, run_llm: bool) -> None: async def queue_input_result(
user_input: UserInput,
status: str,
message: str = "",
) -> None:
await worker.queue_frame( await worker.queue_frame(
LLMMessagesAppendFrame( OutputTransportMessageUrgentFrame(
messages=[{"role": "user", "content": text}], message={
run_llm=run_llm, "type": "user-input-result",
"input_id": user_input.input_id,
"status": status,
**({"message": message} if message else {}),
}
) )
) )
async def finish_user_input(user_input: UserInput) -> None:
try:
if submit_user_input is None:
raise RuntimeError("用户输入提交器尚未配置")
await submit_user_input(user_input)
except Exception as exc: # noqa: BLE001 - input errors must reach the client
logger.warning(f"用户输入处理失败: {exc}")
await queue_input_result(user_input, "error", str(exc))
return
await queue_input_result(user_input, "accepted")
@user_aggregator.event_handler("on_user_turn_stopped") @user_aggregator.event_handler("on_user_turn_stopped")
async def on_user_turn_stopped(_aggregator, _strategy, message): async def on_user_turn_stopped(_aggregator, _strategy, message):
await queue_transcript("user", message.content, message.timestamp) await queue_transcript("user", message.content, message.timestamp)
@@ -123,24 +145,23 @@ def bind_cascade_pipeline_events(
) )
await brain.on_assistant_text_end(turn_id, content, interrupted) await brain.on_assistant_text_end(turn_id, content, interrupted)
@text_input.event_handler("on_text_input") @text_input.event_handler("on_user_input")
async def on_text_input(_processor, text): async def on_user_input(_processor, user_input: UserInput):
pending_text_inputs.append(text) await queue_transcript(
# The transcript must be queued before the interruption is broadcast. "user",
await queue_transcript("user", text, time_now_iso8601()) user_input.transcript_text,
time_now_iso8601(),
)
if user_input.run_immediately and user_input.interrupt:
pending_user_inputs.append(user_input)
return
await finish_user_input(user_input)
@assistant_aggregator.event_handler("on_interruption_processed") @assistant_aggregator.event_handler("on_interruption_processed")
async def on_interruption_processed(_aggregator): async def on_interruption_processed(_aggregator):
if not pending_text_inputs: if not pending_user_inputs:
return return
text = pending_text_inputs.pop(0) await finish_user_input(pending_user_inputs.pop(0))
await append_user_text_to_context(text, run_llm=True)
@text_input.event_handler("on_text_append")
async def on_text_append(_processor, text):
brain.record_user_message(text)
await queue_transcript("user", text, time_now_iso8601())
await append_user_text_to_context(text, run_llm=False)
@text_input.event_handler("on_client_ready") @text_input.event_handler("on_client_ready")
async def on_client_ready(_processor): async def on_client_ready(_processor):
@@ -218,16 +239,24 @@ def bind_realtime_pipeline_events(
) )
) )
@text_input.event_handler("on_text_input") @text_input.event_handler("on_user_input")
async def on_text_input(_processor, text): async def on_user_input(_processor, user_input: UserInput):
await queue_transcript("user", text) await queue_transcript("user", user_input.text)
await realtime.interrupt() if user_input.run_immediately and user_input.interrupt:
await realtime.send_text(text, run_immediately=True) await realtime.interrupt()
await realtime.send_text(
@text_input.event_handler("on_text_append") user_input.text,
async def on_text_append(_processor, text): run_immediately=user_input.run_immediately,
await queue_transcript("user", text) )
await realtime.send_text(text, run_immediately=False) await worker.queue_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "user-input-result",
"input_id": user_input.input_id,
"status": "accepted",
}
)
)
@transport.event_handler("on_client_connected") @transport.event_handler("on_client_connected")
async def on_client_connected(_transport, _client): async def on_client_connected(_transport, _client):

View File

@@ -2,6 +2,7 @@
import asyncio import asyncio
from collections.abc import Callable from collections.abc import Callable
from dataclasses import dataclass
from uuid import uuid4 from uuid import uuid4
from loguru import logger from loguru import logger
@@ -12,6 +13,11 @@ from services.knowledge import search as search_knowledge
from db.session import SessionLocal from db.session import SessionLocal
from pipecat.frames.frames import ( from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallCancelFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
InputTransportMessageFrame, InputTransportMessageFrame,
InterruptionFrame, InterruptionFrame,
LLMContextFrame, LLMContextFrame,
@@ -34,42 +40,120 @@ from pipecat.utils.time import time_now_iso8601
KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->" KNOWLEDGE_CONTEXT_MARKER = "<!-- knowledge-context -->"
def _text_input(message) -> tuple[str, bool] | None: @dataclass(frozen=True)
"""解析现有 user-text 与 RTVI send-text 两种前端文字消息。""" class UserInput:
"""Validated application input submitted as one user turn."""
input_id: str
text: str
has_camera_frame: bool
run_immediately: bool
interrupt: bool
@property
def prompt_text(self) -> str:
if self.text:
return self.text
return "请根据用户刚提交的图片进行回复。"
@property
def transcript_text(self) -> str:
if not self.has_camera_frame:
return self.text
return f"{self.text}\n已发送一张图片".strip()
class UserInputError(ValueError):
def __init__(self, message: str, *, input_id: str = "") -> None:
super().__init__(message)
self.input_id = input_id
def parse_user_input(message) -> UserInput | None:
"""Parse the sole public user-input wire format."""
if not isinstance(message, dict): if not isinstance(message, dict):
return None return None
if message.get("type") == "user-text": if message.get("type") != "user-input":
text = str(message.get("text") or "").strip() return None
return (text, True) if text else None
if message.get("type") == "send-text": input_id = str(message.get("input_id") or "").strip()
data = message.get("data") if not input_id or len(input_id) > 128:
if not isinstance(data, dict): raise UserInputError("user-input 缺少有效的 input_id", input_id=input_id)
return None if message.get("schema_version") != 1:
text = str(data.get("content") or "").strip() raise UserInputError("不支持的 user-input schema_version", input_id=input_id)
options = data.get("options")
run_immediately = not isinstance(options, dict) or options.get( parts = message.get("parts")
"run_immediately", True if not isinstance(parts, list) or not parts:
) raise UserInputError("user-input parts 不能为空", input_id=input_id)
return (text, bool(run_immediately)) if text else None
return None text = ""
has_camera_frame = False
for part in parts:
if not isinstance(part, dict):
raise UserInputError("user-input part 格式不正确", input_id=input_id)
part_type = part.get("type")
if part_type == "input_text":
if text:
raise UserInputError("P0 只支持一个 input_text", input_id=input_id)
text = str(part.get("text") or "").strip()
if not text:
raise UserInputError("input_text 不能为空", input_id=input_id)
elif part_type == "input_image":
if has_camera_frame:
raise UserInputError("P0 只支持一张图片", input_id=input_id)
source = part.get("source")
if (
not isinstance(source, dict)
or source.get("type") != "camera_frame"
or source.get("frame") != "current"
):
raise UserInputError(
"P0 只支持当前摄像头画面",
input_id=input_id,
)
has_camera_frame = True
else:
raise UserInputError(f"不支持的输入类型: {part_type}", input_id=input_id)
if not text and not has_camera_frame:
raise UserInputError("user-input 没有有效内容", input_id=input_id)
options = message.get("options")
options = options if isinstance(options, dict) else {}
run_immediately = bool(options.get("run_immediately", True))
interrupt = bool(options.get("interrupt", True))
return UserInput(
input_id=input_id,
text=text,
has_camera_frame=has_camera_frame,
run_immediately=run_immediately,
interrupt=interrupt,
)
class TextInputProcessor(FrameProcessor): def message_text(message: dict) -> str:
"""transport 文字消息转换成 LLM 可消费的帧。 """Extract textual content from plain or multimodal LLM messages."""
content = message.get("content")
if isinstance(content, str):
return content.strip()
if not isinstance(content, list):
return ""
return "\n".join(
str(part.get("text") or "").strip()
for part in content
if isinstance(part, dict)
and part.get("type") in {"text", "input_text"}
and str(part.get("text") or "").strip()
).strip()
run_immediately(默认/打断):先通过 on_text_input 事件把用户文字交给
run_pipeline 登记,再用 broadcast_interruption() 打断当前播报。新的 LLM class UserInputProcessor(FrameProcessor):
回复由 assistant aggregator 确认处理完 interruption 后触发。 """Validate app inputs and coordinate their interruption boundary."""
run_immediately=False(RTVI send-text 静默追加):仅把文字写进上下文,
不打断、不触发推理。
"""
def __init__(self, should_ignore_input: Callable[[], bool] | None = None): def __init__(self, should_ignore_input: Callable[[], bool] | None = None):
super().__init__() super().__init__()
self._should_ignore_input = should_ignore_input or (lambda: False) self._should_ignore_input = should_ignore_input or (lambda: False)
# 立即触发的文字(含打断语义)走 on_text_input;静默追加另走一条事件 self._register_event_handler("on_user_input")
self._register_event_handler("on_text_input")
self._register_event_handler("on_text_append")
self._register_event_handler("on_client_ready") self._register_event_handler("on_client_ready")
async def process_frame(self, frame, direction: FrameDirection): async def process_frame(self, frame, direction: FrameDirection):
@@ -83,23 +167,35 @@ class TextInputProcessor(FrameProcessor):
await self._call_event_handler("on_client_ready") await self._call_event_handler("on_client_ready")
return return
parsed = _text_input(frame.message) try:
if not parsed: user_input = parse_user_input(frame.message)
except UserInputError as exc:
await self._emit_result(exc.input_id, "error", str(exc))
return
if user_input is None:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
return return
if self._should_ignore_input(): if self._should_ignore_input():
logger.debug("通话正在结束,忽略后续文字输入") logger.debug("通话正在结束,忽略后续文字输入")
await self._emit_result(user_input.input_id, "error", "当前不能接收新的用户输入")
return return
text, run_immediately = parsed await self._call_event_handler("on_user_input", user_input)
if run_immediately: if user_input.run_immediately and user_input.interrupt:
# 先登记文字再打断。下一轮 LLM 由 assistant aggregator 在真正处理完
# InterruptionFrame 后触发,避免新回复被这次 interruption 一起取消。
await self._call_event_handler("on_text_input", text)
await self.broadcast_interruption() await self.broadcast_interruption()
else:
await self._call_event_handler("on_text_append", text) async def _emit_result(self, input_id: str, status: str, message: str = "") -> None:
await self.push_frame(
OutputTransportMessageUrgentFrame(
message={
"type": "user-input-result",
"input_id": input_id,
"status": status,
**({"message": message} if message else {}),
}
)
)
class CallEndingUserMuteStrategy(BaseUserMuteStrategy): class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
@@ -114,6 +210,84 @@ class CallEndingUserMuteStrategy(BaseUserMuteStrategy):
return self._is_call_ending() return self._is_call_ending()
@dataclass
class _BlockedToolCall:
tool_finished: bool = False
response_started: bool = False
response_finished: bool = False
class ToolInterruptionUserMuteStrategy(BaseUserMuteStrategy):
"""Mute user media for selected tools and their associated bot response.
Pipecat's ``cancel_on_interruption`` also controls whether a tool is
synchronous or asynchronous. This strategy keeps the independent product
setting ("allow user interruptions") at the user-input boundary.
"""
def __init__(
self,
blocked_tools: dict[str, str] | set[str],
):
super().__init__()
self._blocked_tools = (
dict(blocked_tools)
if isinstance(blocked_tools, dict)
else {name: "immediate" for name in blocked_tools}
)
self._calls: dict[str, _BlockedToolCall] = {}
self._bot_speaking = False
@property
def is_muted(self) -> bool:
return bool(self._calls)
async def process_frame(self, frame) -> bool:
await super().process_frame(frame)
if isinstance(frame, BotStartedSpeakingFrame):
self._bot_speaking = True
for call in self._calls.values():
call.response_started = True
elif isinstance(frame, BotStoppedSpeakingFrame):
self._bot_speaking = False
for call in self._calls.values():
if call.response_started:
call.response_finished = True
self._release_completed()
elif isinstance(frame, FunctionCallsStartedFrame):
for call in frame.function_calls:
execution_mode = self._blocked_tools.get(call.function_name)
if execution_mode:
self._calls[call.tool_call_id] = _BlockedToolCall(
# An async tool may continue within the current model
# turn. An immediate tool's current speech is a preamble;
# its protected follow-up starts only after the result.
response_started=(
self._bot_speaking and execution_mode == "async"
),
)
elif isinstance(frame, FunctionCallResultFrame):
call = self._calls.get(frame.tool_call_id)
if call is not None:
call.tool_finished = True
self._release_completed()
elif isinstance(frame, FunctionCallCancelFrame):
# A canceled call cannot reliably produce a follow-up response.
self._calls.pop(frame.tool_call_id, None)
return self.is_muted
def _release_completed(self) -> None:
completed = [
call_id
for call_id, state in self._calls.items()
if state.tool_finished and state.response_finished
]
for call_id in completed:
self._calls.pop(call_id, None)
class VisionCaptureProcessor(FrameProcessor): class VisionCaptureProcessor(FrameProcessor):
"""Capture one requested video frame for auxiliary vision-model analysis.""" """Capture one requested video frame for auxiliary vision-model analysis."""
@@ -193,13 +367,12 @@ class RealtimeDynamicVariableProcessor(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
class RealtimeTextInputProcessor(FrameProcessor): class RealtimeUserInputProcessor(FrameProcessor):
"""Route text input directly to a realtime service without cascade semantics.""" """Route text-only user-input messages to a realtime service."""
def __init__(self): def __init__(self):
super().__init__() super().__init__()
self._register_event_handler("on_text_input") self._register_event_handler("on_user_input")
self._register_event_handler("on_text_append")
async def process_frame(self, frame, direction: FrameDirection): async def process_frame(self, frame, direction: FrameDirection):
await super().process_frame(frame, direction) await super().process_frame(frame, direction)
@@ -208,15 +381,32 @@ class RealtimeTextInputProcessor(FrameProcessor):
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
return return
parsed = _text_input(frame.message) try:
if not parsed: user_input = parse_user_input(frame.message)
except UserInputError as exc:
await self._emit_error(exc.input_id, str(exc))
return
if user_input is None:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
return return
if user_input.has_camera_frame:
await self._emit_error(
user_input.input_id,
"Realtime 模式暂不支持图片输入",
)
return
await self._call_event_handler("on_user_input", user_input)
text, run_immediately = parsed async def _emit_error(self, input_id: str, message: str) -> None:
await self._call_event_handler( await self.push_frame(
"on_text_input" if run_immediately else "on_text_append", OutputTransportMessageUrgentFrame(
text, message={
"type": "user-input-result",
"input_id": input_id,
"status": "error",
"message": message,
}
)
) )
@@ -304,7 +494,7 @@ class KnowledgeRetrievalProcessor(FrameProcessor):
if not user_messages: if not user_messages:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
return return
query = str(user_messages[-1].get("content") or "").strip() query = message_text(user_messages[-1])
signature = f"{len(user_messages)}:{query}" signature = f"{len(user_messages)}:{query}"
if not query or signature == self._last_signature: if not query or signature == self._last_signature:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -354,8 +544,7 @@ class UserTurnRoutingProcessor(FrameProcessor):
message message
for message in reversed(frame.context.get_messages()) for message in reversed(frame.context.get_messages())
if message.get("role") == "user" if message.get("role") == "user"
and isinstance(message.get("content"), str) and message_text(message)
and str(message.get("content") or "").strip()
), ),
None, None,
) )
@@ -370,7 +559,7 @@ class UserTurnRoutingProcessor(FrameProcessor):
return return
self._last_user_message = user_message self._last_user_message = user_message
content = str(user_message.get("content") or "").strip() content = message_text(user_message)
handled = await self._brain.on_user_turn_end(content) handled = await self._brain.on_user_turn_end(content)
if not handled: if not handled:
await self.push_frame(frame, direction) await self.push_frame(frame, direction)
@@ -447,6 +636,3 @@ class WorkflowAggregatorPair:
def assistant(self): def assistant(self):
return self._assistant return self._assistant

View File

@@ -14,6 +14,8 @@ from services.runtime_variables import (
DynamicVariableStore, DynamicVariableStore,
value_at_path, value_at_path,
) )
from services.client_tools import ClientToolError, ClientToolPort
from services.tool_policy import policy_for_tool
from services.tools import McpClientError, McpToolClient from services.tools import McpClientError, McpToolClient
@@ -29,9 +31,14 @@ class ToolExecutor:
store: DynamicVariableStore, store: DynamicVariableStore,
*, *,
mcp_client: McpToolClient | None = None, mcp_client: McpToolClient | None = None,
client_tools: ClientToolPort | None = None,
): ):
self.store = store self.store = store
self._mcp_client = mcp_client or McpToolClient() self._mcp_client = mcp_client or McpToolClient()
self._client_tools = client_tools
def set_client_tools(self, client_tools: ClientToolPort | None) -> None:
self._client_tools = client_tools
def register_secrets(self, tool: RuntimeTool) -> None: def register_secrets(self, tool: RuntimeTool) -> None:
dynamic = (tool.secrets or {}).get("dynamic_variables") or {} dynamic = (tool.secrets or {}).get("dynamic_variables") or {}
@@ -78,8 +85,12 @@ class ToolExecutor:
result = await self._execute_http(tool, normalized_arguments) result = await self._execute_http(tool, normalized_arguments)
elif tool.type == "mcp": elif tool.type == "mcp":
result = await self._execute_mcp(tool, normalized_arguments) result = await self._execute_mcp(tool, normalized_arguments)
elif tool.type == "client":
result = await self._execute_client(tool, normalized_arguments)
else: else:
raise ToolExecutionError(f"不支持工具类型: {tool.type}") raise ToolExecutionError(f"不支持工具类型: {tool.type}")
if result.get("status") != "ok":
return {**result, "updated_variables": []}
return self._apply_result_assignments( return self._apply_result_assignments(
tool, tool,
result, result,
@@ -187,6 +198,25 @@ class ToolExecutor:
raise ToolExecutionError(str(exc)) from exc raise ToolExecutionError(str(exc)) from exc
return {"status": "ok", "data": payload} return {"status": "ok", "data": payload}
async def _execute_client(
self,
tool: RuntimeTool,
arguments: dict[str, Any],
) -> dict[str, Any]:
if self._client_tools is None:
raise ToolExecutionError("当前运行模式不支持客户端工具")
config = (tool.definition or {}).get("config") or {}
policy = policy_for_tool(tool)
try:
return await self._client_tools.call(
tool.function_name,
arguments,
timeout_seconds=float(config.get("timeout_seconds") or 3),
wait_for_response=policy.wait_for_response,
)
except ClientToolError as exc:
raise ToolExecutionError(str(exc)) from exc
def _apply_result_assignments( def _apply_result_assignments(
self, self,
tool: RuntimeTool, tool: RuntimeTool,

View File

@@ -0,0 +1,45 @@
"""Runtime behavior shared by HTTP, MCP, and client tools.
The stored definitions are intentionally plain dictionaries. Keeping policy
normalization here gives old definitions stable defaults without spreading
fallback rules across PromptBrain, WorkflowBrain, and the pipeline.
"""
from __future__ import annotations
from dataclasses import dataclass
from models import RuntimeTool
@dataclass(frozen=True)
class ToolRuntimePolicy:
"""Normalized interaction policy for one reusable tool."""
allow_interruptions: bool
execution_mode: str
wait_for_response: bool
@property
def cancel_on_interruption(self) -> bool:
"""Pipecat uses this flag to distinguish waiting from async tools."""
return self.execution_mode == "immediate"
@property
def runs_llm_after_result(self) -> bool:
"""Late async results update context without starting another reply."""
return self.execution_mode == "immediate"
def policy_for_tool(tool: RuntimeTool) -> ToolRuntimePolicy:
"""Read policy with backwards-compatible defaults per tool kind."""
config = (tool.definition or {}).get("config") or {}
default_mode = "async" if tool.type == "client" else "immediate"
mode = str(config.get("execution_mode") or default_mode)
if mode not in {"immediate", "async"}:
mode = default_mode
return ToolRuntimePolicy(
allow_interruptions=bool(config.get("allow_interruptions", True)),
execution_mode=mode,
wait_for_response=bool(config.get("wait_for_response", True)),
)

View File

@@ -34,9 +34,11 @@ from services.workflow.models import LLMRouteResult, RouteStatus
class FakeLLM: class FakeLLM:
def __init__(self): def __init__(self):
self.functions = {} self.functions = {}
self.function_options = {}
def register_function(self, name, handler): def register_function(self, name, handler, **options):
self.functions[name] = handler self.functions[name] = handler
self.function_options[name] = options
class FakeCallEnd: class FakeCallEnd:

View File

@@ -0,0 +1,157 @@
import asyncio
import unittest
from models import RuntimeTool
from pipecat.frames.frames import (
InputTransportMessageFrame,
OutputTransportMessageUrgentFrame,
)
from pipecat.processors.frame_processor import FrameDirection
from services.client_tools import ClientToolBroker, ClientToolError
from services.runtime_variables import DynamicVariableStore
from services.tool_executor import ToolExecutor
from schemas import ClientToolConfig
def client_tool() -> RuntimeTool:
return RuntimeTool(
id="tool_photo_button",
name="显示拍照按钮",
function_name="set_photo_button_visible",
type="client",
definition={
"schema_version": 1,
"type": "client",
"config": {
"parameters": [
{
"name": "visible",
"type": "boolean",
"required": True,
}
],
"timeout_seconds": 3,
"dynamic_variable_assignments": {
"photo_button_visible": "visible"
},
},
},
)
class FakeClientTools:
def __init__(self, result):
self.result = result
self.calls = []
async def call(
self,
function_name,
arguments,
*,
timeout_seconds,
wait_for_response=True,
):
self.calls.append(
(function_name, arguments, timeout_seconds, wait_for_response)
)
return self.result
class ClientToolExecutorTests(unittest.IsolatedAsyncioTestCase):
def test_fire_and_forget_rejects_result_assignments(self):
with self.assertRaisesRegex(ValueError, "不能配置结果变量赋值"):
ClientToolConfig(
wait_for_response=False,
dynamic_variable_assignments={"visible": "visible"},
)
async def test_success_updates_configured_variable(self):
store = DynamicVariableStore(
{"photo_button_visible": False},
variable_types={"photo_button_visible": "boolean"},
)
port = FakeClientTools({"status": "ok", "data": {"visible": True}})
executor = ToolExecutor(store, client_tools=port)
result = await executor.execute(client_tool(), {"visible": True})
self.assertEqual(result["updated_variables"], ["photo_button_visible"])
self.assertIs(store.values["photo_button_visible"], True)
self.assertEqual(
port.calls,
[("set_photo_button_visible", {"visible": True}, 3.0, True)],
)
async def test_failure_does_not_update_variable(self):
store = DynamicVariableStore({"photo_button_visible": False})
executor = ToolExecutor(
store,
client_tools=FakeClientTools(
{"status": "error", "message": "unsupported"}
),
)
result = await executor.execute(client_tool(), {"visible": True})
self.assertEqual(result["updated_variables"], [])
self.assertIs(store.values["photo_button_visible"], False)
class ClientToolBrokerTests(unittest.IsolatedAsyncioTestCase):
async def test_correlates_result_and_times_out(self):
broker = ClientToolBroker()
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append((frame, direction))
broker.push_frame = push_frame
call = asyncio.create_task(
broker.call("set_photo_button_visible", {"visible": True}, timeout_seconds=1)
)
await asyncio.sleep(0)
message = outbound[0][0].message
self.assertIsInstance(outbound[0][0], OutputTransportMessageUrgentFrame)
await broker.process_frame(
InputTransportMessageFrame(
message={
"type": "client-tool-result",
"tool_call_id": message["tool_call_id"],
"status": "ok",
"data": {"visible": True},
}
),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(
await call,
{"status": "ok", "data": {"visible": True}},
)
with self.assertRaisesRegex(ClientToolError, "超时"):
await broker.call("never_returns", {}, timeout_seconds=0.001)
async def test_fire_and_forget_does_not_create_pending_call(self):
broker = ClientToolBroker()
outbound = []
async def push_frame(frame, direction=FrameDirection.DOWNSTREAM):
outbound.append((frame, direction))
broker.push_frame = push_frame
result = await broker.call(
"open_panel",
{"visible": True},
timeout_seconds=1,
wait_for_response=False,
)
self.assertEqual(result, {"status": "ok", "data": {"dispatched": True}})
self.assertEqual(broker._pending, {})
self.assertFalse(outbound[0][0].message["wait_for_response"])
if __name__ == "__main__":
unittest.main()

View File

@@ -92,6 +92,44 @@ class UserTurnRoutingProcessorTest(unittest.IsolatedAsyncioTestCase):
self.assertEqual(brain.turns, ["我叫李白"]) self.assertEqual(brain.turns, ["我叫李白"])
self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)]) self.assertEqual(forwarded, [(frame, FrameDirection.DOWNSTREAM)])
async def test_routes_multimodal_user_message_by_its_text_part(self):
class FakeBrain:
def __init__(self):
self.turns = []
async def on_user_turn_end(self, content):
self.turns.append(content)
return False
brain = FakeBrain()
processor = UserTurnRoutingProcessor(brain)
processor.push_frame = lambda *_args, **_kwargs: _async_none()
context = LLMContext(
messages=[
{
"role": "user",
"content": [
{"type": "text", "text": "看看这张照片"},
{
"type": "image_url",
"image_url": {"url": "data:image/jpeg;base64,AA=="},
},
],
}
]
)
await processor.process_frame(
LLMContextFrame(context),
FrameDirection.DOWNSTREAM,
)
self.assertEqual(brain.turns, ["看看这张照片"])
async def _async_none():
return None
if __name__ == "__main__": if __name__ == "__main__":
unittest.main() unittest.main()

View File

@@ -0,0 +1,142 @@
import unittest
from types import SimpleNamespace
from models import RuntimeTool
from pipecat.frames.frames import (
BotStartedSpeakingFrame,
BotStoppedSpeakingFrame,
FunctionCallResultFrame,
FunctionCallsStartedFrame,
)
from services.pipecat.processors import ToolInterruptionUserMuteStrategy
from services.tool_policy import policy_for_tool
def runtime_tool(tool_type: str, config: dict | None = None) -> RuntimeTool:
return RuntimeTool(
id=f"tool_{tool_type}",
name=tool_type,
function_name=f"run_{tool_type}",
type=tool_type,
definition={"type": tool_type, "config": config or {}},
)
class ToolPolicyTests(unittest.TestCase):
def test_backwards_compatible_execution_defaults(self):
self.assertEqual(
policy_for_tool(runtime_tool("http")).execution_mode,
"immediate",
)
self.assertEqual(
policy_for_tool(runtime_tool("client")).execution_mode,
"async",
)
def test_explicit_policy_is_normalized(self):
policy = policy_for_tool(
runtime_tool(
"client",
{
"allow_interruptions": False,
"execution_mode": "immediate",
"wait_for_response": False,
},
)
)
self.assertFalse(policy.allow_interruptions)
self.assertTrue(policy.cancel_on_interruption)
self.assertFalse(policy.wait_for_response)
class ToolInterruptionStrategyTests(unittest.IsolatedAsyncioTestCase):
async def test_releases_only_after_tool_and_following_speech_finish(self):
strategy = ToolInterruptionUserMuteStrategy({"lookup_order"})
started = FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="lookup_order",
tool_call_id="call_1",
)
]
)
self.assertTrue(await strategy.process_frame(started))
self.assertTrue(
await strategy.process_frame(BotStartedSpeakingFrame())
)
self.assertTrue(
await strategy.process_frame(BotStoppedSpeakingFrame())
)
self.assertFalse(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="lookup_order",
tool_call_id="call_1",
arguments={},
result={"status": "ok"},
)
)
)
async def test_waits_for_response_when_tool_finishes_first(self):
strategy = ToolInterruptionUserMuteStrategy({"lookup_order"})
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="lookup_order",
tool_call_id="call_2",
)
]
)
)
self.assertTrue(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="lookup_order",
tool_call_id="call_2",
arguments={},
result={"status": "ok"},
)
)
)
await strategy.process_frame(BotStartedSpeakingFrame())
self.assertFalse(
await strategy.process_frame(BotStoppedSpeakingFrame())
)
async def test_immediate_tool_does_not_count_existing_preamble(self):
strategy = ToolInterruptionUserMuteStrategy(
{"lookup_order": "immediate"}
)
await strategy.process_frame(BotStartedSpeakingFrame())
await strategy.process_frame(
FunctionCallsStartedFrame(
function_calls=[
SimpleNamespace(
function_name="lookup_order",
tool_call_id="call_3",
)
]
)
)
await strategy.process_frame(BotStoppedSpeakingFrame())
self.assertTrue(
await strategy.process_frame(
FunctionCallResultFrame(
function_name="lookup_order",
tool_call_id="call_3",
arguments={},
result={"status": "ok"},
)
)
)
await strategy.process_frame(BotStartedSpeakingFrame())
self.assertFalse(
await strategy.process_frame(BotStoppedSpeakingFrame())
)
if __name__ == "__main__":
unittest.main()

View File

@@ -0,0 +1,57 @@
import unittest
from services.pipecat.processors import UserInputError, parse_user_input
class UserInputParserTests(unittest.TestCase):
def test_parses_text_and_current_camera_frame(self):
value = parse_user_input(
{
"type": "user-input",
"schema_version": 1,
"input_id": "input_1",
"parts": [
{"type": "input_text", "text": "帮我看看"},
{
"type": "input_image",
"source": {
"type": "camera_frame",
"frame": "current",
},
},
],
"options": {
"run_immediately": True,
"interrupt": True,
},
}
)
self.assertIsNotNone(value)
self.assertEqual(value.text, "帮我看看")
self.assertTrue(value.has_camera_frame)
self.assertEqual(value.transcript_text, "帮我看看\n已发送一张图片")
def test_rejects_legacy_and_unsupported_image_sources(self):
self.assertIsNone(parse_user_input({"type": "user-text", "text": "旧协议"}))
with self.assertRaisesRegex(UserInputError, "当前摄像头"):
parse_user_input(
{
"type": "user-input",
"schema_version": 1,
"input_id": "input_2",
"parts": [
{
"type": "input_image",
"source": {
"type": "uploaded_asset",
"asset_id": "asset_1",
},
}
],
}
)
if __name__ == "__main__":
unittest.main()

View File

@@ -12,6 +12,7 @@ import {
Mic, Mic,
Orbit, Orbit,
PhoneOff, PhoneOff,
ScanLine,
Send, Send,
Smartphone, Smartphone,
Sparkles, Sparkles,
@@ -43,6 +44,7 @@ import { Textarea } from "@/components/ui/textarea";
import { WaveVisualizer } from "@/components/ui/wave-visualizer"; import { WaveVisualizer } from "@/components/ui/wave-visualizer";
import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline"; import { WaveformTimelinePanel } from "@/components/ui/waveform-timeline";
import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview"; import { useCameraPreview, type CameraPreview } from "@/hooks/use-camera-preview";
import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import { import {
useVoicePreview, useVoicePreview,
type ChatMessage, type ChatMessage,
@@ -626,6 +628,7 @@ function DebugVoicePanel({
dynamicVariables: Record<string, string | number | boolean>; dynamicVariables: Record<string, string | number | boolean>;
dynamicVariablesError: string; dynamicVariablesError: string;
}) { }) {
const photoCapture = usePhotoCaptureTool(preview);
const { const {
status, status,
error, error,
@@ -689,6 +692,10 @@ function DebugVoicePanel({
recording={recording} recording={recording}
camera={camera} camera={camera}
videoStream={preview.videoStream} videoStream={preview.videoStream}
photoCaptureVisible={photoCapture.visible}
photoCapturing={photoCapture.capturing}
photoError={photoCapture.error}
onPhotoCapture={photoCapture.capture}
/> />
) : view === "video" ? ( ) : view === "video" ? (
showIdleHub ? ( showIdleHub ? (
@@ -966,6 +973,10 @@ function DebugVisionWorkspace({
recording, recording,
camera, camera,
videoStream, videoStream,
photoCaptureVisible,
photoCapturing,
photoError,
onPhotoCapture,
}: { }: {
view: DebugView; view: DebugView;
onViewChange: (view: DebugView) => void; onViewChange: (view: DebugView) => void;
@@ -973,6 +984,10 @@ function DebugVisionWorkspace({
recording: boolean; recording: boolean;
camera: CameraPreview; camera: CameraPreview;
videoStream: MediaStream | null; videoStream: MediaStream | null;
photoCaptureVisible: boolean;
photoCapturing: boolean;
photoError: string | null;
onPhotoCapture: () => Promise<void>;
}) { }) {
const containerRef = useRef<HTMLDivElement>(null); const containerRef = useRef<HTMLDivElement>(null);
const [position, setPosition] = useState({ x: 16, y: 16 }); const [position, setPosition] = useState({ x: 16, y: 16 });
@@ -1038,6 +1053,14 @@ function DebugVisionWorkspace({
</span> </span>
</span> </span>
</button> </button>
{photoCaptureVisible && (
<DebugPhotoCaptureButton
capturing={photoCapturing}
enabled={Boolean(videoStream)}
error={photoError}
onCapture={onPhotoCapture}
/>
)}
</div> </div>
); );
} }
@@ -1074,6 +1097,51 @@ function DebugVisionWorkspace({
> >
<DebugVideoPanel camera={camera} streamOverride={videoStream} compact /> <DebugVideoPanel camera={camera} streamOverride={videoStream} compact />
</button> </button>
{photoCaptureVisible && (
<DebugPhotoCaptureButton
capturing={photoCapturing}
enabled={Boolean(videoStream)}
error={photoError}
onCapture={onPhotoCapture}
/>
)}
</div>
);
}
function DebugPhotoCaptureButton({
capturing,
enabled,
error,
onCapture,
}: {
capturing: boolean;
enabled: boolean;
error: string | null;
onCapture: () => Promise<void>;
}) {
return (
<div className="absolute bottom-4 left-1/2 z-20 flex -translate-x-1/2 flex-col items-center gap-2">
{error && (
<div className="max-w-xs rounded-full border border-destructive/20 bg-background/90 px-3 py-1 text-xs text-destructive shadow-sm backdrop-blur">
{error}
</div>
)}
<Button
type="button"
size="icon"
onClick={() => void onCapture()}
disabled={capturing || !enabled}
aria-label={capturing ? "正在提交照片" : "拍照并发送"}
title={capturing ? "正在提交照片" : "拍照并发送"}
className="size-12 rounded-full shadow-lg"
>
{capturing ? (
<Loader2 className="size-5 animate-spin" />
) : (
<ScanLine className="size-5" />
)}
</Button>
</div> </div>
); );
} }
@@ -1250,4 +1318,3 @@ function DebugTranscriptPanel({
</div> </div>
); );
} }

View File

@@ -546,7 +546,9 @@ export function ToolPicker({
? "End Call" ? "End Call"
: tool.type === "mcp" : tool.type === "mcp"
? "MCP" ? "MCP"
: "HTTP"} : tool.type === "client"
? "Client"
: "HTTP"}
</Badge> </Badge>
</div> </div>
<div className="mt-0.5 truncate font-mono text-xs text-muted-foreground"> <div className="mt-0.5 truncate font-mono text-xs text-muted-foreground">

View File

@@ -58,19 +58,26 @@ import {
type McpServer, type McpServer,
type Tool, type Tool,
type ToolParameter, type ToolParameter,
type ToolExecutionMode,
type ToolStatus, type ToolStatus,
type ToolUpsert, type ToolUpsert,
} from "@/lib/api"; } from "@/lib/api";
type ToolKind = "end_call" | "http"; type ToolKind = "end_call" | "http" | "client";
type HttpMethod = HttpToolDefinition["config"]["method"]; type HttpMethod = HttpToolDefinition["config"]["method"];
type ToolFilter = "全部" | "End Call" | "HTTP" | "MCP"; type ToolFilter = "全部" | "End Call" | "HTTP" | "Client" | "MCP";
type SortOrder = "newest" | "oldest"; type SortOrder = "newest" | "oldest";
type ToolResource = type ToolResource =
| { kind: "tool"; id: string; tool: Tool } | { kind: "tool"; id: string; tool: Tool }
| { kind: "mcp"; id: string; server: McpServer }; | { kind: "mcp"; id: string; server: McpServer };
const toolFilters: readonly ToolFilter[] = ["全部", "End Call", "HTTP", "MCP"]; const toolFilters: readonly ToolFilter[] = [
"全部",
"End Call",
"HTTP",
"Client",
"MCP",
];
type ToolForm = { type ToolForm = {
name: string; name: string;
@@ -84,10 +91,14 @@ type ToolForm = {
method: HttpMethod; method: HttpMethod;
url: string; url: string;
timeoutSeconds: string; timeoutSeconds: string;
allowInterruptions: boolean;
executionMode: ToolExecutionMode;
waitForResponse: boolean;
headers: string; headers: string;
secretHeaders: string; secretHeaders: string;
parameters: string; parameters: string;
body: string; body: string;
updateDynamicVariables: boolean;
dynamicVariableAssignments: string; dynamicVariableAssignments: string;
secretDynamicVariables: string; secretDynamicVariables: string;
}; };
@@ -108,10 +119,14 @@ function blankForm(): ToolForm {
method: "GET", method: "GET",
url: "", url: "",
timeoutSeconds: "15", timeoutSeconds: "15",
allowInterruptions: true,
executionMode: "immediate",
waitForResponse: true,
headers: EMPTY_OBJECT, headers: EMPTY_OBJECT,
secretHeaders: EMPTY_OBJECT, secretHeaders: EMPTY_OBJECT,
parameters: EMPTY_ARRAY, parameters: EMPTY_ARRAY,
body: EMPTY_OBJECT, body: EMPTY_OBJECT,
updateDynamicVariables: false,
dynamicVariableAssignments: EMPTY_OBJECT, dynamicVariableAssignments: EMPTY_OBJECT,
secretDynamicVariables: EMPTY_OBJECT, secretDynamicVariables: EMPTY_OBJECT,
}; };
@@ -136,7 +151,25 @@ function formFromTool(tool: Tool): ToolForm {
return base; return base;
} }
if (tool.definition.type === "mcp") return base; if (tool.definition.type === "mcp") return base;
if (tool.definition.type === "client") {
base.allowInterruptions =
tool.definition.config.allowInterruptions ?? true;
base.executionMode = tool.definition.config.executionMode ?? "async";
base.waitForResponse = tool.definition.config.waitForResponse ?? true;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
base.parameters = pretty(tool.definition.config.parameters, EMPTY_ARRAY);
base.dynamicVariableAssignments = pretty(
tool.definition.config.dynamicVariableAssignments ?? {},
EMPTY_OBJECT,
);
base.updateDynamicVariables =
Object.keys(tool.definition.config.dynamicVariableAssignments ?? {}).length > 0;
return base;
}
base.method = tool.definition.config.method; base.method = tool.definition.config.method;
base.allowInterruptions =
tool.definition.config.allowInterruptions ?? true;
base.executionMode = tool.definition.config.executionMode ?? "immediate";
base.url = tool.definition.config.url; base.url = tool.definition.config.url;
base.timeoutSeconds = String(tool.definition.config.timeoutSeconds); base.timeoutSeconds = String(tool.definition.config.timeoutSeconds);
base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT); base.headers = pretty(tool.definition.config.headers, EMPTY_OBJECT);
@@ -215,6 +248,39 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
}, },
}; };
} }
if (form.type === "client") {
const timeoutSeconds = Number(form.timeoutSeconds);
if (
!Number.isInteger(timeoutSeconds) ||
timeoutSeconds < 1 ||
timeoutSeconds > 30
) {
throw new Error("Client Tool 超时时间必须是 1 到 30 秒之间的整数");
}
const dynamicVariableAssignments = form.updateDynamicVariables
? parseObject(form.dynamicVariableAssignments, "变量赋值")
: {};
return {
name: form.name.trim(),
functionName: form.functionName,
description: form.description.trim(),
status: form.status,
definition: {
schemaVersion: 1,
type: "client",
config: {
allowInterruptions: form.allowInterruptions,
executionMode: form.executionMode,
waitForResponse: form.waitForResponse,
parameters: parseParameters(form.parameters),
timeoutSeconds,
dynamicVariableAssignments:
dynamicVariableAssignments as Record<string, string>,
},
},
secrets: {},
};
}
const timeoutSeconds = Number(form.timeoutSeconds); const timeoutSeconds = Number(form.timeoutSeconds);
if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) { if (!Number.isInteger(timeoutSeconds) || timeoutSeconds < 1 || timeoutSeconds > 120) {
throw new Error("超时时间必须是 1 到 120 秒之间的整数"); throw new Error("超时时间必须是 1 到 120 秒之间的整数");
@@ -240,6 +306,8 @@ function payloadFromForm(form: ToolForm): ToolUpsert {
schemaVersion: 1, schemaVersion: 1,
type: "http", type: "http",
config: { config: {
allowInterruptions: form.allowInterruptions,
executionMode: form.executionMode,
method: form.method, method: form.method,
url: form.url.trim(), url: form.url.trim(),
timeoutSeconds, timeoutSeconds,
@@ -340,7 +408,8 @@ export function ComponentsToolsPage() {
return ( return (
(filter === "全部" || (filter === "全部" ||
(filter === "End Call" && tool.type === "end_call") || (filter === "End Call" && tool.type === "end_call") ||
(filter === "HTTP" && tool.type === "http")) && (filter === "HTTP" && tool.type === "http") ||
(filter === "Client" && tool.type === "client")) &&
(!query || (!query ||
[tool.name, tool.functionName, tool.description].some((value) => [tool.name, tool.functionName, tool.description].some((value) =>
value.toLowerCase().includes(query), value.toLowerCase().includes(query),
@@ -491,7 +560,9 @@ export function ComponentsToolsPage() {
? "MCP" ? "MCP"
: resource.tool.type === "end_call" : resource.tool.type === "end_call"
? "End Call" ? "End Call"
: "HTTP"} : resource.tool.type === "client"
? "Client"
: "HTTP"}
</Badge> </Badge>
), ),
}, },
@@ -684,13 +755,22 @@ export function ComponentsToolsPage() {
value={form.type} value={form.type}
disabled={Boolean(editing)} disabled={Boolean(editing)}
onValueChange={(type: ToolKind) => onValueChange={(type: ToolKind) =>
setForm((current) => ({ ...current, type })) setForm((current) => ({
...current,
type,
...(type === "client"
? { timeoutSeconds: "3", executionMode: "async" as const }
: type === "http"
? { timeoutSeconds: "15", executionMode: "immediate" as const }
: {}),
}))
} }
> >
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger> <SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent> <SelectContent>
<SelectItem value="end_call">End Call</SelectItem> <SelectItem value="end_call">End Call</SelectItem>
<SelectItem value="http">HTTP</SelectItem> <SelectItem value="http">HTTP</SelectItem>
<SelectItem value="client">Client Tool</SelectItem>
</SelectContent> </SelectContent>
</Select> </Select>
</Field> </Field>
@@ -733,6 +813,8 @@ export function ComponentsToolsPage() {
<FieldSection title="参数配置" scrollable tall> <FieldSection title="参数配置" scrollable tall>
{form.type === "end_call" ? ( {form.type === "end_call" ? (
<EndCallFields form={form} setForm={setForm} /> <EndCallFields form={form} setForm={setForm} />
) : form.type === "client" ? (
<ClientToolFields form={form} setForm={setForm} />
) : ( ) : (
<HttpFields form={form} setForm={setForm} /> <HttpFields form={form} setForm={setForm} />
)} )}
@@ -813,6 +895,97 @@ function EndCallFields({
); );
} }
function ClientToolFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4">
<ToolRuntimeFields form={form} setForm={setForm} />
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground">
</div>
</div>
<Switch
checked={form.waitForResponse}
onCheckedChange={(waitForResponse) =>
setForm((current) => ({
...current,
waitForResponse,
updateDynamicVariables: waitForResponse
? current.updateDynamicVariables
: false,
}))
}
/>
</div>
{form.waitForResponse && (
<Field label="响应超时(秒)">
<Input
type="number"
min={1}
max={30}
value={form.timeoutSeconds}
onChange={(event) =>
setForm((current) => ({
...current,
timeoutSeconds: event.target.value,
}))
}
/>
</Field>
)}
<JsonField
label="参数定义"
value={form.parameters}
onChange={(parameters) =>
setForm((current) => ({ ...current, parameters }))
}
rows={8}
/>
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline-strong px-4 py-3">
<div>
<div className="font-medium text-foreground">
</div>
<div className="mt-0.5 text-xs text-muted-foreground">
status=ok
</div>
</div>
<Switch
checked={form.updateDynamicVariables}
disabled={!form.waitForResponse}
onCheckedChange={(updateDynamicVariables) =>
setForm((current) => ({ ...current, updateDynamicVariables }))
}
/>
</div>
{form.updateDynamicVariables && (
<JsonField
label="结果变量赋值"
value={form.dynamicVariableAssignments}
onChange={(dynamicVariableAssignments) =>
setForm((current) => ({
...current,
dynamicVariableAssignments,
}))
}
/>
)}
<p className="text-xs leading-5 text-muted-foreground">
handler
set_photo_button_visible
</p>
</div>
);
}
function HttpFields({ function HttpFields({
form, form,
setForm, setForm,
@@ -822,6 +995,7 @@ function HttpFields({
}) { }) {
return ( return (
<div className="space-y-4"> <div className="space-y-4">
<ToolRuntimeFields form={form} setForm={setForm} />
<div className="grid gap-4 sm:grid-cols-[140px_1fr]"> <div className="grid gap-4 sm:grid-cols-[140px_1fr]">
<Field label="方法"> <Field label="方法">
<Select <Select
@@ -878,3 +1052,52 @@ function HttpFields({
</div> </div>
); );
} }
function ToolRuntimeFields({
form,
setForm,
}: {
form: ToolForm;
setForm: React.Dispatch<React.SetStateAction<ToolForm>>;
}) {
return (
<div className="space-y-4 rounded-xl border border-hairline bg-canvas-soft p-4">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-1 text-xs leading-5 text-muted-foreground">
Agent
</div>
</div>
<Field label="执行模式">
<Select
value={form.executionMode}
onValueChange={(executionMode: ToolExecutionMode) =>
setForm((current) => ({ ...current, executionMode }))
}
>
<SelectTrigger className="w-full border-hairline-strong bg-background">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="immediate"> · </SelectItem>
<SelectItem value="async"> · </SelectItem>
</SelectContent>
</Select>
</Field>
<div className="flex items-center justify-between gap-4">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground">
Agent
</div>
</div>
<Switch
checked={form.allowInterruptions}
onCheckedChange={(allowInterruptions) =>
setForm((current) => ({ ...current, allowInterruptions }))
}
/>
</div>
</div>
);
}

View File

@@ -9,6 +9,7 @@ import {
Mic, Mic,
Phone, Phone,
PhoneOff, PhoneOff,
ScanLine,
Video, Video,
} from "lucide-react"; } from "lucide-react";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
@@ -30,6 +31,7 @@ import {
SelectValue, SelectValue,
} from "@/components/ui/select"; } from "@/components/ui/select";
import { useCameraPreview } from "@/hooks/use-camera-preview"; import { useCameraPreview } from "@/hooks/use-camera-preview";
import { usePhotoCaptureTool } from "@/hooks/use-photo-capture-tool";
import { import {
useVoicePreview, useVoicePreview,
type ChatMessage, type ChatMessage,
@@ -420,6 +422,7 @@ function CallCameraDeviceSelect({
export function MobileCallPage({ assistantId }: { assistantId: string }) { export function MobileCallPage({ assistantId }: { assistantId: string }) {
const preview = useVoicePreview(assistantId); const preview = useVoicePreview(assistantId);
const photoCapture = usePhotoCaptureTool(preview);
const camera = useCameraPreview(); const camera = useCameraPreview();
const { const {
status, status,
@@ -475,7 +478,7 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
// 首次打开页面时自动发起通话hooks 会在卸载时各自释放媒体资源。 // 首次打开页面时自动发起通话hooks 会在卸载时各自释放媒体资源。
}, [startCall]); }, [startCall]);
const error = previewError || cameraError; const error = previewError || cameraError || photoCapture.error;
return ( return (
<main className="flex h-dvh w-full items-center justify-center overflow-hidden bg-black lg:p-4"> <main className="flex h-dvh w-full items-center justify-center overflow-hidden bg-black lg:p-4">
@@ -562,6 +565,22 @@ export function MobileCallPage({ assistantId }: { assistantId: string }) {
> >
<PhoneOff className="size-6 min-[380px]:size-7" /> <PhoneOff className="size-6 min-[380px]:size-7" />
</button> </button>
{photoCapture.visible && (
<button
type="button"
onClick={() => void photoCapture.capture()}
disabled={photoCapture.capturing || !videoStream}
aria-label={photoCapture.capturing ? "正在提交照片" : "拍照并发送"}
title={photoCapture.capturing ? "正在提交照片" : "拍照并发送"}
className="flex size-12 items-center justify-center rounded-full border border-white/15 bg-white text-[#07101a] shadow-xl transition-transform hover:scale-105 hover:bg-white/90 active:scale-95 disabled:pointer-events-none disabled:opacity-50 min-[380px]:size-14"
>
{photoCapture.capturing ? (
<Loader2 className="size-5 animate-spin" />
) : (
<ScanLine className="size-5" />
)}
</button>
)}
<CallCameraDeviceSelect <CallCameraDeviceSelect
value={cameraDeviceId} value={cameraDeviceId}
devices={cameraDevices} devices={cameraDevices}

View File

@@ -20,6 +20,7 @@ import {
DialogTitle, DialogTitle,
} from "@/components/ui/dialog"; } from "@/components/ui/dialog";
import { Input } from "@/components/ui/input"; import { Input } from "@/components/ui/input";
import { Switch } from "@/components/ui/switch";
import { import {
Select, Select,
SelectContent, SelectContent,
@@ -31,6 +32,8 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Textarea } from "@/components/ui/textarea"; import { Textarea } from "@/components/ui/textarea";
import { import {
mcpServersApi, mcpServersApi,
toolsApi,
type ToolExecutionMode,
type McpServer, type McpServer,
type McpServerUpsert, type McpServerUpsert,
type McpToolDefinition, type McpToolDefinition,
@@ -364,6 +367,14 @@ export function McpServerDialog({
tool={tool} tool={tool}
expanded={expandedToolIds.has(tool.id)} expanded={expandedToolIds.has(tool.id)}
onToggle={() => toggleTool(tool.id)} onToggle={() => toggleTool(tool.id)}
onUpdated={(updated) => {
setServerTools((current) =>
current.map((item) =>
item.id === updated.id ? updated : item,
),
);
void onChanged();
}}
/> />
))} ))}
</div> </div>
@@ -390,12 +401,22 @@ function McpToolRow({
tool, tool,
expanded, expanded,
onToggle, onToggle,
onUpdated,
}: { }: {
tool: Tool; tool: Tool;
expanded: boolean; expanded: boolean;
onToggle: () => void; onToggle: () => void;
onUpdated: (tool: Tool) => void;
}) { }) {
const definition = tool.definition as McpToolDefinition; const definition = tool.definition as McpToolDefinition;
const [allowInterruptions, setAllowInterruptions] = useState(
definition.config.allowInterruptions ?? true,
);
const [executionMode, setExecutionMode] = useState<ToolExecutionMode>(
definition.config.executionMode ?? "immediate",
);
const [savingPolicy, setSavingPolicy] = useState(false);
const [policyError, setPolicyError] = useState<string | null>(null);
const schema = definition.config.inputSchema ?? {}; const schema = definition.config.inputSchema ?? {};
const properties = useMemo( const properties = useMemo(
() => Object.entries((schema.properties as Record<string, unknown> | undefined) ?? {}), () => Object.entries((schema.properties as Record<string, unknown> | undefined) ?? {}),
@@ -403,6 +424,37 @@ function McpToolRow({
); );
const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []); const required = new Set(Array.isArray(schema.required) ? schema.required.map(String) : []);
async function savePolicy() {
if (savingPolicy) return;
setSavingPolicy(true);
setPolicyError(null);
try {
const updated = await toolsApi.update(tool.id, {
name: tool.name,
functionName: tool.functionName,
description: tool.description,
definition: {
...definition,
config: {
...definition.config,
allowInterruptions,
executionMode,
},
},
secrets: tool.secrets,
status: tool.status,
mcpServerId: tool.mcpServerId,
});
onUpdated(updated);
} catch (error) {
setPolicyError(
error instanceof Error ? error.message : "保存工具运行策略失败",
);
} finally {
setSavingPolicy(false);
}
}
return ( return (
<div> <div>
<button <button
@@ -425,6 +477,47 @@ function McpToolRow({
{tool.description && ( {tool.description && (
<p className="mb-4 text-sm leading-6 text-muted-foreground">{tool.description}</p> <p className="mb-4 text-sm leading-6 text-muted-foreground">{tool.description}</p>
)} )}
<div className="mb-4 grid gap-4 rounded-lg border border-hairline bg-card p-4 sm:grid-cols-2">
<Field label="执行模式">
<Select
value={executionMode}
onValueChange={(value: ToolExecutionMode) =>
setExecutionMode(value)
}
>
<SelectTrigger className="w-full"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="immediate"> · </SelectItem>
<SelectItem value="async"> · </SelectItem>
</SelectContent>
</Select>
</Field>
<div className="flex items-center justify-between gap-4 rounded-lg border border-hairline px-3 py-2">
<div>
<div className="text-sm font-medium text-foreground"></div>
<div className="mt-0.5 text-xs text-muted-foreground"></div>
</div>
<Switch
checked={allowInterruptions}
onCheckedChange={setAllowInterruptions}
/>
</div>
<div className="flex items-center gap-3 sm:col-span-2">
<Button
type="button"
size="sm"
variant="outline"
disabled={savingPolicy}
onClick={() => void savePolicy()}
>
{savingPolicy && <Loader2 size={14} className="animate-spin" />}
</Button>
{policyError && (
<span className="text-xs text-destructive">{policyError}</span>
)}
</div>
</div>
{properties.length === 0 ? ( {properties.length === 0 ? (
<div className="text-sm text-muted-foreground"></div> <div className="text-sm text-muted-foreground"></div>
) : ( ) : (

View File

@@ -0,0 +1,66 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import type { VoicePreview } from "@/hooks/use-voice-preview";
const PHOTO_BUTTON_TOOL = "set_photo_button_visible";
export function usePhotoCaptureTool(preview: VoicePreview) {
const { registerClientTool, sendUserInput, status } = preview;
const [visible, setVisible] = useState(false);
const [capturing, setCapturing] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(
() =>
registerClientTool(PHOTO_BUTTON_TOOL, ({ visible: nextValue }) => {
if (typeof nextValue !== "boolean") {
throw new Error("visible 参数必须是布尔值");
}
setVisible(nextValue);
setError(null);
return { visible: nextValue };
}),
[registerClientTool],
);
useEffect(() => {
if (status === "connected") return;
// eslint-disable-next-line react-hooks/set-state-in-effect
setVisible(false);
setCapturing(false);
}, [status]);
const capture = useCallback(async () => {
if (capturing || status !== "connected") return;
setCapturing(true);
setError(null);
try {
await sendUserInput(
[
{
type: "input_image",
source: { type: "camera_frame", frame: "current" },
},
],
{ runImmediately: true, interrupt: true },
);
} catch (captureError) {
setError(
captureError instanceof Error ? captureError.message : "拍照提交失败",
);
} finally {
setCapturing(false);
}
}, [capturing, sendUserInput, status]);
return {
visible,
capturing,
error,
capture,
};
}
export type PhotoCaptureTool = ReturnType<typeof usePhotoCaptureTool>;

View File

@@ -47,6 +47,25 @@ export type ChatMessage = {
type AppMessage = Record<string, unknown> & { type?: string }; type AppMessage = Record<string, unknown> & { type?: string };
type DynamicVariableValue = string | number | boolean; type DynamicVariableValue = string | number | boolean;
export type UserInputPart =
| { type: "input_text"; text: string }
| {
type: "input_image";
source: { type: "camera_frame"; frame: "current" };
};
export type UserInputResult = {
inputId: string;
status: "accepted";
};
export type ClientToolHandler = (
argumentsValue: Record<string, unknown>,
) => unknown | Promise<unknown>;
type PendingUserInput = {
resolve: (result: UserInputResult) => void;
reject: (error: Error) => void;
timeout: number;
};
function publicVariableSnapshot( function publicVariableSnapshot(
value: unknown, value: unknown,
@@ -116,6 +135,13 @@ function encodeHeaderJson(value: unknown): string {
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
} }
function newInputId(): string {
if (typeof crypto !== "undefined" && "randomUUID" in crypto) {
return `input_${crypto.randomUUID()}`;
}
return `input_${Date.now()}_${Math.random().toString(16).slice(2)}`;
}
function readNetworkMetrics(report: RTCStatsReport): NetworkMetrics | null { function readNetworkMetrics(report: RTCStatsReport): NetworkMetrics | null {
const outbound: Partial<Record<"audio" | "video", number>> = {}; const outbound: Partial<Record<"audio" | "video", number>> = {};
const lost: Partial<Record<"audio" | "video", number>> = {}; const lost: Partial<Record<"audio" | "video", number>> = {};
@@ -219,6 +245,8 @@ export function useVoicePreview(
const pendingAssistantTurnsRef = useRef( const pendingAssistantTurnsRef = useRef(
new Map<string, { timestamp: string }>(), new Map<string, { timestamp: string }>(),
); );
const pendingUserInputsRef = useRef(new Map<string, PendingUserInput>());
const clientToolHandlersRef = useRef(new Map<string, ClientToolHandler>());
const endedByServerRef = useRef(false); const endedByServerRef = useRef(false);
const selectedDeviceIdRef = useRef(""); const selectedDeviceIdRef = useRef("");
const selectedOutputDeviceIdRef = useRef(""); const selectedOutputDeviceIdRef = useRef("");
@@ -273,6 +301,11 @@ export function useVoicePreview(
if (audioRef.current) audioRef.current.srcObject = null; if (audioRef.current) audioRef.current.srcObject = null;
startingRef.current = false; startingRef.current = false;
pendingAssistantTurnsRef.current.clear(); pendingAssistantTurnsRef.current.clear();
pendingUserInputsRef.current.forEach(({ reject, timeout }) => {
window.clearTimeout(timeout);
reject(new Error("连接已关闭,用户输入未完成"));
});
pendingUserInputsRef.current.clear();
networkStatsRef.current = null; networkStatsRef.current = null;
onNodeActiveRef.current?.(null); onNodeActiveRef.current?.(null);
}, []); }, []);
@@ -301,8 +334,78 @@ export function useVoicePreview(
setStatus("failed"); setStatus("failed");
}, [releaseResources]); }, [releaseResources]);
const dispatchClientTool = useCallback(async (msg: AppMessage) => {
const toolCallId =
typeof msg.tool_call_id === "string" ? msg.tool_call_id : "";
const functionName =
typeof msg.function_name === "string" ? msg.function_name : "";
const waitForResponse = msg.wait_for_response !== false;
if (!toolCallId || !functionName) return;
const handler = clientToolHandlersRef.current.get(functionName);
const transport = transportRef.current;
if (!transport || transport.state !== "connected") return;
if (!handler) {
if (waitForResponse) {
transport.sendAppMessage({
type: "client-tool-result",
tool_call_id: toolCallId,
status: "error",
message: `客户端未注册工具: ${functionName}`,
});
}
return;
}
try {
const argumentsValue =
msg.arguments &&
typeof msg.arguments === "object" &&
!Array.isArray(msg.arguments)
? (msg.arguments as Record<string, unknown>)
: {};
const data = await handler(argumentsValue);
if (transportRef.current !== transport) return;
if (!waitForResponse) return;
transport.sendAppMessage({
type: "client-tool-result",
tool_call_id: toolCallId,
status: "ok",
data,
});
} catch (toolError) {
if (transportRef.current !== transport) return;
if (!waitForResponse) return;
transport.sendAppMessage({
type: "client-tool-result",
tool_call_id: toolCallId,
status: "error",
message: errorMessage(toolError, "客户端工具执行失败"),
});
}
}, []);
const handleAppMessage = useCallback((msg: AppMessage) => { const handleAppMessage = useCallback((msg: AppMessage) => {
if ( if (msg.type === "client-tool-call") {
void dispatchClientTool(msg);
} else if (
msg.type === "user-input-result" &&
typeof msg.input_id === "string"
) {
const pending = pendingUserInputsRef.current.get(msg.input_id);
if (!pending) return;
pendingUserInputsRef.current.delete(msg.input_id);
window.clearTimeout(pending.timeout);
if (msg.status === "accepted") {
pending.resolve({ inputId: msg.input_id, status: "accepted" });
} else {
pending.reject(
new Error(
typeof msg.message === "string" ? msg.message : "用户输入处理失败",
),
);
}
} else if (
msg.type === "assistant-text-start" && msg.type === "assistant-text-start" &&
typeof msg.turn_id === "string" typeof msg.turn_id === "string"
) { ) {
@@ -413,7 +516,7 @@ export function useVoicePreview(
setCallEnded(true); setCallEnded(true);
disconnect(); disconnect();
} }
}, [disconnect]); }, [disconnect, dispatchClientTool]);
const connect = useCallback(async (options: ConnectOptions = {}) => { const connect = useCallback(async (options: ConnectOptions = {}) => {
if (startingRef.current || transportRef.current) return; if (startingRef.current || transportRef.current) return;
@@ -601,10 +704,68 @@ export function useVoicePreview(
const trimmed = text.trim(); const trimmed = text.trim();
const transport = transportRef.current; const transport = transportRef.current;
if (!trimmed || !transport || transport.state !== "connected") return false; if (!trimmed || !transport || transport.state !== "connected") return false;
transport.sendAppMessage({ type: "user-text", text: trimmed }); const inputId = newInputId();
transport.sendAppMessage({
type: "user-input",
schema_version: 1,
input_id: inputId,
parts: [{ type: "input_text", text: trimmed }],
options: { run_immediately: true, interrupt: true },
});
return true; return true;
}, []); }, []);
const sendUserInput = useCallback(
(
parts: UserInputPart[],
options: { runImmediately?: boolean; interrupt?: boolean } = {},
): Promise<UserInputResult> => {
const transport = transportRef.current;
if (!transport || transport.state !== "connected") {
return Promise.reject(new Error("当前未连接语音服务"));
}
const inputId = newInputId();
return new Promise<UserInputResult>((resolve, reject) => {
const timeout = window.setTimeout(() => {
pendingUserInputsRef.current.delete(inputId);
reject(new Error("等待用户输入处理结果超时"));
}, 30_000);
pendingUserInputsRef.current.set(inputId, { resolve, reject, timeout });
try {
transport.sendAppMessage({
type: "user-input",
schema_version: 1,
input_id: inputId,
parts,
options: {
run_immediately: options.runImmediately ?? true,
interrupt: options.interrupt ?? true,
},
});
} catch (sendError) {
window.clearTimeout(timeout);
pendingUserInputsRef.current.delete(inputId);
reject(
new Error(errorMessage(sendError, "发送用户输入失败")),
);
}
});
},
[],
);
const registerClientTool = useCallback(
(functionName: string, handler: ClientToolHandler): (() => void) => {
clientToolHandlersRef.current.set(functionName, handler);
return () => {
if (clientToolHandlersRef.current.get(functionName) === handler) {
clientToolHandlersRef.current.delete(functionName);
}
};
},
[],
);
useEffect(() => releaseResources, [releaseResources]); useEffect(() => releaseResources, [releaseResources]);
return { return {
@@ -627,6 +788,8 @@ export function useVoicePreview(
selectOutputDevice, selectOutputDevice,
supportsOutputSelection, supportsOutputSelection,
sendText, sendText,
sendUserInput,
registerClientTool,
connect, connect,
replaceVideoStream, replaceVideoStream,
selectCamera, selectCamera,

View File

@@ -315,6 +315,7 @@ export const conversationsApi = {
// ---------- 工具 ---------- // ---------- 工具 ----------
export type ToolStatus = "active" | "archived" | "draft"; export type ToolStatus = "active" | "archived" | "draft";
export type ToolExecutionMode = "immediate" | "async";
export type ToolParameter = { export type ToolParameter = {
name: string; name: string;
type: "string" | "number" | "integer" | "boolean" | "object" | "array"; type: "string" | "number" | "integer" | "boolean" | "object" | "array";
@@ -337,6 +338,8 @@ export type HttpToolDefinition = {
schemaVersion: number; schemaVersion: number;
type: "http"; type: "http";
config: { config: {
allowInterruptions: boolean;
executionMode: ToolExecutionMode;
method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE"; method: "GET" | "POST" | "PUT" | "PATCH" | "DELETE";
url: string; url: string;
timeoutSeconds: number; timeoutSeconds: number;
@@ -347,10 +350,25 @@ export type HttpToolDefinition = {
}; };
}; };
export type ClientToolDefinition = {
schemaVersion: number;
type: "client";
config: {
allowInterruptions: boolean;
executionMode: ToolExecutionMode;
waitForResponse: boolean;
parameters: ToolParameter[];
timeoutSeconds: number;
dynamicVariableAssignments: Record<string, string>;
};
};
export type McpToolDefinition = { export type McpToolDefinition = {
schemaVersion: number; schemaVersion: number;
type: "mcp"; type: "mcp";
config: { config: {
allowInterruptions: boolean;
executionMode: ToolExecutionMode;
remoteToolName: string; remoteToolName: string;
inputSchema: Record<string, unknown>; inputSchema: Record<string, unknown>;
schemaHash: string; schemaHash: string;
@@ -362,9 +380,13 @@ export type Tool = {
id: string; id: string;
name: string; name: string;
functionName: string; functionName: string;
type: "end_call" | "http" | "mcp"; type: "end_call" | "http" | "mcp" | "client";
description: string; description: string;
definition: EndCallToolDefinition | HttpToolDefinition | McpToolDefinition; definition:
| EndCallToolDefinition
| HttpToolDefinition
| ClientToolDefinition
| McpToolDefinition;
secrets: Record<string, unknown>; secrets: Record<string, unknown>;
status: ToolStatus; status: ToolStatus;
mcpServerId?: string | null; mcpServerId?: string | null;