This commit is contained in:
Timothy Jaeryang Baek 2026-10-10 21:11:33 +04:00
parent 3e0e760ce3
commit 82f142d376
6 changed files with 5434 additions and 45 deletions

View file

@ -9,6 +9,7 @@ import time
import weakref
from contextlib import suppress
from typing import Any
from uuid import uuid4
import pycrdt as Y
import socketio
@ -1470,6 +1471,21 @@ async def get_event_call(request_info):
log.warning(f'Event caller: session {session_id} not owned by requesting user or disconnected')
return {'error': 'Client session disconnected.'}
interaction_id = None
timeout = WEBSOCKET_EVENT_CALLER_TIMEOUT
if event_data.get('type') == 'request:user_input' or (
event_data.get('type') == 'confirmation' and (event_data.get('data') or {}).get('tool_call')
):
interaction_id = str(uuid4())
data = dict(event_data.get('data') or {})
timeout_ms = data.get('timeout_ms', 120_000)
if isinstance(timeout_ms, bool) or not isinstance(timeout_ms, int):
timeout_ms = 120_000
timeout = min(max(timeout_ms / 1000, 60), 240)
if WEBSOCKET_EVENT_CALLER_TIMEOUT is not None and WEBSOCKET_EVENT_CALLER_TIMEOUT > 0:
timeout = min(timeout, WEBSOCKET_EVENT_CALLER_TIMEOUT)
event_data = {**event_data, 'data': {**data, 'interaction_id': interaction_id}}
try:
return await sio.call(
'events',
@ -1479,11 +1495,22 @@ async def get_event_call(request_info):
'data': event_data,
},
to=session_id,
timeout=WEBSOCKET_EVENT_CALLER_TIMEOUT,
timeout=timeout,
)
except (TimeoutError, socketio.exceptions.TimeoutError):
log.warning(f'Event caller timed out for session {session_id}')
return {'error': 'Event call timed out. The browser tab may be inactive or closed.'}
finally:
if interaction_id:
await sio.emit(
'events',
{
'chat_id': request_info.get('chat_id'),
'message_id': request_info.get('message_id'),
'data': {'type': 'request:interaction:done', 'data': {'interaction_id': interaction_id}},
},
to=session_id,
)
if 'session_id' in request_info and 'chat_id' in request_info and 'message_id' in request_info:
return __event_caller__

View file

@ -3431,6 +3431,23 @@ async def execute_tool_call(form_data, metadata, event_caller, tool_call):
params = {key: value for key, value in params.items() if key in allowed_params}
try:
if (
not is_saved_chat_id(metadata.get('chat_id'))
and metadata.get('params', {}).get('tool_approval_mode') == 'ask'
and not (name == 'ask_user' and tool_type == 'builtin')
and (
not event_caller
or await event_caller(
{
'type': 'confirmation',
'data': {'tool_call': {'id': tool_call.get('id'), 'name': name, 'arguments': params}},
}
)
is not True # Disconnection and timeout replies are error objects, not approval.
)
):
return params, 'Error: tool call was not approved.', tool, tool_type, direct_tool
if direct_tool:
if not event_caller:
result = 'Error: Browser session is not connected for this direct tool.'
@ -5927,7 +5944,7 @@ async def streaming_chat_response_handler(response, ctx):
for tool_call in response_tool_calls
if tool_call.get('function', {}).get('name') != 'ask_user'
]
elif ask_user_staged:
elif ask_user_staged and save_to_chat:
if is_saved_chat_id(metadata.get('chat_id')) and metadata.get('message_id'):
await pause_for_tool_approval(
metadata['chat_id'],
@ -5938,6 +5955,11 @@ async def streaming_chat_response_handler(response, ctx):
)
await event_emitter({'type': 'chat:completion', 'data': {'output': full_output()}})
return
elif ask_user_staged:
# Live questions await the socket callback, not the saved-chat resolver.
for item in output:
if item.get('name') == 'ask_user' and item.get('status') == 'pending':
item['status'] = 'in_progress'
# Append function_call items for each tool call
# (Responses API already has them from streaming, so skip duplicates)

View file

