Add wait_for_response functionality to ToolResource and related components. Update API models, schemas, and routers to support new parameter. Enhance UI components to manage wait_for_response state, ensuring proper integration across the application.

This commit is contained in:
Xin Wang
2026-02-27 16:54:39 +08:00
parent 95c6e93a9c
commit 229243e832
9 changed files with 303 additions and 44 deletions

View File

@@ -98,6 +98,7 @@ class ToolResource(Base):
http_timeout_ms: Mapped[int] = mapped_column(Integer, default=10000) http_timeout_ms: Mapped[int] = mapped_column(Integer, default=10000)
parameter_schema: Mapped[dict] = mapped_column(JSON, default=dict) parameter_schema: Mapped[dict] = mapped_column(JSON, default=dict)
parameter_defaults: Mapped[dict] = mapped_column(JSON, default=dict) parameter_defaults: Mapped[dict] = mapped_column(JSON, default=dict)
wait_for_response: Mapped[bool] = mapped_column(default=False)
enabled: Mapped[bool] = mapped_column(default=True) enabled: Mapped[bool] = mapped_column(default=True)
is_system: Mapped[bool] = mapped_column(default=False) is_system: Mapped[bool] = mapped_column(default=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)

View File

@@ -22,7 +22,13 @@ from ..schemas import (
AssistantOpenerAudioGenerateRequest, AssistantOpenerAudioGenerateRequest,
AssistantOpenerAudioOut, AssistantOpenerAudioOut,
) )
from .tools import TOOL_REGISTRY, TOOL_CATEGORY_MAP, TOOL_PARAMETER_DEFAULTS, _ensure_tool_resource_schema from .tools import (
TOOL_REGISTRY,
TOOL_CATEGORY_MAP,
TOOL_PARAMETER_DEFAULTS,
TOOL_WAIT_FOR_RESPONSE_DEFAULTS,
_ensure_tool_resource_schema,
)
router = APIRouter(prefix="/assistants", tags=["Assistants"]) router = APIRouter(prefix="/assistants", tags=["Assistants"])
@@ -142,6 +148,11 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings:
) )
defaults_raw = resource.parameter_defaults if resource else TOOL_PARAMETER_DEFAULTS.get(tool_id) defaults_raw = resource.parameter_defaults if resource else TOOL_PARAMETER_DEFAULTS.get(tool_id)
defaults = dict(defaults_raw) if isinstance(defaults_raw, dict) else {} defaults = dict(defaults_raw) if isinstance(defaults_raw, dict) else {}
wait_for_response = (
bool(resource.wait_for_response)
if resource
else bool(TOOL_WAIT_FOR_RESPONSE_DEFAULTS.get(tool_id, False))
)
if not resource and tool_id not in TOOL_REGISTRY: if not resource and tool_id not in TOOL_REGISTRY:
warnings.append(f"Tool resource not found: {tool_id}") warnings.append(f"Tool resource not found: {tool_id}")
@@ -160,6 +171,7 @@ def _resolve_runtime_tools(db: Session, selected_tool_ids: List[str], warnings:
}, },
"displayName": display_name or tool_id, "displayName": display_name or tool_id,
"toolId": tool_id, "toolId": tool_id,
"waitForResponse": wait_for_response,
} }
if defaults: if defaults:
runtime_tool["defaultArgs"] = defaults runtime_tool["defaultArgs"] = defaults

View File

