This commit is contained in:
Timothy Jaeryang Baek 2026-10-12 18:11:33 +04:00
parent a9227421d8
commit 774007d728
7 changed files with 60 additions and 60 deletions

View file

@ -124,8 +124,8 @@ class AppUpdateForm(StrictModel):
access_grants: list[dict] | None = None
class AppContextForm(StrictModel):
context: str = Field(max_length=100000)
class AppTokenForm(StrictModel):
token: str = Field(max_length=100000)
class AppConnectionForm(StrictModel):

View file

@ -12,12 +12,12 @@ from open_webui.models.apps import (
AppAccessResponse,
AppChannelMessage,
AppConnectionForm,
AppContextForm,
AppForm,
AppInteractionForm,
AppManifestForm,
AppResponse,
Apps,
AppTokenForm,
AppUpdateForm,
Manifest,
)
@ -30,7 +30,7 @@ from open_webui.utils.apps import (
check_app_invocation_access,
create_app_message,
decode_app_context_token,
generate_app_response,
generate_app_channel_reply,
get_app_by_id,
get_app_encryption_cipher,
get_app_message_data,
@ -187,9 +187,9 @@ async def get_app_channels(app=Depends(get_verified_app), db: AsyncSession = Dep
@router.post('/runtime/authorize')
async def authorize_app_execution(
form_data: AppContextForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
form_data: AppTokenForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
):
value = decode_app_context_token(app, 'execution', form_data.context)
value = decode_app_context_token(app, 'execution', form_data.token)
user = await Users.get_user_by_id(value['actor_id'], db=db)
if not user:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Actor is unavailable')
@ -212,9 +212,9 @@ async def authorize_app_execution(
@router.post('/runtime/events/verify')
async def verify_app_event(
form_data: AppContextForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
form_data: AppTokenForm, app=Depends(get_verified_app), db: AsyncSession = Depends(get_async_session)
):
value = decode_app_context_token(app, 'event', form_data.context)
value = decode_app_context_token(app, 'event', form_data.token)
await check_app_channel_membership(app, value['channel']['id'], db=db)
require_scope(app, 'messages:read')
return {k: v for k, v in value.items() if k not in ('purpose', 'exp', 'nonce')}
@ -450,7 +450,7 @@ async def create_app_interaction(
action = next((item for item in actions if item['action_id'] == form_data.action_id), None)
if not action:
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail='Unknown action')
return await generate_app_response(
return await generate_app_channel_reply(
app,
user,
message.channel_id,
@ -479,11 +479,11 @@ async def get_app_operation(
if value['actor_id'] != user.id or value['operation_id'] != operation_id:
raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail='Invalid operation receipt')
await check_app_invocation_access(app, value['channel_id'], user, db=db)
return await generate_app_response(
return await generate_app_channel_reply(
app,
user,
value['channel_id'],
'operation_status',
'channel.operation.status',
{'operation_id': operation_id},
operation_id=operation_id,
db=db,

View file

@ -52,7 +52,7 @@ from open_webui.tasks import create_task
from open_webui.utils.access_control import filter_allowed_access_grants, has_permission
from open_webui.utils.apps import (
check_app_channel_access,
generate_app_response,
generate_app_channel_reply,
get_app_by_id,
get_app_response,
has_app_access,
@ -1090,7 +1090,7 @@ async def send_notification(request, channel, message, active_user_ids, db=None)
return True
async def app_response_handler(channel_id, message_id, user_id):
async def app_channel_reply_handler(channel_id, message_id, user_id):
"""Committed human messages only; errors never cause the human POST to retry."""
if not await Config.get('apps.enable'):
return
@ -1110,7 +1110,7 @@ async def app_response_handler(channel_id, message_id, user_id):
user = await Users.get_user_by_id(user_id, db=db)
if not user:
continue
value = await generate_app_response(
value = await generate_app_channel_reply(
app,
user,
channel_id,
@ -1135,7 +1135,7 @@ async def app_response_handler(channel_id, message_id, user_id):
'channel_id': channel_id,
'message_id': message_id,
'parent_id': parent_id,
'response': value,
'reply': value,
},
[user_id],
)
@ -1409,7 +1409,7 @@ async def post_new_message(
# Background tasks should manage their own short-lived sessions to avoid
# holding database connections during slow operations (e.g., LLM calls).
async def background_handler():
await create_task(None, app_response_handler(channel.id, message.id, user.id))
await create_task(None, app_channel_reply_handler(channel.id, message.id, user.id))
await model_response_handler(request, channel, message, user)
await send_notification(
request,

View file

@ -375,7 +375,7 @@ async def send_app_message_event(message, kind, created_at):
'webui_url': app.data['webui_url'],
'channel': {'id': message['channel_id']},
}
envelope['event_context'] = create_app_context_token(app, 'event', envelope, expires_in=300)
envelope['event_token'] = create_app_context_token(app, 'event', envelope, expires_in=300)
status, _ = await send_app_event(app, envelope, timeout=10)
if not 200 <= status < 300:
log.warning('App %s did not acknowledge %s: HTTP %s', app_id, kind, status)
@ -452,13 +452,13 @@ async def update_app_message(db, app, message_id, form, if_match):
return value
async def generate_app_response(
async def generate_app_channel_reply(
app, user, channel_id, event_type, data, *, db: AsyncSession, source=None, operation_id=None, scope=None
):
await check_app_invocation_access(app, channel_id, user, db=db)
require_scope(app, scope)
operation_id = operation_id or str(uuid.uuid4())
context = create_app_context_token(
token = create_app_context_token(
app,
'execution',
{
@ -477,7 +477,7 @@ async def generate_app_response(
'actor': {'id': user.id, 'is_admin': user.role == 'admin'},
'channel': {'id': channel_id},
'data': data,
'execution_context': context,
'execution_token': token,
'source': source,
}
checked_key = (app.api_key, app.data['manifest']['public_key'])
@ -500,7 +500,7 @@ async def generate_app_response(
except Exception:
raise HTTPException(502, 'App returned an invalid response') from None
await check_app_invocation_access(app, channel_id, user, db=db)
if event_type == 'operation_status' and (reply.visibility != 'private' or reply.type == 'update'):
if event_type == 'channel.operation.status' and (reply.visibility != 'private' or reply.type == 'update'):
raise HTTPException(502, 'Deferred results must be private; apps publish separately through the message API')
if reply.actions and (reply.visibility != 'channel' or reply.type not in ('message', 'update')):
raise HTTPException(502, 'Buttons require a channel message')

View file

@ -4,7 +4,7 @@
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
export let appId: string;
export let channelId: string;
export let response: any;
export let reply: any;
export let source: any = null;
const dispatch = createEventDispatcher();
let busy = false,
@ -22,12 +22,12 @@
action_id: actionId
});
if (result.visibility !== 'channel' || result.type === 'error') {
dispatch('response', {
dispatch('reply', {
app_id: appId,
app_name: source.user?.name,
channel_id: channelId,
message_id: source.id,
response: result
reply: result
});
}
} catch (e) {
@ -37,17 +37,17 @@
}
}
async function poll() {
if (disposed || response?.type !== 'deferred') return;
if (disposed || reply?.type !== 'deferred') return;
if (!document.hidden) {
try {
const result = await getAppOperationById(
localStorage.token,
appId,
response.operation_id,
response.receipt
reply.operation_id,
reply.receipt
);
if (disposed) return;
response = result;
reply = result;
} catch (e) {
error = String(e);
return;
@ -58,16 +58,16 @@
}
function download() {
const url = URL.createObjectURL(
new Blob([response.content], { type: 'text/markdown;charset=utf-8' })
new Blob([reply.content], { type: 'text/markdown;charset=utf-8' })
);
const link = document.createElement('a');
link.href = url;
link.download = response.filename;
link.download = reply.filename;
link.click();
URL.revokeObjectURL(url);
}
onMount(() => {
if (response?.type === 'deferred') timer = setTimeout(poll, 1000);
if (reply?.type === 'deferred') timer = setTimeout(poll, 1000);
});
onDestroy(() => {
disposed = true;
@ -76,20 +76,20 @@
</script>
<div class="space-y-3 text-sm" aria-busy={busy}>
{#if response?.title}<h3 class="font-medium">{response.title}</h3>{/if}
{#if response?.content}<Markdown content={response.content} allowEmbeds={false} />{/if}
{#if response?.type === 'error'}<p role="alert">
{#if reply?.title}<h3 class="font-medium">{reply.title}</h3>{/if}
{#if reply?.content}<Markdown content={reply.content} allowEmbeds={false} />{/if}
{#if reply?.type === 'error'}<p role="alert">
The app could not complete this operation.
</p>{/if}
{#if error}<p role="alert" class="text-red-600">{error}</p>{/if}
{#if response?.type === 'deferred'}<p role="status" class="text-gray-500">Working…</p>{/if}
{#if response?.type === 'download'}<button
{#if reply?.type === 'deferred'}<p role="status" class="text-gray-500">Working…</p>{/if}
{#if reply?.type === 'download'}<button
class="focus-ring flex h-7 shrink-0 items-center justify-center gap-1.5 rounded-lg bg-gray-900 px-2.5 text-xs font-normal text-white transition hover:bg-black disabled:opacity-60 dark:bg-gray-100 dark:text-gray-900 dark:hover:bg-white"
on:click={download}>Download {response.filename}</button
on:click={download}>Download {reply.filename}</button
>{/if}
{#if response?.actions?.length}
{#if reply?.actions?.length}
<div class="flex flex-wrap items-center gap-1.5">
{#each response.actions as item, index}
{#each reply.actions as item, index}
<button
type="button"
disabled={busy}

View file

@ -14,7 +14,7 @@
import { settings, socket, user } from '$lib/stores';
import Message from './Messages/Message.svelte';
import AppResponse from './AppResponse.svelte';
import AppChannelReply from './AppChannelReply.svelte';
import Loader from '../common/Loader.svelte';
import Spinner from '../common/Spinner.svelte';
import {
@ -41,24 +41,24 @@
export let onPin: Function = () => {};
let messagesLoading = false;
let appResponses: Record<string, any> = {};
let responseLocation = '';
$: if (responseLocation !== `${channel?.id}:${id}`) {
responseLocation = `${channel?.id}:${id}`;
appResponses = {};
let appReplies: Record<string, any> = {};
let replyLocation = '';
$: if (replyLocation !== `${channel?.id}:${id}`) {
replyLocation = `${channel?.id}:${id}`;
appReplies = {};
}
const receiveAppResponse = (event) => {
const receiveAppChannelReply = (event) => {
if (event.channel_id !== channel?.id || (event.parent_id ?? null) !== (thread ? id : null))
return;
const key = `${event.message_id}:${event.app_id}`;
appResponses = { ...appResponses, [key]: event };
appReplies = { ...appReplies, [key]: event };
};
onMount(() => {
const appSocket = $socket;
appSocket?.on('events:app', receiveAppResponse);
return () => appSocket?.off('events:app', receiveAppResponse);
appSocket?.on('events:app', receiveAppChannelReply);
return () => appSocket?.off('events:app', receiveAppChannelReply);
});
const loadMoreMessages = async () => {
@ -148,8 +148,8 @@
{#each messageList as message, messageIdx (id ? `${id}-${message.id}` : message.id)}
<Message
on:response={(event) =>
receiveAppResponse({ ...event.detail, parent_id: thread ? id : null })}
on:reply={(event) =>
receiveAppChannelReply({ ...event.detail, parent_id: thread ? id : null })}
{message}
{channel}
{thread}
@ -278,10 +278,10 @@
}
}}
/>
{#each Object.entries(appResponses).filter(([, event]) => event.message_id === message.id) as [key, event] (key)}
{#each Object.entries(appReplies).filter(([, event]) => event.message_id === message.id) as [key, event] (key)}
<div
class="flex gap-3 px-4 pt-3 pb-2"
data-app-response={event.app_id}
data-app-reply={event.app_id}
dir={$settings.chatDirection}
>
<span
@ -296,11 +296,11 @@
<span class="text-[0.65rem] text-gray-500">{$i18n.t('App')}</span>
</div>
<div class="[&_.markdown-prose]:text-sm! [&_.markdown-prose]:leading-[1.375rem]!">
{#key event.response}
<AppResponse
{#key event.reply}
<AppChannelReply
appId={event.app_id}
channelId={channel.id}
response={event.response}
reply={event.reply}
/>
{/key}
</div>
@ -311,8 +311,8 @@
type="button"
class="focus-ring rounded hover:text-gray-800 hover:underline dark:hover:text-gray-200"
on:click={() => {
delete appResponses[key];
appResponses = { ...appResponses };
delete appReplies[key];
appReplies = { ...appReplies };
}}>{$i18n.t('Dismiss')}</button
>
</div>

View file

@ -23,7 +23,7 @@
import { WEBUI_API_BASE_URL, WEBUI_BASE_URL } from '$lib/constants';
import { getMessageData } from '$lib/apis/channels';
import AppResponse from '../AppResponse.svelte';
import AppChannelReply from '../AppChannelReply.svelte';
import Markdown from '$lib/components/chat/Messages/Markdown.svelte';
import StructuredOutputRenderer from '$lib/components/chat/Messages/StructuredOutputRenderer.svelte';
import { buildOutputDisplayItems } from '$lib/components/chat/Messages/structuredOutput';
@ -589,7 +589,7 @@
{#if message.app_id && message?.data?.app}
<div class="mt-2">
<AppResponse on:response appId={message.app_id} channelId={channel.id} source={message} response={{ actions: message.data.app.actions }} />
<AppChannelReply on:reply appId={message.app_id} channelId={channel.id} source={message} reply={{ actions: message.data.app.actions }} />
</div>
{/if}