@ -177,10 +177,29 @@
let eventConfirmationInputType = '';
let eventConfirmationInputOptions: ({ label?: string; value: string } | string)[] = [];
let eventCallback: (value: any) => void = () => {};
let showAskUserDialog = false;
let askUserQuestions: any[] = [];
let askUserAllowOther = true;
let askUserTimeoutMs: number | null = null;
type BrowserInteraction = {
id: string;
messageId: string;
type: string;
data: any;
callback: (value: any) => void;
};
let browserInteractions: BrowserInteraction[] = [];
let browserApprovalMode: string | null = null;
$: browserInteraction = browserInteractions[0];
const resolveBrowserInteraction = (interaction: BrowserInteraction, value: any) => {
if (!browserInteractions.includes(interaction)) return;
browserInteractions = browserInteractions.filter((item) => item !== interaction);
interaction.callback(value);
};
const cancelBrowserInteractions = () => {
browserApprovalMode = null;
for (const interaction of browserInteractions) {
resolveBrowserInteraction(interaction, { status: 'cancelled', answers: {} });
}
};
let selectedModels = [''];
let selectedModelIdx = 0;
@ -461,6 +480,7 @@
const handleToolApprovalModeChange = async (mode: string) => {
const tool_approval_mode = mode === 'ask' ? 'ask' : 'full';
browserApprovalMode = tool_approval_mode;
params = {
...params,
tool_approval_mode
@ -488,6 +508,13 @@
}
if (tool_approval_mode === 'full') {
if (isTemporaryChatId($chatId)) {
if (browserApprovalMode !== 'full') return;
for (const interaction of browserInteractions) {
if (interaction.data?.tool_call) resolveBrowserInteraction(interaction, true);
}
return;
}
const messages = [...Object.values(history?.messages ?? {})].reverse() as any[];
for (const message of messages) {
if ((message.user_id ?? chat?.user_id ?? $user?.id) !== $user?.id) continue;
@ -649,20 +676,16 @@
}
: null;
$: socketAskUserPrompt = {
show: showAskUserDialog,
questions: askUserQuestions,
allowOther: askUserAllowOther,
timeoutMs: askUserTimeoutMs,
onConfirm: (value) => {
showAskUserDialog = false;
eventCallback(value);
},
onCancel: () => {
showAskUserDialog = false;
eventCallback({ status: 'cancelled', answers: {} });
}
};
$: socketAskUserPrompt = ((interaction: BrowserInteraction | undefined) => ({
id: interaction?.id,
show: interaction?.type === 'request:user_input',
questions: interaction?.data?.questions ?? [],
allowOther: interaction?.data?.allow_other ?? true,
timeoutMs: interaction?.data?.timeout_ms ?? null,
onConfirm: (value: any) => interaction && resolveBrowserInteraction(interaction, value),
onCancel: () =>
interaction && resolveBrowserInteraction(interaction, { status: 'cancelled', answers: {} })
}))(browserInteraction);
const mergeChatVariableSchemas = (modelIds = [], availableModels = []) => {
const byKey: Record<string, any> = {};
@ -840,6 +863,7 @@
}
const navigateHandler = async () => {
cancelBrowserInteractions();
bridge?.end();
noteChatDebug('navigateHandler start');
// Mark the outgoing chat as read before loading the new one.
@ -1382,6 +1406,43 @@
const chatEventHandler = async (event, cb) => {
console.log(event);
if (event.shared && event.user_id === $user?.id && event.data?.type !== 'chat:messages') return;
const interactionType = event?.data?.type;
const interactionData = event?.data?.data;
if (interactionType === 'request:interaction:done') {
browserInteractions = browserInteractions.filter(
(item) => item.id !== interactionData?.interaction_id
);
return;
}
if (
interactionType === 'request:user_input' ||
(interactionType === 'confirmation' && interactionData?.tool_call)
) {
if (!cb) return;
if (event.chat_id !== $chatId || !history.messages[event.message_id]) {
cb({ status: 'cancelled', answers: {} });
return;
}
if (
interactionData?.tool_call &&
isTemporaryChatId(event.chat_id) &&
browserApprovalMode === 'full'
) {
cb(true);
return;
}
browserInteractions = [
...browserInteractions,
{
id: interactionData?.interaction_id ?? uuidv4(),
messageId: event.message_id,
type: interactionType,
data: interactionData,
callback: cb
}
];
return;
}
// A new chat's title can arrive before its id; the response message already exists.
if (
@ -1466,6 +1527,11 @@
}
autoScrollToBottom();
} else if (type === 'chat:tasks:cancel') {
for (const interaction of browserInteractions) {
if (interaction.messageId === event.message_id) {
resolveBrowserInteraction(interaction, { status: 'cancelled', answers: {} });
}
}
message.bridgeCancelled = true;
bridgeCancellations.get(event.message_id)?.();
if (!event.shared) dismissContextCompactionToast();
@ -1629,13 +1695,6 @@
eventConfirmationInputValue = data?.value ?? '';
eventConfirmationInputType = data?.input?.type ?? data?.type ?? '';
eventConfirmationInputOptions = data?.input?.options ?? data?.options ?? [];
} else if (type === 'request:user_input') {
eventCallback = cb;
askUserQuestions = data?.questions ?? [];
askUserAllowOther = data?.allow_other ?? true;
askUserTimeoutMs =
typeof data?.timeout_ms === 'number' && data.timeout_ms > 0 ? data.timeout_ms : null;
showAskUserDialog = true;
} else if (type.startsWith('terminal:')) {
terminalEventHandler(type, data);
} else {
@ -1761,6 +1820,7 @@
$socket?.on('events', chatEventHandler);
$socket?.on('connect', handleSocketConnect);
$socket?.on('disconnect', clearTyping);
$socket?.on('disconnect', cancelBrowserInteractions);
$audioQueue?.destroy();
@ -1842,6 +1902,7 @@
return () => {
try {
cancelBrowserInteractions();
clearTimeout(saveControlsTimer);
saveControls();
if (chatIdProp && !$temporaryChatEnabled) {
@ -1866,6 +1927,7 @@
$socket?.off('events', chatEventHandler);
$socket?.off('connect', handleSocketConnect);
$socket?.off('disconnect', clearTyping);
$socket?.off('disconnect', cancelBrowserInteractions);
dismissContextCompactionToast();
audioQueueInstance?.destroy();
audioQueue.set(null);
@ -2402,6 +2464,7 @@
};
const initNewChat = async () => {
cancelBrowserInteractions();
bridge?.end();
console.log('initNewChat');
resetWebSearchConfirmation();
@ -4199,6 +4262,7 @@
const responseMessage = messageId ? history.messages[messageId] : null;
if (responseMessage && (responseMessage.user_id ?? chat?.user_id ?? $user?.id) !== $user?.id)
return;
cancelBrowserInteractions();
if (bridge?.connected && responseMessage) responseMessage.bridgeStopping = true;
const hasTaskIds = (taskIds?.length ?? 0) > 0;
const hasPendingAssistantResponse =
@ -4741,6 +4805,29 @@
}}
/>
{#if browserInteraction?.data?.tool_call}
{#key browserInteraction.id}
{@const interaction = browserInteraction}
<EventConfirmDialog
show={true}
title={$i18n.t('Tool Permissions')}
confirmLabel={$i18n.t('Allow')}
cancelLabel={$i18n.t('Deny')}
on:confirm={() => resolveBrowserInteraction(interaction, true)}
on:cancel={() => resolveBrowserInteraction(interaction, false)}
>
<div class="text-sm text-gray-500">
<div class="font-medium break-all">{interaction.data.tool_call.name}</div>
<pre class="mt-2 max-h-64 overflow-auto whitespace-pre-wrap break-all">{JSON.stringify(
interaction.data.tool_call.arguments,
null,
2
)}</pre>
</div>
</EventConfirmDialog>
{/key}
{/if}
<div
class="{embedded
? 'h-full'

View file

@ -125,6 +125,7 @@
const i18n: any = getContext('i18n');
type AskUserPrompt = {
id?: string;
show: boolean;
questions: any[];
allowOther: boolean;
@ -1813,18 +1814,21 @@
{#if askUser?.show}
<div class="mx-1">
<AskUserCard
show={askUser.show}
questions={askUser.questions}
allowOther={askUser.allowOther}
timeoutMs={askUser.timeoutMs}
on:confirm={(e) => {
askUser.onConfirm(e.detail);
}}
on:cancel={() => {
askUser.onCancel();
}}
/>
{#key askUser.id}
{@const activeAskUser = askUser}
<AskUserCard
show={askUser.show}
questions={askUser.questions}
allowOther={askUser.allowOther}
timeoutMs={askUser.timeoutMs}
on:confirm={(e) => {
activeAskUser.onConfirm(e.detail);
}}
on:cancel={() => {
activeAskUser.onCancel();
}}
/>
{/key}
</div>
{/if}

View file

@ -1425,11 +1425,9 @@
<AdvancedParams admin={true} custom={true} layout="grid" bind:params />
</fieldset>
{/if}
{#if admin}
<fieldset disabled={readOnly} class="min-w-0">
<ModelControls bind:controls={params.model_controls} />
</fieldset>
{/if}
<fieldset disabled={readOnly} class="min-w-0">
<ModelControls bind:controls={params.model_controls} />
</fieldset>
</div>
</section>

File diff suppressed because one or more lines are too long