This commit is contained in:
Timothy Jaeryang Baek
2026-07-16 00:30:44 -04:00
parent c1e228d6ad
commit b23ddeb280
8 changed files with 543 additions and 49 deletions
+12 -3
View File
@@ -1389,6 +1389,15 @@ async def chat_completion(
'content_preview': user_message.get('content', '')[:300],
},
)
if not getattr(request.state, 'internal', False) and not (
user_message.get('meta') or {}
).get('internal'):
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(chat_id, 'chat.user_message')
except Exception:
log.exception('Failed to cancel chat.user_message timers for chat %s', chat_id)
# Link grandparent → user message (childrenIds)
grandparent_id = user_message.get('parentId')
@@ -1607,9 +1616,9 @@ async def chat_completion(
and getattr(request.state, 'internal', False) is not True
and not await has_active_tasks(request.app.state.redis, chat_id)
):
from open_webui.utils.subagents import process_pending_subagent_results
from open_webui.utils.subagents import process_pending_internal_messages
await process_pending_subagent_results(
await process_pending_internal_messages(
request,
chat_id,
user.id,
@@ -1626,7 +1635,7 @@ async def chat_completion(
},
)
except Exception:
log.exception('Failed to process pending sub-agent results for chat %s', metadata.get('chat_id'))
log.exception('Failed to process pending internal messages for chat %s', metadata.get('chat_id'))
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
+6
View File
@@ -529,6 +529,12 @@ async def chat_events(sid, data):
if event_type == 'last_read_at':
await Chats.update_chat_last_read_at_by_id(data['chat_id'], user['id'])
try:
from open_webui.utils.timers import cancel_timers_for_chat
await cancel_timers_for_chat(data['chat_id'], 'chat.read')
except Exception:
log.exception('Failed to cancel chat.read timers for chat %s', data.get('chat_id'))
def normalize_document_id(document_id: str) -> str:
+38 -1
View File
@@ -12,7 +12,7 @@ import asyncio
import json
import logging
import time
from typing import Optional
from typing import Literal, Optional
from fastapi import Request
@@ -1519,6 +1519,43 @@ async def delegate_task(
)
async def timer(
prompt: str,
at: str,
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None = None,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
__chat_id__: str = None,
__message_id__: str = None,
) -> str:
"""
Set a one-shot timer for this chat.
:param prompt: The prompt to send back into this chat when the timer fires
:param at: Relative time like 10s, 5m, 1h, 2d, or a timezone-aware RFC 3339 timestamp
:param cancel_on: Optional events that cancel the timer before it fires
:return: JSON status with the scheduled time, or an error string
"""
if __request__ is None:
return 'Error: request context not available.'
if getattr(__request__.state, 'internal', False) is True:
return 'Error: timers cannot be set from internal chats.'
from open_webui.utils.timers import create_timer
return await create_timer(
prompt=prompt,
at=at,
cancel_on=cancel_on,
request=__request__,
user_data=__user__ or {},
metadata=__metadata__ or {},
parent_chat_id=__chat_id__ or '',
parent_message_id=__message_id__,
)
# =============================================================================
# CHANNELS TOOLS
# =============================================================================
+11
View File
@@ -7,6 +7,7 @@ Follows the utils/<feature>.py pattern (cf. utils/channels.py, utils/task.py).
The scheduler_worker_loop handles all time-based background work:
- Automation execution (claim_due → execute)
- Calendar event alerts (upcoming events → socket + webhook notifications)
- One-shot chat timers
Environment:
SCHEDULER_POLL_INTERVAL seconds between polls (default: 10)
@@ -190,8 +191,18 @@ async def scheduler_worker_loop(app) -> None:
SCHEDULER_POLL_INTERVAL env var (default: 10 seconds).
"""
log.info(f'Scheduler worker started (poll interval: {SCHEDULER_POLL_INTERVAL}s)')
while True:
try:
# ── Timers ──
try:
from open_webui.utils.timers import claim_due_timers, execute_due_timer
for timer_id, claim_id in await claim_due_timers(int(time.time_ns()), limit=10):
asyncio.create_task(execute_due_timer(app, timer_id, claim_id))
except Exception:
log.exception('Scheduler: timer error')
# ── Automations ──
if await Config.get('automations.enable'):
try:
+62 -27
View File
@@ -67,7 +67,7 @@ def _build_request(source: Request, user_id: str, *, internal: bool) -> Request:
return request
async def process_pending_subagent_results(
async def process_pending_internal_messages(
source_request: Request,
parent_chat_id: str,
user_id: str,
@@ -92,40 +92,74 @@ async def process_pending_subagent_results(
message
for message in messages.values()
if message.get('role') == 'user'
and (message.get('meta') or {}).get('async_subagent_result') is True
and not message.get('childrenIds')
and (
(message.get('meta') or {}).get('async_subagent_result') is True
or (
(message.get('meta') or {}).get('internal') is True
and (message.get('meta') or {}).get('type') == 'timer'
)
)
]
if not pending:
return
first = pending[0]
first_meta = first.get('meta') or {}
kind = 'subagent' if first_meta.get('async_subagent_result') is True else 'timer'
parent_id = first.get('parentId')
if kind == 'timer' and first_meta.get('timer_id'):
timer = await Chats.get_chat_by_id(first_meta['timer_id'])
run = {**run, **(((timer.meta or {}).get('run') if timer else None) or {})}
model_id = first.get('model') or run['model_id']
batch = [
message
for message in pending
if message.get('parentId') == parent_id and (message.get('model') or model_id) == model_id
]
if kind == 'timer':
batch = [
message
for message in pending
if message.get('parentId') == parent_id
and (message.get('model') or model_id) == model_id
and (message.get('meta') or {}).get('internal') is True
and (message.get('meta') or {}).get('type') == 'timer'
]
else:
batch = [
message
for message in pending
if message.get('parentId') == parent_id
and (message.get('model') or model_id) == model_id
and (message.get('meta') or {}).get('async_subagent_result') is True
]
combined_content = '\n\n'.join(message.get('content', '') for message in batch if message.get('content'))
delegation_ids = [
message['meta']['delegation_id'] for message in batch if (message.get('meta') or {}).get('delegation_id')
]
subagent_chat_ids = [
message['meta']['subagent_chat_id']
for message in batch
if (message.get('meta') or {}).get('subagent_chat_id')
]
combined_meta = {'async_subagent_result': True}
if len(delegation_ids) == 1:
combined_meta['delegation_id'] = delegation_ids[0]
elif delegation_ids:
combined_meta['delegation_ids'] = delegation_ids
if len(subagent_chat_ids) == 1:
combined_meta['subagent_chat_id'] = subagent_chat_ids[0]
elif subagent_chat_ids:
combined_meta['subagent_chat_ids'] = subagent_chat_ids
if kind == 'timer':
timer_ids = [message['meta']['timer_id'] for message in batch if (message.get('meta') or {}).get('timer_id')]
combined_meta = {'internal': True, 'type': 'timer'}
if len(timer_ids) == 1:
combined_meta['timer_id'] = timer_ids[0]
elif timer_ids:
combined_meta['timer_ids'] = timer_ids
else:
delegation_ids = [
message['meta']['delegation_id']
for message in batch
if (message.get('meta') or {}).get('delegation_id')
]
subagent_chat_ids = [
message['meta']['subagent_chat_id']
for message in batch
if (message.get('meta') or {}).get('subagent_chat_id')
]
combined_meta = {'async_subagent_result': True}
if len(delegation_ids) == 1:
combined_meta['delegation_id'] = delegation_ids[0]
elif delegation_ids:
combined_meta['delegation_ids'] = delegation_ids
if len(subagent_chat_ids) == 1:
combined_meta['subagent_chat_id'] = subagent_chat_ids[0]
elif subagent_chat_ids:
combined_meta['subagent_chat_ids'] = subagent_chat_ids
reuse_message = len(batch) == 1 and not (first.get('meta') or {}).get('async_subagent_pending')
pending_flag = 'timer_pending' if kind == 'timer' else 'async_subagent_pending'
reuse_message = len(batch) == 1 and not (first.get('meta') or {}).get(pending_flag)
user_message_id = first['id'] if reuse_message else str(uuid4())
if not reuse_message:
removed_ids = {message['id'] for message in batch}
@@ -211,12 +245,13 @@ async def process_pending_subagent_results(
'id': assistant_message_id,
'parent_id': parent_id,
'user_message': user_message,
'session_id': run.get('session_id') or f'subagent-result:{parent_chat_id}',
'session_id': run.get('session_id') or f'{kind}-result:{parent_chat_id}',
'background_tasks': {},
'tool_ids': run.get('tool_ids') or [],
'skill_ids': run.get('skill_ids') or [],
'filter_ids': run.get('filter_ids') or [],
'features': run.get('features') or {},
'files': run.get('files') or [],
'variables': run.get('variables') or {},
}
if run.get('terminal_id'):
@@ -582,7 +617,7 @@ async def delegate(
room=f'user:{user.id}',
)
if not await has_active_tasks(request.app.state.redis, parent_chat_id):
await process_pending_subagent_results(request, parent_chat_id, user.id, run)
await process_pending_internal_messages(request, parent_chat_id, user.id, run)
if cancelled:
raise asyncio.CancelledError
return result
+355
View File
@@ -0,0 +1,355 @@
"""Durable one-shot timers backed by internal child chats."""
from __future__ import annotations
import asyncio
import copy
import json
import logging
import re
import time
from datetime import datetime, timezone
from typing import Literal
from uuid import uuid4
from fastapi import Request
from sqlalchemy import select
from starlette.datastructures import Headers
from open_webui.internal.db import get_async_db
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chat, ChatForm, Chats
from open_webui.models.users import UserModel, Users
from open_webui.tasks import has_active_tasks
log = logging.getLogger(__name__)
_RELATIVE_TIME = re.compile(r"^(?:\+|in\s+)?(\d+)\s*(s|sec(?:onds?)?|m|min(?:utes?)?|h|hours?|d|days?)$")
_RFC3339_TIME = re.compile(r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$")
_TIME_UNITS_NS = {
's': 1_000_000_000,
'm': 60 * 1_000_000_000,
'h': 60 * 60 * 1_000_000_000,
'd': 24 * 60 * 60 * 1_000_000_000,
}
_timer_locks: dict[str, asyncio.Lock] = {}
def parse_timer_at(value: str) -> int:
"""Normalize a relative offset or timezone-aware RFC 3339 timestamp."""
raw = value.strip()
now = time.time_ns()
relative = _RELATIVE_TIME.fullmatch(raw.lower())
if relative:
count = int(relative.group(1))
if count <= 0:
raise ValueError('at must be in the future.')
return now + count * _TIME_UNITS_NS[relative.group(2)[0]]
if not _RFC3339_TIME.fullmatch(raw):
raise ValueError(
'at must be a relative time such as 10s or in 10 seconds, '
'or an RFC 3339 timestamp with a timezone.'
)
try:
parsed = datetime.fromisoformat(raw.replace('Z', '+00:00'))
except ValueError as exc:
raise ValueError(
'at must be a relative time such as 10s or in 10 seconds, '
'or an RFC 3339 timestamp with a timezone.'
) from exc
if parsed.tzinfo is None or parsed.utcoffset() is None:
raise ValueError('absolute at values must include an explicit timezone.')
due_at = int(parsed.timestamp() * 1_000_000_000)
if due_at <= now:
raise ValueError('at must be in the future.')
return due_at
async def create_timer(
*,
prompt: str,
at: str,
cancel_on: list[Literal['chat.read', 'chat.user_message']] | None,
request: Request,
user_data: dict,
metadata: dict,
parent_chat_id: str,
parent_message_id: str | None,
) -> str:
prompt = prompt.strip()
if not prompt:
return 'Error: prompt must not be empty.'
if not parent_chat_id or not user_data.get('id'):
return 'Error: chat and user context are required.'
try:
due_at = parse_timer_at(at)
except ValueError as exc:
return f'Error: {exc}'
selected_events = cancel_on or []
allowed_events = {'chat.read', 'chat.user_message'}
if any(event not in allowed_events for event in selected_events):
return 'Error: cancel_on accepts only chat.read and chat.user_message.'
selected_events = list(dict.fromkeys(selected_events))
model_id = metadata.get('model_id') or (metadata.get('model') or {}).get('id')
if not model_id:
return 'Error: model context is required.'
if metadata.get('direct'):
return 'Error: timers are unavailable for direct connections.'
chat_id = str(uuid4())
user_message_id = str(uuid4())
user = UserModel(**user_data)
run = {
'model_id': model_id,
'session_id': metadata.get('session_id'),
'tool_ids': copy.deepcopy(metadata.get('tool_ids') or []),
'skill_ids': copy.deepcopy(metadata.get('skill_ids') or []),
'system_prompt': metadata.get('system_prompt'),
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
'terminal_id': metadata.get('terminal_id'),
'features': copy.deepcopy(metadata.get('features') or {}),
'files': copy.deepcopy(metadata.get('files') or []),
'variables': copy.deepcopy(metadata.get('variables') or {}),
}
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': f'Timer: {prompt[:60]}',
'models': [model_id],
'history': {
'currentId': user_message_id,
'messages': {
user_message_id: {
'id': user_message_id,
'parentId': None,
'childrenIds': [],
'role': 'user',
'content': prompt,
'timestamp': int(time.time()),
'models': [model_id],
},
},
},
'messages': [{'role': 'user', 'content': prompt}],
}
),
internal_meta={
'internal': True,
'type': 'timer',
'parent_chat_id': parent_chat_id,
'parent_message_id': parent_message_id,
'timer_at': due_at,
'timer_status': 'pending',
'timer_model_id': model_id,
'timer_task_message_id': user_message_id,
'cancel_on': selected_events,
'run': run,
},
)
if not chat:
return 'Error: failed to create timer.'
return json.dumps(
{
'status': 'set',
'at': datetime.fromtimestamp(due_at / 1_000_000_000, timezone.utc).isoformat().replace('+00:00', 'Z'),
'cancel_on': selected_events,
},
ensure_ascii=False,
)
async def claim_due_timers(now_ns: int, limit: int = 10) -> list[tuple[str, str]]:
"""Claim due timers by moving them from pending to running."""
async with get_async_db() as db:
stmt = (
select(Chat)
.where(Chat.meta['internal'].as_boolean().is_(True))
.where(Chat.meta['type'].as_string() == 'timer')
.where(Chat.meta['timer_status'].as_string() == 'pending')
)
if db.bind.dialect.name == 'postgresql':
stmt = stmt.with_for_update(skip_locked=True)
result = await db.execute(stmt)
rows = [
row
for row in result.scalars().all()
if int((row.meta or {}).get('timer_at') or 0) <= now_ns
]
rows.sort(key=lambda row: int((row.meta or {}).get('timer_at') or 0))
rows = rows[:limit]
claimed = []
for row in rows:
claim_id = str(uuid4())
row.meta = {
**(row.meta or {}),
'timer_status': 'running',
'timer_started_at': now_ns,
'timer_claim_id': claim_id,
}
row.updated_at = int(time.time())
claimed.append((row.id, claim_id))
await db.commit()
return claimed
async def cancel_timers_for_chat(parent_chat_id: str, event: Literal['chat.read', 'chat.user_message']) -> None:
async with get_async_db() as db:
result = await db.execute(
select(Chat)
.where(Chat.meta['internal'].as_boolean().is_(True))
.where(Chat.meta['type'].as_string() == 'timer')
.where(Chat.meta['parent_chat_id'].as_string() == parent_chat_id)
.where(Chat.meta['timer_status'].as_string() == 'pending')
)
now_ns = int(time.time_ns())
for row in result.scalars().all():
meta = row.meta or {}
if event not in (meta.get('cancel_on') or []):
continue
row.meta = {
**meta,
'timer_status': 'cancelled',
'timer_cancelled_at': now_ns,
'timer_cancelled_by': event,
}
row.updated_at = int(time.time())
await db.commit()
async def execute_due_timer(app, timer_id: str, claim_id: str | None = None) -> None:
lock = _timer_locks.setdefault(timer_id, asyncio.Lock())
async with lock:
from open_webui.socket.main import sio
from open_webui.utils.subagents import _parent_locks, process_pending_internal_messages
timer = await Chats.get_chat_by_id(timer_id)
if not timer:
return
meta = timer.meta or {}
if meta.get('timer_status') != 'running':
return
if claim_id is not None and meta.get('timer_claim_id') != claim_id:
return
parent_chat_id = meta.get('parent_chat_id') or ''
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, timer.user_id)
if not parent:
await _set_timer_status(timer_id, 'error', timer_error='parent chat no longer exists')
return
prompt_message_id = meta.get('timer_task_message_id')
prompt_message = await Chats.get_message_by_id_and_message_id(timer_id, prompt_message_id)
if not prompt_message:
await _set_timer_status(timer_id, 'error', timer_error='timer task message is missing')
return
user = await Users.get_user_by_id(timer.user_id)
if not user:
await _set_timer_status(timer_id, 'error', timer_error='timer user no longer exists')
return
run = meta.get('run') or {}
model_id = run.get('model_id') or meta.get('timer_model_id')
if not model_id:
await _set_timer_status(timer_id, 'error', timer_error='model context is missing')
return
prompt = prompt_message.get('content') or ''
if isinstance(prompt, list):
prompt = ''.join(
str(part.get('text', '')) for part in prompt if isinstance(part, dict) and part.get('type') == 'text'
)
user_message_id = str(uuid4())
parent_lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
async with parent_lock:
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, timer.user_id)
if not parent:
await _set_timer_status(timer_id, 'error', timer_error='parent chat no longer exists')
return
parent_chat = copy.deepcopy(parent.chat or {})
history = parent_chat.setdefault('history', {})
messages = history.setdefault('messages', {})
done_assistants = [
message
for message in messages.values()
if message.get('role') == 'assistant' and message.get('done') is not False
]
parent_id = (
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
if done_assistants
else meta.get('parent_message_id')
)
pending_meta = {'internal': True, 'type': 'timer', 'timer_id': timer_id}
if await has_active_tasks(app.state.redis, parent_chat_id):
pending_meta['timer_pending'] = True
user_message = {
'id': user_message_id,
'parentId': parent_id,
'childrenIds': [],
'role': 'user',
'content': prompt,
'model': model_id,
'meta': pending_meta,
'timestamp': int(time.time()),
}
messages[user_message_id] = user_message
if parent_id and parent_id in messages:
children = messages[parent_id].setdefault('childrenIds', [])
if user_message_id not in children:
children.append(user_message_id)
await Chats.update_chat_by_id(parent_chat_id, parent_chat)
await ChatMessages.upsert_message(user_message_id, parent_chat_id, timer.user_id, user_message)
await _set_timer_status(timer_id, 'dispatched', timer_dispatched_at=int(time.time_ns()))
if user_message['meta'].get('timer_pending') is True:
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': user_message_id,
'data': {'type': 'chat:reload'},
},
room=f'user:{timer.user_id}',
)
elif not await has_active_tasks(app.state.redis, parent_chat_id):
request = Request(
{
'type': 'http',
'asgi': {'version': '3.0', 'spec_version': '2.0'},
'method': 'POST',
'path': '/api/v1/timers/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 0),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': app,
}
)
request.state.token = None
request.state.enable_api_keys = False
await process_pending_internal_messages(request, parent_chat_id, user.id, run)
async def _set_timer_status(timer_id: str, status: str, **fields) -> None:
async with get_async_db() as db:
row = await db.get(Chat, timer_id)
if not row:
return
row.meta = {**(row.meta or {}), 'timer_status': status, **fields}
row.updated_at = int(time.time())
await db.commit()
+2 -1
View File
@@ -82,6 +82,7 @@ from open_webui.tools.builtin import (
search_memories,
search_notes,
search_web,
timer,
toggle_automation,
update_automation,
update_calendar_event,
@@ -574,7 +575,7 @@ async def get_builtin_tools(
and getattr(request.state, 'internal', False) is not True
and getattr(request.state, 'direct', False) is not True
):
builtin_functions.append(delegate_task)
builtin_functions.extend([delegate_task, timer])
# Add memory tools when memory is enabled and the model allows this builtin category.
if (
@@ -22,14 +22,6 @@
import SubagentResultRow from './SubagentResultRow.svelte';
const i18n = getContext('i18n');
type SubagentResult = {
async_subagent_result: true;
delegation_id?: string;
delegation_ids?: string[];
subagent_chat_id?: string;
subagent_chat_ids?: string[];
};
export let user;
export let chatId;
@@ -62,7 +54,7 @@
let editScrollContainer: HTMLDivElement;
let message = structuredClone(history.messages[messageId]);
let subagentResult: SubagentResult | undefined;
let timerExpanded = false;
$: if (history.messages) {
const source = history.messages[messageId];
if (source) {
@@ -73,8 +65,6 @@
}
}
}
$: subagentResult = message?.meta?.async_subagent_result ? message.meta : undefined;
const copyToClipboard = async (text) => {
const res = await _copyToClipboard(text);
if (res) {
@@ -143,7 +133,7 @@
id="message-{message.id}"
style="scroll-margin-top: 3rem;"
>
{#if !($settings?.chatBubble ?? true) && !subagentResult}
{#if !($settings?.chatBubble ?? true) && !message?.meta?.async_subagent_result && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div class={`shrink-0 ltr:mr-2 rtl:ml-2 hidden @lg:flex mt-0.5`}>
<ProfileImage
src={user?.id
@@ -153,8 +143,13 @@
/>
</div>
{/if}
<div class="flex-auto w-0 max-w-full {subagentResult ? '' : 'pl-1'}">
{#if !($settings?.chatBubble ?? true) && !subagentResult}
<div
class="flex-auto w-0 max-w-full {message?.meta?.async_subagent_result ||
(message?.meta?.internal === true && message?.meta?.type === 'timer')
? ''
: 'pl-1'}"
>
{#if !($settings?.chatBubble ?? true) && !message?.meta?.async_subagent_result && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div>
<Name>
{#if message.user}
@@ -335,8 +330,53 @@
</div>
</div>
</div>
{:else if subagentResult}
<SubagentResultRow content={message.content} result={subagentResult} />
{:else if message?.meta?.internal === true && message?.meta?.type === 'timer'}
<div class="flex justify-start">
<div
class="w-full max-w-3xl rounded-xl border border-gray-100 bg-gray-50/70 px-3 py-2 text-gray-700 dark:border-gray-800 dark:bg-gray-900/60 dark:text-gray-300"
>
<button
type="button"
class="flex w-full items-center justify-between gap-3 text-left"
aria-expanded={timerExpanded}
on:click={() => {
timerExpanded = !timerExpanded;
}}
>
<span class="text-xs font-medium text-gray-500 dark:text-gray-400">{$i18n.t('Timer')}</span>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="2"
stroke="currentColor"
class="size-3.5 shrink-0 text-gray-400 transition-transform dark:text-gray-600 {timerExpanded
? 'rotate-180'
: ''}"
>
<path stroke-linecap="round" stroke-linejoin="round" d="m19.5 8.25-7.5 7.5-7.5-7.5" />
</svg>
</button>
{#if timerExpanded}
<div class="mt-2 text-sm">
{#if $settings?.renderMarkdownInUserMessages ?? true}
<Markdown
id={`${chatId}-${message.id}`}
content={message.content}
{editCodeBlock}
{topPadding}
/>
{:else}
<div class="whitespace-pre-wrap" dir={$settings?.chatDirection ?? 'auto'}>
{message.content}
</div>
{/if}
</div>
{/if}
</div>
</div>
{:else if message?.meta?.async_subagent_result}
<SubagentResultRow content={message.content} result={message.meta} />
{:else if message.content !== ''}
<div class="w-full">
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
@@ -371,7 +411,7 @@
</div>
{/if}
{#if edit !== true && !subagentResult}
{#if edit !== true && !message?.meta?.async_subagent_result && !(message?.meta?.internal === true && message?.meta?.type === 'timer')}
<div
class=" flex {($settings?.chatBubble ?? true)
? 'justify-end'