@@ -98,6 +98,17 @@ TOOL_REGISTRY = {
"required": ["msg"] "required": ["msg"]
} }
}, },
"text_msg_prompt": {
"name": "文本消息提示",
"description": "显示一条文本弹窗提示",
"parameters": {
"type": "object",
"properties": {
"msg": {"type": "string", "description": "提示文本内容"}
},
"required": ["msg"]
}
},
} }
TOOL_CATEGORY_MAP = { TOOL_CATEGORY_MAP = {
@@ -109,6 +120,7 @@ TOOL_CATEGORY_MAP = {
"increase_volume": "system", "increase_volume": "system",
"decrease_volume": "system", "decrease_volume": "system",
"voice_message_prompt": "system", "voice_message_prompt": "system",
"text_msg_prompt": "system",
} }
TOOL_ICON_MAP = { TOOL_ICON_MAP = {
@@ -120,6 +132,7 @@ TOOL_ICON_MAP = {
"increase_volume": "Volume2", "increase_volume": "Volume2",
"decrease_volume": "Volume2", "decrease_volume": "Volume2",
"voice_message_prompt": "Volume2", "voice_message_prompt": "Volume2",
"text_msg_prompt": "Terminal",
} }
TOOL_HTTP_DEFAULTS = { TOOL_HTTP_DEFAULTS = {
@@ -130,6 +143,10 @@ TOOL_PARAMETER_DEFAULTS = {
"decrease_volume": {"step": 1}, "decrease_volume": {"step": 1},
} }
TOOL_WAIT_FOR_RESPONSE_DEFAULTS = {
"text_msg_prompt": True,
}
def _normalize_parameter_schema(value: Any, *, tool_id: Optional[str] = None) -> Dict[str, Any]: def _normalize_parameter_schema(value: Any, *, tool_id: Optional[str] = None) -> Dict[str, Any]:
if not isinstance(value, dict): if not isinstance(value, dict):
@@ -177,6 +194,9 @@ def _ensure_tool_resource_schema(db: Session) -> None:
if "parameter_defaults" not in columns: if "parameter_defaults" not in columns:
db.execute(text("ALTER TABLE tool_resources ADD COLUMN parameter_defaults JSON")) db.execute(text("ALTER TABLE tool_resources ADD COLUMN parameter_defaults JSON"))
altered = True altered = True
if "wait_for_response" not in columns:
db.execute(text("ALTER TABLE tool_resources ADD COLUMN wait_for_response BOOLEAN DEFAULT 0"))
altered = True
if altered: if altered:
db.commit() db.commit()
@@ -222,6 +242,7 @@ def _seed_default_tools_if_empty(db: Session) -> None:
http_timeout_ms=int(http_defaults.get("http_timeout_ms") or 10000), http_timeout_ms=int(http_defaults.get("http_timeout_ms") or 10000),
parameter_schema=_normalize_parameter_schema(payload.get("parameters"), tool_id=tool_id), parameter_schema=_normalize_parameter_schema(payload.get("parameters"), tool_id=tool_id),
parameter_defaults=_normalize_parameter_defaults(TOOL_PARAMETER_DEFAULTS.get(tool_id)), parameter_defaults=_normalize_parameter_defaults(TOOL_PARAMETER_DEFAULTS.get(tool_id)),
wait_for_response=bool(TOOL_WAIT_FOR_RESPONSE_DEFAULTS.get(tool_id, False)),
enabled=True, enabled=True,
is_system=True, is_system=True,
)) ))
@@ -311,6 +332,7 @@ def create_tool_resource(data: ToolResourceCreate, db: Session = Depends(get_db)
http_timeout_ms=max(1000, int(data.http_timeout_ms or 10000)), http_timeout_ms=max(1000, int(data.http_timeout_ms or 10000)),
parameter_schema=parameter_schema, parameter_schema=parameter_schema,
parameter_defaults=parameter_defaults, parameter_defaults=parameter_defaults,
wait_for_response=bool(data.wait_for_response) if data.category == "system" else False,
enabled=data.enabled, enabled=data.enabled,
is_system=False, is_system=False,
) )
@@ -342,6 +364,8 @@ def update_tool_resource(id: str, data: ToolResourceUpdate, db: Session = Depend
update_data["parameter_schema"] = _normalize_parameter_schema(update_data.get("parameter_schema"), tool_id=id) update_data["parameter_schema"] = _normalize_parameter_schema(update_data.get("parameter_schema"), tool_id=id)
if "parameter_defaults" in update_data: if "parameter_defaults" in update_data:
update_data["parameter_defaults"] = _normalize_parameter_defaults(update_data.get("parameter_defaults")) update_data["parameter_defaults"] = _normalize_parameter_defaults(update_data.get("parameter_defaults"))
if new_category != "system":
update_data["wait_for_response"] = False
for field, value in update_data.items(): for field, value in update_data.items():
setattr(item, field, value) setattr(item, field, value)

View File

@@ -241,6 +241,7 @@ class ToolResourceBase(BaseModel):
http_timeout_ms: int = 10000 http_timeout_ms: int = 10000
parameter_schema: Dict[str, Any] = Field(default_factory=dict) parameter_schema: Dict[str, Any] = Field(default_factory=dict)
parameter_defaults: Dict[str, Any] = Field(default_factory=dict) parameter_defaults: Dict[str, Any] = Field(default_factory=dict)
wait_for_response: bool = False
enabled: bool = True enabled: bool = True
@@ -259,6 +260,7 @@ class ToolResourceUpdate(BaseModel):
http_timeout_ms: Optional[int] = None http_timeout_ms: Optional[int] = None
parameter_schema: Optional[Dict[str, Any]] = None parameter_schema: Optional[Dict[str, Any]] = None
parameter_defaults: Optional[Dict[str, Any]] = None parameter_defaults: Optional[Dict[str, Any]] = None
wait_for_response: Optional[bool] = None
enabled: Optional[bool] = None enabled: Optional[bool] = None

View File

@@ -314,6 +314,7 @@ class DuplexPipeline:
self._runtime_tool_default_args: Dict[str, Dict[str, Any]] = {} self._runtime_tool_default_args: Dict[str, Dict[str, Any]] = {}
self._runtime_tool_id_map: Dict[str, str] = {} self._runtime_tool_id_map: Dict[str, str] = {}
self._runtime_tool_display_names: Dict[str, str] = {} self._runtime_tool_display_names: Dict[str, str] = {}
self._runtime_tool_wait_for_response: Dict[str, bool] = {}
self._pending_tool_waiters: Dict[str, asyncio.Future] = {} self._pending_tool_waiters: Dict[str, asyncio.Future] = {}
self._early_tool_results: Dict[str, Dict[str, Any]] = {} self._early_tool_results: Dict[str, Dict[str, Any]] = {}
self._completed_tool_call_ids: set[str] = set() self._completed_tool_call_ids: set[str] = set()
@@ -337,6 +338,7 @@ class DuplexPipeline:
self._runtime_tool_default_args = self._resolved_tool_default_args_map() self._runtime_tool_default_args = self._resolved_tool_default_args_map()
self._runtime_tool_id_map = self._resolved_tool_id_map() self._runtime_tool_id_map = self._resolved_tool_id_map()
self._runtime_tool_display_names = self._resolved_tool_display_name_map() self._runtime_tool_display_names = self._resolved_tool_display_name_map()
self._runtime_tool_wait_for_response = self._resolved_tool_wait_for_response_map()
self._initial_greeting_emitted = False self._initial_greeting_emitted = False
if self._server_tool_executor is None: if self._server_tool_executor is None:
@@ -441,12 +443,14 @@ class DuplexPipeline:
self._runtime_tool_default_args = self._resolved_tool_default_args_map() self._runtime_tool_default_args = self._resolved_tool_default_args_map()
self._runtime_tool_id_map = self._resolved_tool_id_map() self._runtime_tool_id_map = self._resolved_tool_id_map()
self._runtime_tool_display_names = self._resolved_tool_display_name_map() self._runtime_tool_display_names = self._resolved_tool_display_name_map()
self._runtime_tool_wait_for_response = self._resolved_tool_wait_for_response_map()
elif "tools" in metadata: elif "tools" in metadata:
self._runtime_tools = [] self._runtime_tools = []
self._runtime_tool_executor = {} self._runtime_tool_executor = {}
self._runtime_tool_default_args = {} self._runtime_tool_default_args = {}
self._runtime_tool_id_map = {} self._runtime_tool_id_map = {}
self._runtime_tool_display_names = {} self._runtime_tool_display_names = {}
self._runtime_tool_wait_for_response = {}
if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"): if self.llm_service and hasattr(self.llm_service, "set_knowledge_config"):
self.llm_service.set_knowledge_config(self._resolved_knowledge_config()) self.llm_service.set_knowledge_config(self._resolved_knowledge_config())
@@ -1577,6 +1581,25 @@ class DuplexPipeline:
result[name] = dict(raw_defaults) result[name] = dict(raw_defaults)
return result return result
def _resolved_tool_wait_for_response_map(self) -> Dict[str, bool]:
result: Dict[str, bool] = {}
for item in self._runtime_tools:
if not isinstance(item, dict):
continue
fn = item.get("function")
if isinstance(fn, dict) and fn.get("name"):
name = str(fn.get("name")).strip()
else:
name = str(item.get("name") or "").strip()
if not name:
continue
raw_wait = item.get("waitForResponse")
if raw_wait is None:
raw_wait = item.get("wait_for_response")
if isinstance(raw_wait, bool):
result[name] = raw_wait
return result
def _resolved_tool_id_map(self) -> Dict[str, str]: def _resolved_tool_id_map(self) -> Dict[str, str]:
result: Dict[str, str] = {} result: Dict[str, str] = {}
for item in self._runtime_tools: for item in self._runtime_tools:
@@ -1647,6 +1670,9 @@ class DuplexPipeline:
def _tool_display_name(self, tool_name: str) -> str: def _tool_display_name(self, tool_name: str) -> str:
return str(self._runtime_tool_display_names.get(tool_name) or tool_name).strip() return str(self._runtime_tool_display_names.get(tool_name) or tool_name).strip()
def _tool_wait_for_response(self, tool_name: str) -> bool:
return bool(self._runtime_tool_wait_for_response.get(tool_name, False))
def _tool_executor(self, tool_call: Dict[str, Any]) -> str: def _tool_executor(self, tool_call: Dict[str, Any]) -> str:
name = self._tool_name(tool_call) name = self._tool_name(tool_call)
if name and name in self._runtime_tool_executor: if name and name in self._runtime_tool_executor:
@@ -1873,6 +1899,8 @@ class DuplexPipeline:
tool_name = self._tool_name(enriched_tool_call) or "unknown_tool" tool_name = self._tool_name(enriched_tool_call) or "unknown_tool"
tool_id = self._tool_id_for_name(tool_name) tool_id = self._tool_id_for_name(tool_name)
tool_display_name = self._tool_display_name(tool_name) or tool_name tool_display_name = self._tool_display_name(tool_name) or tool_name
wait_for_response = self._tool_wait_for_response(tool_name)
enriched_tool_call["wait_for_response"] = wait_for_response
call_id = str(enriched_tool_call.get("id") or "").strip() call_id = str(enriched_tool_call.get("id") or "").strip()
fn_payload = ( fn_payload = (
dict(enriched_tool_call.get("function")) dict(enriched_tool_call.get("function"))
@@ -1906,6 +1934,7 @@ class DuplexPipeline:
tool_name=tool_name, tool_name=tool_name,
tool_id=tool_id, tool_id=tool_id,
tool_display_name=tool_display_name, tool_display_name=tool_display_name,
wait_for_response=wait_for_response,
arguments=tool_arguments, arguments=tool_arguments,
executor=executor, executor=executor,
timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000), timeout_ms=int(self._TOOL_WAIT_TIMEOUT_SECONDS * 1000),

View File

@@ -1717,6 +1717,9 @@ const DEBUG_CLIENT_TOOLS = [
{ id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' }, { id: 'text_msg_prompt', name: 'text_msg_prompt', description: '文本消息提示' },
] as const; ] as const;
const DEBUG_CLIENT_TOOL_ID_SET = new Set<string>(DEBUG_CLIENT_TOOLS.map((item) => item.id)); const DEBUG_CLIENT_TOOL_ID_SET = new Set<string>(DEBUG_CLIENT_TOOLS.map((item) => item.id));
const DEBUG_CLIENT_TOOL_WAIT_DEFAULTS: Record<string, boolean> = {
text_msg_prompt: true,
};
type DynamicVariableEntry = { type DynamicVariableEntry = {
id: string; id: string;
@@ -1981,7 +1984,16 @@ export const DebugDrawer: React.FC<{
const [inputText, setInputText] = useState(''); const [inputText, setInputText] = useState('');
const [isLoading, setIsLoading] = useState(false); const [isLoading, setIsLoading] = useState(false);
const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle'); const [callStatus, setCallStatus] = useState<'idle' | 'calling' | 'active'>('idle');
const [textPromptDialog, setTextPromptDialog] = useState<{ open: boolean; message: string }>({ open: false, message: '' }); const [textPromptDialog, setTextPromptDialog] = useState<{
open: boolean;
message: string;
pendingResult?: {
toolCallId: string;
toolName: string;
toolDisplayName: string;
waitForResponse: boolean;
};
}>({ open: false, message: '' });
const [textSessionStarted, setTextSessionStarted] = useState(false); const [textSessionStarted, setTextSessionStarted] = useState(false);
const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected'); const [wsStatus, setWsStatus] = useState<'disconnected' | 'connecting' | 'ready' | 'error'>('disconnected');
const [wsError, setWsError] = useState(''); const [wsError, setWsError] = useState('');
@@ -2043,6 +2055,7 @@ export const DebugDrawer: React.FC<{
const assistantResponseIndexByIdRef = useRef<Map<string, number>>(new Map()); const assistantResponseIndexByIdRef = useRef<Map<string, number>>(new Map());
const pendingTtfbByResponseIdRef = useRef<Map<string, number>>(new Map()); const pendingTtfbByResponseIdRef = useRef<Map<string, number>>(new Map());
const interruptedResponseIdsRef = useRef<Set<string>>(new Set()); const interruptedResponseIdsRef = useRef<Set<string>>(new Set());
const interruptedDropNoticeKeysRef = useRef<Set<string>>(new Set());
const audioCtxRef = useRef<AudioContext | null>(null); const audioCtxRef = useRef<AudioContext | null>(null);
const playbackTimeRef = useRef<number>(0); const playbackTimeRef = useRef<number>(0);
const activeAudioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set()); const activeAudioSourcesRef = useRef<Set<AudioBufferSourceNode>>(new Set());
@@ -2088,6 +2101,9 @@ export const DebugDrawer: React.FC<{
return null; return null;
} }
const isClientTool = debugClientTool ? true : (item?.category || 'query') === 'system'; const isClientTool = debugClientTool ? true : (item?.category || 'query') === 'system';
const waitForResponse = isClientTool
? (item?.waitForResponse ?? DEBUG_CLIENT_TOOL_WAIT_DEFAULTS[toolId] ?? false)
: false;
const parameterSchema = (item?.parameterSchema && typeof item.parameterSchema === 'object') const parameterSchema = (item?.parameterSchema && typeof item.parameterSchema === 'object')
? item.parameterSchema ? item.parameterSchema
: getDefaultToolParameters(toolId); : getDefaultToolParameters(toolId);
@@ -2097,6 +2113,7 @@ export const DebugDrawer: React.FC<{
return { return {
type: 'function', type: 'function',
executor: isClientTool ? 'client' : 'server', executor: isClientTool ? 'client' : 'server',
waitForResponse,
...(parameterDefaults && Object.keys(parameterDefaults).length > 0 ? { defaultArgs: parameterDefaults } : {}), ...(parameterDefaults && Object.keys(parameterDefaults).length > 0 ? { defaultArgs: parameterDefaults } : {}),
function: { function: {
name: toolId, name: toolId,
@@ -2112,6 +2129,7 @@ export const DebugDrawer: React.FC<{
assistantResponseIndexByIdRef.current.clear(); assistantResponseIndexByIdRef.current.clear();
pendingTtfbByResponseIdRef.current.clear(); pendingTtfbByResponseIdRef.current.clear();
interruptedResponseIdsRef.current.clear(); interruptedResponseIdsRef.current.clear();
interruptedDropNoticeKeysRef.current.clear();
}; };
const extractResponseId = (payload: any): string | undefined => { const extractResponseId = (payload: any): string | undefined => {
@@ -2120,6 +2138,23 @@ export const DebugDrawer: React.FC<{
return responseId || undefined; return responseId || undefined;
}; };
const noteInterruptedDrop = (responseId: string, kind: 'ttfb' | 'delta' | 'final') => {
const key = `${responseId}:${kind}`;
if (interruptedDropNoticeKeysRef.current.has(key)) return;
interruptedDropNoticeKeysRef.current.add(key);
if (interruptedDropNoticeKeysRef.current.size > 256) {
const oldest = interruptedDropNoticeKeysRef.current.values().next().value as string | undefined;
if (oldest) interruptedDropNoticeKeysRef.current.delete(oldest);
}
setMessages((prev) => [
...prev,
{
role: 'tool',
text: `drop stale ${kind} from interrupted response ${responseId}`,
},
]);
};
// Initialize // Initialize
useEffect(() => { useEffect(() => {
if (isOpen) { if (isOpen) {
@@ -2364,6 +2399,64 @@ export const DebugDrawer: React.FC<{
clearPlaybackQueue(); clearPlaybackQueue();
}; };
const emitClientToolResult = (resultPayload: any, toolDisplayName?: string) => {
const ws = wsRef.current;
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(
JSON.stringify({
type: 'tool_call.results',
results: [resultPayload],
})
);
}
const statusCode = Number(resultPayload?.status?.code || 500);
const statusMessage = String(resultPayload?.status?.message || 'error');
const displayName = toolDisplayName || String(resultPayload?.name || 'unknown_tool');
const resultText =
statusCode === 200 && typeof resultPayload?.output?.result === 'number'
? `result ${displayName} = ${resultPayload.output.result}`
: `result ${displayName} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [
...prev,
{
role: 'tool',
text: resultText,
},
]);
};
const closeTextPromptDialog = (action: 'confirm' | 'dismiss') => {
let pending:
| {
toolCallId: string;
toolName: string;
toolDisplayName: string;
waitForResponse: boolean;
}
| undefined;
let message = '';
setTextPromptDialog((prev) => {
pending = prev.pendingResult;
message = prev.message;
return { open: false, message: '' };
});
if (pending?.waitForResponse) {
emitClientToolResult(
{
tool_call_id: pending.toolCallId,
name: pending.toolName,
output: {
message: 'text_prompt_closed',
action,
msg: message,
},
status: { code: 200, message: 'ok' },
},
pending.toolDisplayName
);
}
};
const scheduleQueuedPlayback = (ctx: AudioContext) => { const scheduleQueuedPlayback = (ctx: AudioContext) => {
const queue = queuedAudioBuffersRef.current; const queue = queuedAudioBuffersRef.current;
if (queue.length === 0) return; if (queue.length === 0) return;
@@ -2492,6 +2585,9 @@ export const DebugDrawer: React.FC<{
}; };
const handleHangup = () => { const handleHangup = () => {
if (textPromptDialog.open) {
closeTextPromptDialog('dismiss');
}
stopVoiceCapture(); stopVoiceCapture();
stopMedia(); stopMedia();
closeWs(); closeWs();
@@ -2941,7 +3037,10 @@ export const DebugDrawer: React.FC<{
if (!Number.isFinite(maybeTtfb) || maybeTtfb < 0) return; if (!Number.isFinite(maybeTtfb) || maybeTtfb < 0) return;
const ttfbMs = Math.round(maybeTtfb); const ttfbMs = Math.round(maybeTtfb);
const responseId = extractResponseId(payload); const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) return; if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'ttfb');
return;
}
if (responseId) { if (responseId) {
const indexed = assistantResponseIndexByIdRef.current.get(responseId); const indexed = assistantResponseIndexByIdRef.current.get(responseId);
if (typeof indexed === 'number') { if (typeof indexed === 'number') {
@@ -2994,6 +3093,9 @@ export const DebugDrawer: React.FC<{
parsedArgs = {}; parsedArgs = {};
} }
} }
const waitForResponse = Boolean(
payload?.wait_for_response ?? toolCall?.wait_for_response ?? toolCall?.waitForResponse ?? false
);
const resultPayload: any = { const resultPayload: any = {
tool_call_id: toolCallId, tool_call_id: toolCallId,
name: toolName, name: toolName,
@@ -3004,31 +3106,21 @@ export const DebugDrawer: React.FC<{
resultPayload.output = { message: `Client tool '${toolName}' is disabled in debug settings` }; resultPayload.output = { message: `Client tool '${toolName}' is disabled in debug settings` };
resultPayload.status = { code: 503, message: 'tool_disabled' }; resultPayload.status = { code: 503, message: 'tool_disabled' };
} }
const sendToolResult = () => {
ws.send(
JSON.stringify({
type: 'tool_call.results',
results: [resultPayload],
})
);
const statusCode = Number(resultPayload?.status?.code || 500);
const statusMessage = String(resultPayload?.status?.message || 'error');
const resultText =
statusCode === 200 && typeof resultPayload?.output?.result === 'number'
? `result ${toolDisplayName} = ${resultPayload.output.result}`
: `result ${toolDisplayName} status=${statusCode} ${statusMessage}`;
setMessages((prev) => [
...prev,
{
role: 'tool',
text: resultText,
},
]);
};
try { try {
if (resultPayload.status.code === 503) { if (resultPayload.status.code === 503) {
// Keep disabled result as-is. // Keep disabled result as-is.
} else if (toolName === 'turn_on_camera') { } else if (toolName === 'turn_on_camera') {
if (!waitForResponse) {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: { message: 'camera_on_dispatched' },
status: { code: 200, message: 'ok' },
},
toolDisplayName
);
}
navigator.mediaDevices navigator.mediaDevices
.getUserMedia({ .getUserMedia({
video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true, video: selectedCamera ? { deviceId: { exact: selectedCamera } } : true,
@@ -3037,20 +3129,36 @@ export const DebugDrawer: React.FC<{
.then((stream) => { .then((stream) => {
if (videoRef.current) videoRef.current.srcObject = stream; if (videoRef.current) videoRef.current.srcObject = stream;
streamRef.current = stream; streamRef.current = stream;
resultPayload.output = { if (waitForResponse) {
message: 'camera_on', emitClientToolResult(
tracks: stream.getVideoTracks().length, {
}; tool_call_id: toolCallId,
resultPayload.status = { code: 200, message: 'ok' }; name: toolName,
sendToolResult(); output: {
message: 'camera_on',
tracks: stream.getVideoTracks().length,
},
status: { code: 200, message: 'ok' },
},
toolDisplayName
);
}
}) })
.catch((err) => { .catch((err) => {
resultPayload.output = { if (waitForResponse) {
message: `Client tool '${toolName}' failed`, emitClientToolResult(
error: err instanceof Error ? err.message : String(err), {
}; tool_call_id: toolCallId,
resultPayload.status = { code: 500, message: 'client_tool_failed' }; name: toolName,
sendToolResult(); output: {
message: `Client tool '${toolName}' failed`,
error: err instanceof Error ? err.message : String(err),
},
status: { code: 500, message: 'client_tool_failed' },
},
toolDisplayName
);
}
}); });
return; return;
} else if (toolName === 'turn_off_camera') { } else if (toolName === 'turn_off_camera') {
@@ -3085,6 +3193,36 @@ export const DebugDrawer: React.FC<{
const utterance = new SpeechSynthesisUtterance(msg); const utterance = new SpeechSynthesisUtterance(msg);
utterance.lang = 'zh-CN'; utterance.lang = 'zh-CN';
window.speechSynthesis.cancel(); window.speechSynthesis.cancel();
if (waitForResponse) {
utterance.onend = () => {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: { message: 'voice_prompt_completed', msg },
status: { code: 200, message: 'ok' },
},
toolDisplayName
);
};
utterance.onerror = (event) => {
emitClientToolResult(
{
tool_call_id: toolCallId,
name: toolName,
output: {
message: 'voice_prompt_failed',
msg,
error: String(event.error || 'speech_error'),
},
status: { code: 500, message: 'client_tool_failed' },
},
toolDisplayName
);
};
window.speechSynthesis.speak(utterance);
return;
}
window.speechSynthesis.speak(utterance); window.speechSynthesis.speak(utterance);
resultPayload.output = { message: 'voice_prompt_sent', msg }; resultPayload.output = { message: 'voice_prompt_sent', msg };
resultPayload.status = { code: 200, message: 'ok' }; resultPayload.status = { code: 200, message: 'ok' };
@@ -3098,9 +3236,22 @@ export const DebugDrawer: React.FC<{
resultPayload.output = { message: "Missing required argument 'msg'" }; resultPayload.output = { message: "Missing required argument 'msg'" };
resultPayload.status = { code: 422, message: 'invalid_arguments' }; resultPayload.status = { code: 422, message: 'invalid_arguments' };
} else { } else {
setTextPromptDialog({ open: true, message: msg }); setTextPromptDialog({
resultPayload.output = { message: 'text_prompt_shown', msg }; open: true,
resultPayload.status = { code: 200, message: 'ok' }; message: msg,
pendingResult: {
toolCallId: toolCallId,
toolName,
toolDisplayName,
waitForResponse,
},
});
if (!waitForResponse) {
resultPayload.output = { message: 'text_prompt_shown', msg };
resultPayload.status = { code: 200, message: 'ok' };
} else {
return;
}
} }
} }
} catch (err) { } catch (err) {
@@ -3110,7 +3261,7 @@ export const DebugDrawer: React.FC<{
}; };
resultPayload.status = { code: 500, message: 'client_tool_failed' }; resultPayload.status = { code: 500, message: 'client_tool_failed' };
} }
sendToolResult(); emitClientToolResult(resultPayload, toolDisplayName);
} }
return; return;
} }
@@ -3221,7 +3372,10 @@ export const DebugDrawer: React.FC<{
const delta = String(payload.text || ''); const delta = String(payload.text || '');
if (!delta) return; if (!delta) return;
const responseId = extractResponseId(payload); const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) return; if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'delta');
return;
}
setMessages((prev) => { setMessages((prev) => {
let idx = assistantDraftIndexRef.current; let idx = assistantDraftIndexRef.current;
if (idx === null || !prev[idx] || prev[idx].role !== 'model') { if (idx === null || !prev[idx] || prev[idx].role !== 'model') {
@@ -3288,7 +3442,10 @@ export const DebugDrawer: React.FC<{
if (type === 'assistant.response.final') { if (type === 'assistant.response.final') {
const finalText = String(payload.text || ''); const finalText = String(payload.text || '');
const responseId = extractResponseId(payload); const responseId = extractResponseId(payload);
if (responseId && interruptedResponseIdsRef.current.has(responseId)) return; if (responseId && interruptedResponseIdsRef.current.has(responseId)) {
noteInterruptedDrop(responseId, 'final');
return;
}
setMessages((prev) => { setMessages((prev) => {
let idx = assistantDraftIndexRef.current; let idx = assistantDraftIndexRef.current;
assistantDraftIndexRef.current = null; assistantDraftIndexRef.current = null;
@@ -3813,7 +3970,7 @@ export const DebugDrawer: React.FC<{
<div className="relative w-[92%] max-w-md rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200"> <div className="relative w-[92%] max-w-md rounded-xl border border-white/15 bg-card/95 p-4 shadow-2xl animate-in zoom-in-95 duration-200">
<button <button
type="button" type="button"
onClick={() => setTextPromptDialog({ open: false, message: '' })} onClick={() => closeTextPromptDialog('dismiss')}
className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity" className="absolute right-3 top-3 rounded-sm opacity-70 hover:opacity-100 text-muted-foreground hover:text-foreground transition-opacity"
title="关闭" title="关闭"
> >
@@ -3824,7 +3981,7 @@ export const DebugDrawer: React.FC<{
<p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">{textPromptDialog.message}</p> <p className="mt-2 text-sm leading-6 text-foreground whitespace-pre-wrap break-words">{textPromptDialog.message}</p>
</div> </div>
<div className="flex justify-end"> <div className="flex justify-end">
<Button size="sm" onClick={() => setTextPromptDialog({ open: false, message: '' })}> <Button size="sm" onClick={() => closeTextPromptDialog('confirm')}>
</Button> </Button>
</div> </div>

View File

@@ -184,6 +184,7 @@ export const ToolLibraryPage: React.FC = () => {
const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system'); const [toolCategory, setToolCategory] = useState<'system' | 'query'>('system');
const [toolIcon, setToolIcon] = useState('Wrench'); const [toolIcon, setToolIcon] = useState('Wrench');
const [toolEnabled, setToolEnabled] = useState(true); const [toolEnabled, setToolEnabled] = useState(true);
const [toolWaitForResponse, setToolWaitForResponse] = useState(false);
const [toolHttpMethod, setToolHttpMethod] = useState<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>('GET'); const [toolHttpMethod, setToolHttpMethod] = useState<'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'>('GET');
const [toolHttpUrl, setToolHttpUrl] = useState(''); const [toolHttpUrl, setToolHttpUrl] = useState('');
const [toolHttpHeadersText, setToolHttpHeadersText] = useState('{}'); const [toolHttpHeadersText, setToolHttpHeadersText] = useState('{}');
@@ -215,6 +216,7 @@ export const ToolLibraryPage: React.FC = () => {
setToolCategory('system'); setToolCategory('system');
setToolIcon('Wrench'); setToolIcon('Wrench');
setToolEnabled(true); setToolEnabled(true);
setToolWaitForResponse(false);
setToolHttpMethod('GET'); setToolHttpMethod('GET');
setToolHttpUrl(''); setToolHttpUrl('');
setToolHttpHeadersText('{}'); setToolHttpHeadersText('{}');
@@ -231,6 +233,7 @@ export const ToolLibraryPage: React.FC = () => {
setToolCategory(tool.category); setToolCategory(tool.category);
setToolIcon(tool.icon || 'Wrench'); setToolIcon(tool.icon || 'Wrench');
setToolEnabled(tool.enabled ?? true); setToolEnabled(tool.enabled ?? true);
setToolWaitForResponse(Boolean(tool.waitForResponse));
setToolHttpMethod((tool.httpMethod || 'GET') as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE'); setToolHttpMethod((tool.httpMethod || 'GET') as 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE');
setToolHttpUrl(tool.httpUrl || ''); setToolHttpUrl(tool.httpUrl || '');
setToolHttpHeadersText(JSON.stringify(tool.httpHeaders || {}, null, 2)); setToolHttpHeadersText(JSON.stringify(tool.httpHeaders || {}, null, 2));
@@ -269,6 +272,15 @@ export const ToolLibraryPage: React.FC = () => {
<Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}> <Badge variant="outline" className={`text-[10px] border-0 px-0 ${tool.category === 'system' ? 'text-primary' : 'text-blue-400'}`}>
{tool.category === 'system' ? 'SYSTEM' : 'QUERY'} {tool.category === 'system' ? 'SYSTEM' : 'QUERY'}
</Badge> </Badge>
{tool.category === 'system' && (
<Badge
variant="outline"
className={`text-[10px] h-4 px-1.5 border ${tool.waitForResponse ? 'border-amber-400/40 text-amber-300' : 'border-white/20 text-muted-foreground'}`}
title={tool.waitForResponse ? 'wait for response: ON' : 'wait for response: OFF'}
>
{tool.waitForResponse ? 'WAIT' : 'NO-WAIT'}
</Badge>
)}
<span className="text-[10px] text-muted-foreground font-mono opacity-50 truncate">ID: {tool.id}</span> <span className="text-[10px] text-muted-foreground font-mono opacity-50 truncate">ID: {tool.id}</span>
</div> </div>
<p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p> <p className="text-xs text-muted-foreground line-clamp-2 leading-relaxed opacity-80">{tool.description}</p>
@@ -371,6 +383,7 @@ export const ToolLibraryPage: React.FC = () => {
httpTimeoutMs: toolHttpTimeoutMs, httpTimeoutMs: toolHttpTimeoutMs,
parameterSchema: parsedParameterSchema, parameterSchema: parsedParameterSchema,
parameterDefaults: parsedParameterDefaults, parameterDefaults: parsedParameterDefaults,
waitForResponse: toolCategory === 'system' ? toolWaitForResponse : false,
enabled: toolEnabled, enabled: toolEnabled,
}); });
setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item))); setTools((prev) => prev.map((item) => (item.id === updated.id ? updated : item)));
@@ -387,6 +400,7 @@ export const ToolLibraryPage: React.FC = () => {
httpTimeoutMs: toolHttpTimeoutMs, httpTimeoutMs: toolHttpTimeoutMs,
parameterSchema: parsedParameterSchema, parameterSchema: parsedParameterSchema,
parameterDefaults: parsedParameterDefaults, parameterDefaults: parsedParameterDefaults,
waitForResponse: toolCategory === 'system' ? toolWaitForResponse : false,
enabled: toolEnabled, enabled: toolEnabled,
}); });
setTools((prev) => [created, ...prev]); setTools((prev) => [created, ...prev]);
@@ -573,6 +587,22 @@ export const ToolLibraryPage: React.FC = () => {
/> />
</div> </div>
{toolCategory === 'system' && (
<div className="rounded-md border border-white/10 bg-black/20 p-3 space-y-1.5">
<label className="flex items-center gap-2 text-xs text-muted-foreground">
<input
type="checkbox"
checked={toolWaitForResponse}
onChange={(e) => setToolWaitForResponse(e.target.checked)}
/>
(wait for response)
</label>
<p className="text-[11px] text-muted-foreground">
</p>
</div>
)}
<div className="space-y-4 rounded-md border border-white/10 bg-white/5 p-3"> <div className="space-y-4 rounded-md border border-white/10 bg-white/5 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-[10px] font-black uppercase tracking-widest text-emerald-300">Tool Parameters</div> <div className="text-[10px] font-black uppercase tracking-widest text-emerald-300">Tool Parameters</div>

View File

@@ -125,6 +125,7 @@ const mapTool = (raw: AnyRecord): Tool => ({
httpTimeoutMs: Number(readField(raw, ['httpTimeoutMs', 'http_timeout_ms'], 10000)), httpTimeoutMs: Number(readField(raw, ['httpTimeoutMs', 'http_timeout_ms'], 10000)),
parameterSchema: readField(raw, ['parameterSchema', 'parameter_schema'], {}), parameterSchema: readField(raw, ['parameterSchema', 'parameter_schema'], {}),
parameterDefaults: readField(raw, ['parameterDefaults', 'parameter_defaults'], {}), parameterDefaults: readField(raw, ['parameterDefaults', 'parameter_defaults'], {}),
waitForResponse: Boolean(readField(raw, ['waitForResponse', 'wait_for_response'], false)),
isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)), isSystem: Boolean(readField(raw, ['isSystem', 'is_system'], false)),
enabled: Boolean(readField(raw, ['enabled'], true)), enabled: Boolean(readField(raw, ['enabled'], true)),
isCustom: !Boolean(readField(raw, ['isSystem', 'is_system'], false)), isCustom: !Boolean(readField(raw, ['isSystem', 'is_system'], false)),
@@ -571,6 +572,7 @@ export const createTool = async (data: Partial<Tool>): Promise<Tool> => {
http_timeout_ms: data.httpTimeoutMs ?? 10000, http_timeout_ms: data.httpTimeoutMs ?? 10000,
parameter_schema: data.parameterSchema || {}, parameter_schema: data.parameterSchema || {},
parameter_defaults: data.parameterDefaults || {}, parameter_defaults: data.parameterDefaults || {},
wait_for_response: data.waitForResponse ?? false,
enabled: data.enabled ?? true, enabled: data.enabled ?? true,
}; };
const response = await apiRequest<AnyRecord>('/tools/resources', { method: 'POST', body: payload }); const response = await apiRequest<AnyRecord>('/tools/resources', { method: 'POST', body: payload });
@@ -589,6 +591,7 @@ export const updateTool = async (id: string, data: Partial<Tool>): Promise<Tool>
http_timeout_ms: data.httpTimeoutMs, http_timeout_ms: data.httpTimeoutMs,
parameter_schema: data.parameterSchema, parameter_schema: data.parameterSchema,
parameter_defaults: data.parameterDefaults, parameter_defaults: data.parameterDefaults,
wait_for_response: data.waitForResponse,
enabled: data.enabled, enabled: data.enabled,
}; };
const response = await apiRequest<AnyRecord>(`/tools/resources/${id}`, { method: 'PUT', body: payload }); const response = await apiRequest<AnyRecord>(`/tools/resources/${id}`, { method: 'PUT', body: payload });

View File

@@ -199,6 +199,7 @@ export interface Tool {
httpTimeoutMs?: number; httpTimeoutMs?: number;
parameterSchema?: Record<string, any>; parameterSchema?: Record<string, any>;
parameterDefaults?: Record<string, any>; parameterDefaults?: Record<string, any>;
waitForResponse?: boolean;
isCustom?: boolean; isCustom?: boolean;
isSystem?: boolean; isSystem?: boolean;
enabled?: boolean; enabled?: boolean;