This commit is contained in:
Timothy Jaeryang Baek 2026-07-14 00:10:28 -04:00
parent 0c23466a3e
commit 7088d245bb
18 changed files with 1329 additions and 211 deletions

View file

@ -1991,6 +1991,8 @@ ENABLE_SUBAGENTS = os.getenv('ENABLE_SUBAGENTS', 'False').lower() == 'true'
SUBAGENTS_BACKGROUND_ENABLED = os.getenv('SUBAGENTS_BACKGROUND_ENABLED', 'False').lower() == 'true'
SUBAGENTS_MAX_CONCURRENT = int(os.getenv('SUBAGENTS_MAX_CONCURRENT', '20'))
SUBAGENTS_MAX_ASYNC = int(os.getenv('SUBAGENTS_MAX_ASYNC', '20'))
SUBAGENTS_MAX_ITERATIONS = int(os.getenv('SUBAGENTS_MAX_ITERATIONS', '30'))
SUBAGENTS_MAX_OUTPUT = int(os.getenv('SUBAGENTS_MAX_OUTPUT', '30000'))
SUBAGENTS_SYSTEM_PROMPT = os.getenv('SUBAGENTS_SYSTEM_PROMPT', '')
AUTOMATION_MAX_COUNT = os.getenv('AUTOMATION_MAX_COUNT', '')
@ -3029,6 +3031,8 @@ DEFAULT_CONFIG = {
'subagents.background_enabled': SUBAGENTS_BACKGROUND_ENABLED,
'subagents.max_concurrent': SUBAGENTS_MAX_CONCURRENT,
'subagents.max_async': SUBAGENTS_MAX_ASYNC,
'subagents.max_iterations': SUBAGENTS_MAX_ITERATIONS,
'subagents.max_output': SUBAGENTS_MAX_OUTPUT,
'subagents.system_prompt': SUBAGENTS_SYSTEM_PROMPT,
'automations.max_count': AUTOMATION_MAX_COUNT,
'automations.min_interval': AUTOMATION_MIN_INTERVAL,

View file

@ -1131,6 +1131,7 @@ async def chat_completion(
metadata = {
'user_id': user.id,
'user_agent': request.headers.get('user-agent', '') or '',
'internal': getattr(request.state, 'internal', False) is True,
'chat_id': form_data.pop('chat_id', None) or '',
'user_message': user_message,
'user_message_id': user_message.get('id') if user_message else None,
@ -1590,9 +1591,39 @@ async def chat_completion(
except Exception:
pass
try:
chat_id = metadata.get('chat_id')
if (
chat_id
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
await process_pending_subagent_results(
request,
chat_id,
user.id,
{
'model_id': metadata.get('model_id') or form_data.get('model'),
'session_id': metadata.get('session_id'),
'tool_ids': metadata.get('tool_ids') or [],
'skill_ids': metadata.get('skill_ids') or [],
'system_prompt': metadata.get('system_prompt'),
'filter_ids': metadata.get('filter_ids') or [],
'terminal_id': metadata.get('terminal_id'),
'features': metadata.get('features') or {},
'variables': metadata.get('variables') or {},
},
)
except Exception:
log.exception('Failed to process pending sub-agent results for chat %s', metadata.get('chat_id'))
# Fan out: one task per model
if metadata.get('session_id') and metadata.get('chat_id'):
task_ids = []
subagent_results = []
is_internal = getattr(request.state, 'internal', False) is True
chat_id = metadata['chat_id']
for idx, entry in enumerate(message_ids):
@ -1619,28 +1650,39 @@ async def chat_completion(
# Only the first model runs chat-level background tasks;
# subsequent models only run follow-ups.
process = process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v for k, v in (tasks or {}).items() if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
)
if is_internal:
subagent_results.append(await process)
continue
task_id, _ = await create_task(
request.app.state.redis,
process_chat(
request,
model_form_data,
user,
per_model_metadata,
resolved_model,
tasks
if idx == 0
else {
k: v
for k, v in (tasks or {}).items()
if k not in (TASKS.TITLE_GENERATION, TASKS.TAGS_GENERATION)
}
or None,
),
process,
id=chat_id,
)
per_model_metadata['task_id'] = task_id
task_ids.append(task_id)
if is_internal:
return {
'status': True,
'task_ids': [],
'chat_id': chat_id,
'results': subagent_results,
}
# Emit chat:active=true
if task_ids:
event_emitter = await get_event_emitter(

View file

@ -74,6 +74,10 @@ class Chat(Base): # database table mapping for chat entity
)
def is_internal_chat(meta: dict | None) -> bool:
return bool(meta and meta.get('internal') is True)
class ChatModel(BaseModel):
model_config = ConfigDict(from_attributes=True) # allows ORM model binding
id: str
@ -349,7 +353,13 @@ class ChatTable:
return True
async def insert_new_chat(
self, id: str, user_id: str, form_data: ChatForm, db: AsyncSession | None = None
self,
id: str,
user_id: str,
form_data: ChatForm,
db: AsyncSession | None = None,
*,
internal_meta: dict | None = None,
) -> ChatModel | None:
async with get_async_db_context(db) as session:
chat = ChatModel(
@ -361,6 +371,7 @@ class ChatTable:
),
'chat': self._clean_null_bytes(form_data.chat),
'folder_id': form_data.folder_id,
'meta': internal_meta or {},
'created_at': int(time.time()),
'updated_at': int(time.time()),
'last_read_at': int(time.time()),
@ -389,6 +400,17 @@ class ChatTable:
return ChatModel.model_validate(chat_item) if chat_item else None
async def get_internal_chat_ids_by_parent_id(self, parent_chat_id: str, user_id: str) -> list[str]:
async with get_async_db_context() as session:
result = await session.execute(
select(Chat.id).where(
Chat.user_id == user_id,
Chat.meta['internal'].as_boolean().is_(True),
Chat.meta['parent_chat_id'].as_string() == parent_chat_id,
)
)
return list(result.scalars().all())
def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel:
id = str(uuid.uuid4())
chat = ChatModel(
@ -951,6 +973,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at).filter_by(
user_id=user_id, archived=True
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
query_key = filter.get('query')
@ -998,7 +1021,8 @@ class ChatTable:
db: AsyncSession | None = None,
) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True))
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=True)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
return result.scalar() or 0
async def get_shared_chat_list_by_user_id(
@ -1027,6 +1051,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_archived:
stmt = stmt.filter_by(archived=False)
@ -1082,6 +1107,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if not include_folders:
stmt = stmt.filter_by(folder_id=None)
@ -1123,9 +1149,9 @@ class ChatTable:
db: AsyncSession | None = None,
) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False).order_by(Chat.updated_at.desc())
)
stmt = select(Chat).filter(Chat.id.in_(chat_ids)).filter_by(archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
@ -1170,6 +1196,7 @@ class ChatTable:
select(Chat.id, Chat.user_id, Chat.title, Chat.updated_at, User.name.label('user_name'))
.join(chat_ids, chat_ids.c.chat_id == Chat.id)
.outerjoin(User, User.id == Chat.user_id)
.where(Chat.meta['internal'].as_boolean().is_not(True))
)
order_by = filter.get('order_by') if filter else None
@ -1301,7 +1328,8 @@ class ChatTable:
async def get_chats(self, skip: int = 0, limit: int = 50, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(select(Chat).order_by(Chat.updated_at.desc()))
stmt = select(Chat).where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.scalars().all()
return [ChatModel.model_validate(chat) for chat in all_chats]
@ -1316,6 +1344,7 @@ class ChatTable:
) -> ChatListResponse:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter_by(user_id=user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if filter:
if filter.get('updated_at'):
@ -1359,11 +1388,11 @@ class ChatTable:
self, user_id: str, db: AsyncSession | None = None
) -> list[ChatTitleIdResponse]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at)
.filter_by(user_id=user_id, pinned=True, archived=False)
.order_by(Chat.updated_at.desc())
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id, pinned=True, archived=False
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
all_chats = result.all()
return [
ChatTitleIdResponse.model_validate(
@ -1380,9 +1409,9 @@ class ChatTable:
async def get_archived_chats_by_user_id(self, user_id: str, db: AsyncSession | None = None) -> list[ChatModel]:
async with get_async_db_context(db) as session:
result = await session.execute(
select(Chat).filter_by(user_id=user_id, archived=True).order_by(Chat.updated_at.desc())
)
stmt = select(Chat).filter_by(user_id=user_id, archived=True)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
result = await session.execute(stmt.order_by(Chat.updated_at.desc()))
return [ChatModel.model_validate(chat) for chat in result.scalars().all()]
# search user conversations
@ -1453,6 +1482,7 @@ class ChatTable:
async with get_async_db_context(db) as session:
stmt = select(Chat).filter(Chat.user_id == user_id)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
if is_archived is not None:
stmt = stmt.filter(Chat.archived == is_archived)
@ -1594,6 +1624,7 @@ class ChatTable:
.filter_by(folder_id=folder_id, user_id=user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
)
@ -1631,6 +1662,7 @@ class ChatTable:
.filter_by(folder_id=folder_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc(), Chat.id)
)
@ -1662,6 +1694,7 @@ class ChatTable:
.filter(Chat.folder_id.in_(folder_ids), Chat.user_id == user_id)
.filter(or_(Chat.pinned == False, Chat.pinned == None))
.filter_by(archived=False)
.where(Chat.meta['internal'].as_boolean().is_not(True))
.order_by(Chat.updated_at.desc())
)
@ -1707,6 +1740,7 @@ class ChatTable:
stmt = select(Chat.id, Chat.title, Chat.updated_at, Chat.created_at, Chat.last_read_at).filter_by(
user_id=user_id
)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
@ -1769,6 +1803,7 @@ class ChatTable:
) -> int:
async with get_async_db_context(db) as session:
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, archived=False)
stmt = stmt.where(Chat.meta['internal'].as_boolean().is_not(True))
tag_id = tag_name.replace(' ', '_').lower()
bind = await session.connection()
@ -1816,7 +1851,8 @@ class ChatTable:
self, folder_id: str, user_id: str, db: AsyncSession | None = None
) -> int:
async with get_async_db_context(db) as session:
result = await session.execute(select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id))
stmt = select(func.count(Chat.id)).filter_by(user_id=user_id, folder_id=folder_id)
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folder '{folder_id}': {count}")
@ -1829,9 +1865,8 @@ class ChatTable:
return 0
async with get_async_db_context(db) as session:
result = await session.execute(
select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
)
stmt = select(func.count(Chat.id)).filter(Chat.user_id == user_id, Chat.folder_id.in_(folder_ids))
result = await session.execute(stmt.where(Chat.meta['internal'].as_boolean().is_not(True)))
count = result.scalar()
log.info(f"Count of chats for folders '{folder_ids}': {count}")

View file

@ -26,6 +26,7 @@ from open_webui.models.chats import (
ChatStatsExport,
ChatTitleIdResponse,
ChatUsageStatsListResponse,
is_internal_chat,
MessageStats,
)
from open_webui.models.folders import Folders
@ -1178,8 +1179,10 @@ async def get_chat_by_id(id: str, user=Depends(get_verified_user), db: AsyncSess
if not chat:
# Check if user has access via access grants (shared_chat grants)
if user.role == 'admin' and ENABLE_ADMIN_CHAT_ACCESS:
chat = await Chats.get_chat_by_id(id, db=db)
if user.role == 'admin':
candidate = await Chats.get_chat_by_id(id, db=db)
if ENABLE_ADMIN_CHAT_ACCESS or (candidate and is_internal_chat(candidate.meta)):
chat = candidate
else:
has_grant = await AccessGrants.has_access(
user_id=user.id,
@ -1430,6 +1433,13 @@ async def delete_chat_by_id(
# before deleting the chat to prevent orphaned requests.
await stop_item_tasks(request.app.state.redis, id)
async def delete_internal_children(owner_id: str) -> None:
child_ids = await Chats.get_internal_chat_ids_by_parent_id(id, owner_id)
for child_id in child_ids:
await stop_item_tasks(request.app.state.redis, child_id)
await Chats.delete_chat_by_id_and_user_id(child_id, owner_id)
await stop_item_tasks(request.app.state.redis, id)
if user.role == 'admin':
chat = await Chats.get_chat_by_id(id, db=db)
if not chat:
@ -1438,6 +1448,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(chat.user_id)
result = await Chats.delete_chat_by_id(id, db=db)
@ -1464,6 +1475,7 @@ async def delete_chat_by_id(
detail=ERROR_MESSAGES.NOT_FOUND,
)
await Chats.delete_orphan_tags_for_user(chat.meta.get('tags', []), user.id, threshold=1, db=db)
await delete_internal_children(user.id)
result = await Chats.delete_chat_by_id_and_user_id(id, user.id, db=db)
if result:

View file

@ -72,6 +72,8 @@ SUBAGENTS_CONFIG_KEYS = {
'SUBAGENTS_BACKGROUND_ENABLED': 'subagents.background_enabled',
'SUBAGENTS_MAX_CONCURRENT': 'subagents.max_concurrent',
'SUBAGENTS_MAX_ASYNC': 'subagents.max_async',
'SUBAGENTS_MAX_ITERATIONS': 'subagents.max_iterations',
'SUBAGENTS_MAX_OUTPUT': 'subagents.max_output',
'SUBAGENTS_SYSTEM_PROMPT': 'subagents.system_prompt',
}
@ -767,6 +769,8 @@ class SubagentsConfigForm(BaseModel):
SUBAGENTS_BACKGROUND_ENABLED: bool
SUBAGENTS_MAX_CONCURRENT: int
SUBAGENTS_MAX_ASYNC: int
SUBAGENTS_MAX_ITERATIONS: int
SUBAGENTS_MAX_OUTPUT: int
SUBAGENTS_SYSTEM_PROMPT: str

View file

@ -925,12 +925,17 @@ async def get_event_emitter(request_info, update_db=True):
user_id = request_info['user_id']
chat_id = request_info['chat_id']
message_id = request_info['message_id']
internal = request_info.get('internal') is True
if internal and event_data.get('type') == 'notification':
return
await sio.emit(
'events',
{
'chat_id': chat_id,
'message_id': message_id,
**({'internal': True} if internal else {}),
'data': event_data,
},
room=f'user:{user_id}',

View file

@ -1348,6 +1348,48 @@ async def view_chat(
return json.dumps({'error': str(e)})
# =============================================================================
# SUB-AGENT TOOL
# =============================================================================
async def delegate_task(
task: str,
context: str = '',
background: bool = False,
__request__: Request = None,
__user__: dict = None,
__metadata__: dict = None,
__chat_id__: str = None,
__message_id__: str = None,
) -> str:
"""
Delegate focused work to a parallel sub-agent using the current model and tools.
:param task: The specific task for the sub-agent to complete
:param context: Relevant context, decisions, or file paths for the task
:param background: Return immediately and continue this chat when the sub-agent finishes
:return: Foreground result text, or a JSON dispatch handle for background work
"""
if __request__ is None:
return 'Error: request context not available.'
if getattr(__request__.state, 'internal', False) is True:
return 'Error: sub-agents cannot delegate recursively.'
from open_webui.utils.subagents import delegate
return await delegate(
task,
context,
background,
request=__request__,
user_data=__user__ or {},
metadata=__metadata__ or {},
parent_chat_id=__chat_id__ or '',
parent_message_id=__message_id__,
)
# =============================================================================
# CHANNELS TOOLS
# =============================================================================

View file

@ -2592,14 +2592,16 @@ async def process_chat_payload(request, form_data, user, metadata, model):
# Remove duplicate files based on their content
files = list({json.dumps(f, sort_keys=True): f for f in files}.values())
metadata = {
**metadata,
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'terminal_id': terminal_id,
'files': files,
'features': features,
}
metadata.update(
{
'model_id': form_data.get('model'),
'tool_ids': tool_ids,
'skill_ids': list(skill_ids),
'terminal_id': terminal_id,
'files': files,
'features': features,
}
)
form_data['metadata'] = metadata
# When the caller provides an explicit `tools` key in the request body,
@ -2717,6 +2719,7 @@ async def process_chat_payload(request, form_data, user, metadata, model):
)
except Exception as e:
log.exception(e)
raise HTTPException(status_code=503, detail=f'Terminal unavailable: {e}') from e
if direct_tool_servers:
for tool_server in direct_tool_servers:
@ -3556,7 +3559,11 @@ async def non_streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
if (
getattr(request.state, 'internal', False) is not True
and await Config.get('ui.enable_user_webhooks')
and not await Users.is_user_active(user.id)
):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')
@ -4624,6 +4631,11 @@ async def streaming_chat_response_handler(response, ctx):
await response.background()
tool_call_iterations = 0
max_tool_call_iterations = getattr(
request.state,
'max_tool_call_iterations',
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS,
)
tool_call_sources = [] # Track citation sources from tool results
all_tool_call_sources = [] # Accumulated sources across all iterations
user_message = get_last_user_message(form_data['messages'])
@ -4644,8 +4656,7 @@ async def streaming_chat_response_handler(response, ctx):
)
while tool_calls and (
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is None
or tool_call_iterations < CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
max_tool_call_iterations is None or tool_call_iterations < max_tool_call_iterations
):
tool_call_iterations += 1
@ -4682,83 +4693,92 @@ async def streaming_chat_response_handler(response, ctx):
results = []
def parse_tool_params(tool_call):
tool_args = tool_call.get('function', {}).get('arguments', '{}')
params = {}
if tool_args and tool_args.strip():
try:
params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
try:
params = json.loads(tool_args)
except Exception:
return None
tool_call.setdefault('function', {})['arguments'] = json.dumps(params)
return params
async def execute_tool_call(tool_call):
name = tool_call.get('function', {}).get('name', '')
params = parse_tool_params(tool_call)
if params is None:
return {}, None, None, None, False
tool = tools.get(name)
if not tool:
return params, f'Error: Tool "{name}" not found.', None, None, False
spec = tool.get('spec', {})
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
params = {key: value for key, value in params.items() if key in allowed_params}
try:
if direct_tool:
result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': name,
'params': params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id'),
},
}
)
else:
function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files', []),
},
)
result = await function(**params)
except Exception as e:
result = str(e)
return params, result, tool, tool_type, direct_tool
delegate_calls = [
tool_call
for tool_call in response_tool_calls
if tool_call.get('function', {}).get('name') == 'delegate_task'
]
tool_results = {}
for tool_call in response_tool_calls:
if tool_call.get('function', {}).get('name') != 'delegate_task':
tool_results[id(tool_call)] = await execute_tool_call(tool_call)
tool_results.update(
zip(
[id(tool_call) for tool_call in delegate_calls],
await asyncio.gather(*(execute_tool_call(tool_call) for tool_call in delegate_calls)),
)
)
for tool_call in response_tool_calls:
tool_call_id = tool_call.get('id', '')
tool_function_name = tool_call.get('function', {}).get('name', '')
tool_args = tool_call.get('function', {}).get('arguments', '{}')
tool_function_params = {}
if tool_args and tool_args.strip():
try:
# json.loads cannot be used because some models do not produce valid JSON
tool_function_params = ast.literal_eval(tool_args)
except Exception as e:
log.debug(e)
# Fallback to JSON parsing
try:
tool_function_params = json.loads(tool_args)
except Exception as e:
log.error(f'Error parsing tool call arguments: {tool_args}')
results.append(
{
'tool_call_id': tool_call_id,
'content': f'Error: Tool call arguments could not be parsed. The model generated malformed or incomplete JSON for `{tool_function_name}`. Please try again.',
}
)
continue
# Ensure arguments are valid JSON for downstream LLM integrations
log.debug(f'Parsed args from {tool_args} to {tool_function_params}')
tool_call.setdefault('function', {})['arguments'] = json.dumps(tool_function_params)
tool_result = None
tool = None
tool_type = None
direct_tool = False
if tool_function_name in tools:
tool = tools[tool_function_name]
spec = tool.get('spec', {})
tool_type = tool.get('type', '')
direct_tool = tool.get('direct', False)
try:
allowed_params = spec.get('parameters', {}).get('properties', {}).keys()
tool_function_params = {
k: v for k, v in tool_function_params.items() if k in allowed_params
tool_function_params, tool_result, tool, tool_type, direct_tool = tool_results[id(tool_call)]
if tool_result is None:
results.append(
{
'tool_call_id': tool_call_id,
'content': (
'Error: Tool call arguments could not be parsed. The model generated '
f'malformed or incomplete JSON for `{tool_function_name}`. Please try again.'
),
}
if direct_tool:
tool_result = await event_caller(
{
'type': 'execute:tool',
'data': {
'id': str(uuid4()),
'name': tool_function_name,
'params': tool_function_params,
'server': tool.get('server', {}),
'session_id': metadata.get('session_id', None),
},
}
)
else:
tool_function = await get_updated_tool_function(
function=tool['callable'],
extra_params={
'__messages__': form_data.get('messages', []),
'__files__': metadata.get('files', []),
},
)
tool_result = await tool_function(**tool_function_params)
except Exception as e:
tool_result = str(e)
else:
tool_result = f'Error: Tool "{tool_function_name}" not found.'
)
continue
tool_result, tool_result_files, tool_result_embeds = await process_tool_result(
request,
@ -5030,12 +5050,12 @@ async def streaming_chat_response_handler(response, ctx):
break
if (
CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS is not None
max_tool_call_iterations is not None
and tool_calls
and tool_call_iterations >= CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS
and tool_call_iterations >= max_tool_call_iterations
):
log.warning('Tool-call iteration limit reached (%s)', CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS)
error_content = f'Tool-call limit reached ({CHAT_RESPONSE_MAX_TOOL_CALL_ITERATIONS} iterations).'
log.warning('Tool-call iteration limit reached (%s)', max_tool_call_iterations)
error_content = f'Tool-call limit reached ({max_tool_call_iterations} iterations).'
if not metadata.get('chat_id', '').startswith('channel:'):
await Chats.upsert_message_to_chat_by_id_and_message_id(
metadata['chat_id'],
@ -5261,7 +5281,11 @@ async def streaming_chat_response_handler(response, ctx):
)
# Send a webhook notification if the user is not active
if await Config.get('ui.enable_user_webhooks') and not await Users.is_user_active(user.id):
if (
getattr(request.state, 'internal', False) is not True
and await Config.get('ui.enable_user_webhooks')
and not await Users.is_user_active(user.id)
):
webhook_url = await Users.get_user_webhook_url_by_id(user.id)
if webhook_url:
webui_url = await Config.get('webui.url')

View file

@ -0,0 +1,627 @@
from __future__ import annotations
import asyncio
import copy
import json
import time
from datetime import timedelta
from uuid import uuid4
from fastapi import Request
from fastapi.security import HTTPAuthorizationCredentials
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import ChatForm, Chats
from open_webui.models.config import Config
from open_webui.models.users import UserModel, Users
from open_webui.tasks import create_task, has_active_tasks
from open_webui.utils.auth import create_token
from open_webui.utils.misc import get_message_list
from starlette.datastructures import Headers
DEFAULT_SUBAGENT_SYSTEM_PROMPT = """You are a sub-agent working on a specific task assigned by the lead agent.
You have full access to the workspace you can read, write, edit files, and run commands.
Focus exclusively on your assigned task. Do NOT work on anything outside your scope.
When done, end with a clear summary:
- What you did
- What files you changed (if any)
- Any issues or open questions
"""
MUTATING_MEMORY_TOOLS = {
'add_memory',
'delete_memory',
'replace_memory_content',
'update_memory',
}
_background_active: set[str] = set()
_background_lock = asyncio.Lock()
_foreground_semaphore: asyncio.Semaphore | None = None
_parent_locks: dict[str, asyncio.Lock] = {}
def _build_request(source: Request, user_id: str, *, internal: bool) -> Request:
scope = {
'type': 'http',
'asgi': {'version': '3.0', 'spec_version': '2.0'},
'method': 'POST',
'path': '/api/v1/subagents/internal',
'query_string': b'',
'headers': Headers({}).raw,
'client': ('127.0.0.1', 0),
'server': ('127.0.0.1', 80),
'scheme': 'http',
'app': source.app,
}
request = Request(scope)
token = create_token(
data={'id': user_id, 'typ': 'subagent'},
expires_delta=timedelta(hours=1),
)
request.state.token = HTTPAuthorizationCredentials(scheme='Bearer', credentials=token)
request.state.enable_api_keys = False
if internal:
request.state.internal = True
return request
async def process_pending_subagent_results(
source_request: Request,
parent_chat_id: str,
user_id: str,
run: dict,
) -> None:
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
while await has_active_tasks(source_request.app.state.redis, parent_chat_id):
await asyncio.sleep(0.25)
async with lock:
if await has_active_tasks(source_request.app.state.redis, parent_chat_id):
return
chat = await Chats.get_chat_by_id_and_user_id(parent_chat_id, user_id)
user = await Users.get_user_by_id(user_id)
if not chat or not user:
return
history = copy.deepcopy(chat.chat.get('history') or {})
messages = history.get('messages') or {}
pending = [
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')
]
if not pending:
return
first = pending[0]
parent_id = first.get('parentId')
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
]
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
reuse_message = len(batch) == 1 and not (first.get('meta') or {}).get('async_subagent_pending')
user_message_id = first['id'] if reuse_message else str(uuid4())
if not reuse_message:
removed_ids = {message['id'] for message in batch}
for message_id in removed_ids:
messages.pop(message_id, None)
if parent_id and parent_id in messages:
messages[parent_id]['childrenIds'] = [
child_id for child_id in messages[parent_id].get('childrenIds', []) if child_id not in removed_ids
]
history['messages'] = messages
history['currentId'] = parent_id
updated_chat = copy.deepcopy(chat.chat)
updated_chat['history'] = history
await Chats.update_chat_by_id(parent_chat_id, updated_chat)
await ChatMessages.delete_message_ids_by_chat_id(parent_chat_id, removed_ids)
assistant_message_id = str(uuid4())
message_list = get_message_list(messages, parent_id)
system_prompt = run.get('system_prompt')
user_message = {
'id': user_message_id,
'parentId': parent_id,
'childrenIds': [assistant_message_id],
'role': 'user',
'content': combined_content,
'model': model_id,
'meta': combined_meta,
'timestamp': int(time.time()),
}
assistant_message = {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': model_id,
'timestamp': int(time.time()),
}
if parent_id and parent_id in messages:
parent_children = [
child_id for child_id in messages[parent_id].get('childrenIds', []) if child_id != user_message_id
]
parent_children.append(user_message_id)
await Chats.upsert_message_to_chat_by_id_and_message_id(
parent_chat_id,
parent_id,
{'childrenIds': parent_children},
)
await Chats.upsert_message_to_chat_by_id_and_message_id(
parent_chat_id,
user_message_id,
user_message,
)
await Chats.upsert_message_to_chat_by_id_and_message_id(
parent_chat_id,
assistant_message_id,
assistant_message,
)
from open_webui.socket.main import sio
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': assistant_message_id,
'data': {'type': 'chat:reload'},
},
room=f'user:{user.id}',
)
form_data = {
'model': model_id,
'messages': [
*([{'role': 'system', 'content': system_prompt}] if system_prompt else []),
*message_list,
{'role': 'user', 'content': combined_content},
],
'stream': True,
'chat_id': parent_chat_id,
'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}',
'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 {},
'variables': run.get('variables') or {},
}
if run.get('terminal_id'):
form_data['terminal_id'] = run['terminal_id']
request = _build_request(source_request, user.id, internal=False)
await source_request.app.state.CHAT_COMPLETION_HANDLER(request, form_data, user=user)
async def delegate(
task: str,
context: str,
background: bool,
*,
request: Request,
user_data: dict,
metadata: dict,
parent_chat_id: str,
parent_message_id: str | None,
) -> str:
global _foreground_semaphore
task = task.strip()
if not task:
return 'Error: task must not be empty.'
if not parent_chat_id or not user_data.get('id'):
return 'Error: chat and user context are required.'
config = await Config.get_many(
'subagents.background_enabled',
'subagents.max_concurrent',
'subagents.max_async',
'subagents.max_iterations',
'subagents.max_output',
'subagents.system_prompt',
)
max_concurrent = int(config.get('subagents.max_concurrent') or 20)
max_async = int(config.get('subagents.max_async') or 20)
max_iterations = int(config.get('subagents.max_iterations') or 30)
max_output = int(config.get('subagents.max_output') or 30_000)
if max_concurrent != -1:
max_concurrent = max(1, max_concurrent)
if max_async != -1:
max_async = max(1, max_async)
if background and not config.get('subagents.background_enabled'):
return 'Error: background sub-agents are disabled in settings.'
features = copy.deepcopy(metadata.get('features') or {})
if (
background
and features.get('code_interpreter')
and await Config.get('code_interpreter.engine', 'pyodide') != 'jupyter'
):
features.pop('code_interpreter')
run = {
'model_id': metadata.get('model_id') or (metadata.get('model') or {}).get('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'),
'tool_servers': [] if background else copy.deepcopy(metadata.get('tool_servers') or []),
'filter_ids': copy.deepcopy(metadata.get('filter_ids') or []),
'terminal_id': metadata.get('terminal_id'),
'features': features,
'files': copy.deepcopy(metadata.get('files') or []),
'variables': copy.deepcopy(metadata.get('variables') or {}),
'direct': bool(metadata.get('direct')),
}
if not run.get('model_id'):
return 'Error: model context is required.'
if run.get('direct'):
return 'Error: sub-agents are unavailable for direct connections.'
delegation_id = f'deleg_{uuid4().hex[:8]}'
foreground_semaphore = None
if background:
async with _background_lock:
if max_async != -1 and len(_background_active) >= max_async:
return (
f'Error: Async subagent capacity reached ({max_async} running). '
'Wait for one to finish or increase subagents.max_async.'
)
_background_active.add(delegation_id)
elif max_concurrent != -1:
if _foreground_semaphore is None:
_foreground_semaphore = asyncio.Semaphore(max_concurrent)
foreground_semaphore = _foreground_semaphore
await foreground_semaphore.acquire()
mode = 'background' if background else 'foreground'
try:
user = UserModel(**user_data)
chat_id = str(uuid4())
user_message_id = str(uuid4())
assistant_message_id = str(uuid4())
prompt = f'{task}\n\n## Context\n{context}' if context else task
chat = await Chats.insert_new_chat(
chat_id,
user.id,
ChatForm(
chat={
'id': chat_id,
'title': f'Sub-agent: {task[:60]}',
'models': [run['model_id']],
'history': {
'currentId': assistant_message_id,
'messages': {
user_message_id: {
'id': user_message_id,
'parentId': None,
'childrenIds': [assistant_message_id],
'role': 'user',
'content': prompt,
'timestamp': int(time.time()),
'models': [run['model_id']],
},
assistant_message_id: {
'id': assistant_message_id,
'parentId': user_message_id,
'childrenIds': [],
'role': 'assistant',
'content': '',
'done': False,
'model': run['model_id'],
'timestamp': int(time.time()),
},
},
},
'messages': [{'role': 'user', 'content': prompt}],
}
),
internal_meta={
'internal': True,
'type': 'subagent',
'parent_chat_id': parent_chat_id,
'parent_message_id': parent_message_id,
'delegation_id': delegation_id,
'mode': mode,
},
)
if not chat:
raise RuntimeError('Failed to create sub-agent chat')
except Exception as exc:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
prefix = 'background ' if background else ''
return f'Error: failed to create {prefix}sub-agent: {exc}'
async def run_reserved() -> dict:
try:
child_request = _build_request(request, user.id, internal=True)
child_request.state.max_tool_call_iterations = max_iterations
parent_system_prompt = run.get('system_prompt') or ''
subagent_system_prompt = (
str(config.get('subagents.system_prompt') or '').strip() or DEFAULT_SUBAGENT_SYSTEM_PROMPT
)
form_data = {
'model': run['model_id'],
'messages': [
{
'role': 'system',
'content': (
f'{parent_system_prompt}\n\n{subagent_system_prompt}'
if parent_system_prompt
else subagent_system_prompt
),
},
{'role': 'user', 'content': prompt},
],
'stream': True,
'chat_id': chat_id,
'id': assistant_message_id,
'parent_id': None,
'user_message': {
'id': user_message_id,
'parentId': None,
'role': 'user',
'content': prompt,
},
'session_id': run.get('session_id') or f'subagent:{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'):
form_data['terminal_id'] = run['terminal_id']
if run.get('tool_servers'):
form_data['tool_servers'] = run['tool_servers']
await request.app.state.CHAT_COMPLETION_HANDLER(child_request, form_data, user=user)
message = await Chats.get_message_by_id_and_message_id(chat_id, assistant_message_id)
if not message:
return {
'status': 'error',
'summary': '',
'error': 'Sub-agent chat or completion message no longer exists.',
}
summary = message.get('content') or ''
if isinstance(summary, list):
summary = ''.join(
str(item.get('text', ''))
for item in summary
if isinstance(item, dict) and item.get('type') == 'text'
)
if not summary:
summary = ''.join(
str(part.get('text', ''))
for item in message.get('output') or []
if item.get('type') == 'message'
for part in item.get('content') or []
if part.get('type') == 'output_text'
)
if len(summary) > max_output:
summary = f'{summary[:max_output]}\n\n[output truncated]'
error = message.get('error')
return {
'status': 'error' if error else 'completed',
'summary': summary or ('Sub-agent produced no output.' if not error else ''),
'error': error,
}
except asyncio.CancelledError:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{'done': True, 'error': {'content': 'Sub-agent cancelled.'}},
)
raise
except Exception as exc:
await Chats.upsert_message_to_chat_by_id_and_message_id(
chat_id,
assistant_message_id,
{'done': True, 'error': {'content': str(exc)}},
)
raise
finally:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
async def run_background() -> dict:
started_at = time.time()
cancelled = False
try:
result = await run_reserved()
except asyncio.CancelledError:
result = {'status': 'interrupted', 'summary': '', 'error': 'cancelled'}
cancelled = True
except Exception as exc:
result = {'status': 'error', 'summary': '', 'error': str(exc)}
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, user.id)
if not parent:
if cancelled:
raise asyncio.CancelledError
return result
history = copy.deepcopy(parent.chat.get('history') or {})
messages = history.setdefault('messages', {})
done_assistants = [
message
for message in messages.values()
if message.get('role') == 'assistant' and message.get('done') is not False
]
result_parent_id = (
max(done_assistants, key=lambda message: message.get('timestamp', 0)).get('id')
if done_assistants
else parent_message_id
)
duration = f'{time.time() - started_at:.1f}s'
lines = [
f'[ASYNC SUBAGENT COMPLETE - {delegation_id}]',
(
'A background subagent you dispatched earlier has finished. '
'The original task source is included so you can decide whether '
'to use the result or continue without it.'
),
'',
f'Original task: {task}',
]
if context:
lines.append(f'Context provided: {context}')
lines.extend(
[
f'Subagent chat: {chat_id}',
f'Status: {result.get("status", "completed")} Duration: {duration}',
'--- RESULT ---',
]
)
if result.get('status') == 'completed':
lines.append(result.get('summary') or 'Subagent completed without a final summary.')
elif result.get('status') == 'interrupted':
lines.append('The subagent was interrupted before completing.')
if result.get('summary'):
lines.extend(['Partial output:', result['summary']])
else:
detail = f' {result.get("error")}' if result.get('error') else ''
lines.append(f'The subagent did not complete successfully.{detail}')
if result.get('summary'):
lines.extend(['Partial output:', result['summary']])
pending_message_id = str(uuid4())
pending_meta = {
'async_subagent_result': True,
'delegation_id': delegation_id,
'subagent_chat_id': chat_id,
}
pending_message = {
'id': pending_message_id,
'parentId': result_parent_id,
'childrenIds': [],
'role': 'user',
'content': '\n'.join(lines),
'model': run['model_id'],
'meta': pending_meta,
'timestamp': int(time.time()),
}
lock = _parent_locks.setdefault(parent_chat_id, asyncio.Lock())
async with lock:
parent = await Chats.get_chat_by_id_and_user_id(parent_chat_id, user.id)
if not parent:
if cancelled:
raise asyncio.CancelledError
return result
if await has_active_tasks(request.app.state.redis, parent_chat_id):
pending_message['meta']['async_subagent_pending'] = True
updated_chat = copy.deepcopy(parent.chat)
updated_history = updated_chat.setdefault('history', {})
updated_messages = updated_history.setdefault('messages', {})
updated_messages[pending_message_id] = pending_message
if result_parent_id and result_parent_id in updated_messages:
children = updated_messages[result_parent_id].setdefault('childrenIds', [])
if pending_message_id not in children:
children.append(pending_message_id)
await Chats.update_chat_by_id(parent_chat_id, updated_chat)
await ChatMessages.upsert_message(
message_id=pending_message_id,
chat_id=parent_chat_id,
user_id=user.id,
data=pending_message,
)
if pending_message['meta'].get('async_subagent_pending') is True:
from open_webui.socket.main import sio
await sio.emit(
'events',
{
'chat_id': parent_chat_id,
'message_id': pending_message_id,
'data': {'type': 'chat:reload'},
},
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)
if cancelled:
raise asyncio.CancelledError
return result
try:
_, child_task = await create_task(
request.app.state.redis,
run_background() if background else run_reserved(),
id=chat_id,
)
except Exception as exc:
if background:
async with _background_lock:
_background_active.discard(delegation_id)
elif foreground_semaphore:
foreground_semaphore.release()
return f'Error: {exc}'
if background:
return json.dumps(
{
'status': 'dispatched',
'delegation_id': delegation_id,
'subagent_chat_id': chat_id,
'mode': 'background',
'task': task,
},
ensure_ascii=False,
)
try:
result = await child_task
except asyncio.CancelledError:
if asyncio.current_task() and asyncio.current_task().cancelling():
raise
return 'Error: sub-agent was cancelled.'
except Exception as exc:
return f'Error: {exc}'
if result.get('status') != 'completed':
return f'Error: {result.get("error") or "sub-agent failed."}'
return result.get('summary') or 'Sub-agent produced no output.'

View file

@ -51,6 +51,7 @@ from open_webui.tools.builtin import (
create_automation,
create_calendar_event,
create_tasks,
delegate_task,
delete_automation,
delete_calendar_event,
delete_memory,
@ -98,6 +99,7 @@ from open_webui.utils.access_control import has_access, has_connection_access, h
from open_webui.utils.headers import get_custom_headers, include_user_info_headers
from open_webui.utils.misc import is_string_allowed
from open_webui.utils.plugin import get_tool_contents_cache, get_tools_cache, load_tool_module_by_id
from open_webui.utils.terminals import get_terminal_server_url
from pydantic import BaseModel, Field, create_model
from pydantic.fields import FieldInfo
@ -499,6 +501,8 @@ async def get_builtin_tools(
'channels.enable',
'automations.enable',
'calendar.enable',
'subagents.enable',
'subagents.background_enabled',
)
async def has_user_permission(feature_key: str) -> bool:
@ -563,6 +567,14 @@ async def get_builtin_tools(
if is_builtin_tool_enabled('chats'):
builtin_functions.extend([search_chats, view_chat])
if (
is_builtin_tool_enabled('subagents')
and config.get('subagents.enable')
and getattr(request.state, 'internal', False) is not True
and getattr(request.state, 'direct', False) is not True
):
builtin_functions.append(delegate_task)
# Add memory tools when memory is enabled and the model allows this builtin category.
if (
is_builtin_tool_enabled('memory')
@ -660,6 +672,11 @@ async def get_builtin_tools(
[search_calendar_events, create_calendar_event, update_calendar_event, delete_calendar_event]
)
if getattr(request.state, 'internal', False) is True:
from open_webui.utils.subagents import MUTATING_MEMORY_TOOLS
builtin_functions = [func for func in builtin_functions if func.__name__ not in MUTATING_MEMORY_TOOLS]
for func in builtin_functions:
callable = await get_async_tool_function_and_apply_extra_params(
func,
@ -679,6 +696,11 @@ async def get_builtin_tools(
pydantic_model = convert_function_to_pydantic_model(func)
spec = convert_pydantic_model_to_openai_function_spec(pydantic_model)
spec = clean_openai_tool_schema(spec)
if func.__name__ == 'delegate_task' and not config.get('subagents.background_enabled'):
parameters = spec.get('parameters', {})
parameters.get('properties', {}).pop('background', None)
if isinstance(parameters.get('required'), list):
parameters['required'] = [name for name in parameters['required'] if name != 'background']
tools_dict[func.__name__] = {
'tool_id': f'builtin:{func.__name__}',
@ -1075,7 +1097,9 @@ async def get_terminal_system_prompt(
trust_env=True,
) as session:
# 1. Check feature flag
async with session.get(f'{base}/api/config', ssl=AIOHTTP_CLIENT_SESSION_SSL) as resp:
async with session.get(
f'{base}/api/config', headers=headers, cookies=cookies or {}, ssl=AIOHTTP_CLIENT_SESSION_SSL
) as resp:
if resp.status != 200:
return None
config = await resp.json()
@ -1107,13 +1131,7 @@ async def set_terminal_servers(request: Request):
enabled = connection.get('enabled', True)
base_url = connection.get('url', '').rstrip('/')
policy_id = connection.get('policy_id', '')
# Orchestrator connections route through /p/{policy_id}/ — the
# OpenAPI spec lives on the proxied terminal, not the orchestrator.
if connection.get('server_type') == 'orchestrator' and policy_id:
base_url = f'{base_url}/p/{policy_id}'
base_url = get_terminal_server_url(connection)
server_configs.append(
{
@ -1143,6 +1161,8 @@ async def set_terminal_servers(request: Request):
headers = {}
if connection.get('auth_type', 'bearer') == 'bearer':
headers.update(bearer_auth_header(connection.get('key', '')))
if connection.get('policy_id'):
headers['X-User-Id'] = 'system'
prompt = await get_terminal_system_prompt(server['url'], headers)
if prompt:
server['system_prompt'] = prompt
@ -1192,24 +1212,21 @@ async def get_terminal_tools(
connections = await Config.get('terminal_server.connections', []) or []
connection = next((c for c in connections if c.get('id') == terminal_id), None)
if connection is None:
log.warning(f'Terminal server not found: {terminal_id}')
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' not found")
user_group_ids = {group.id for group in await Groups.get_groups_by_member_id(user.id)}
if not await has_connection_access(user, connection, user_group_ids):
log.warning(f'Access denied to terminal {terminal_id} for user {user.id}')
return {}
raise RuntimeError(f'Access denied to terminal {terminal_id}')
# Find the cached spec data for this terminal
terminal_servers = await get_terminal_servers(request)
server_data = next((s for s in terminal_servers if s.get('id') == terminal_id), None)
if server_data is None:
log.warning(f'Terminal server spec not found for {terminal_id}')
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' is unavailable")
specs = server_data.get('specs', [])
if not specs:
return {}
raise RuntimeError(f"Terminal server '{terminal_id}' has no available tools")
# Build auth headers
auth_type = connection.get('auth_type', 'bearer')
@ -1236,7 +1253,7 @@ async def get_terminal_tools(
if session_id:
headers['X-Session-Id'] = session_id
terminal_cwd = await get_terminal_cwd(connection.get('url', ''), headers, cookies)
terminal_cwd = await get_terminal_cwd(server_data['url'], headers, cookies)
tools_dict = {}
for spec in specs:

View file

@ -22,10 +22,12 @@
import Evaluations from './Settings/Evaluations.svelte';
import CodeExecution from './Settings/CodeExecution.svelte';
import Integrations from './Settings/Integrations.svelte';
import Subagents from './Settings/Subagents.svelte';
import ChartBar from '../icons/ChartBar.svelte';
import DocumentChartBar from '../icons/DocumentChartBar.svelte';
import Search from '../icons/Search.svelte';
import User from '../icons/User.svelte';
import XMark from '../icons/XMark.svelte';
const i18n = getContext('i18n');
@ -41,6 +43,7 @@
'authentication',
'connections',
'models',
'subagents',
'evaluations',
'integrations',
'documents',
@ -145,6 +148,12 @@
'export'
]
},
{
id: 'subagents',
title: 'Sub-agents',
route: '/admin/settings/subagents',
keywords: ['sub-agents', 'subagents', 'delegation', 'background', 'agents']
},
{
id: 'evaluations',
title: 'Evaluations',
@ -345,6 +354,7 @@
<!-- {$i18n.t('Authentication')} -->
<!-- {$i18n.t('Connections')} -->
<!-- {$i18n.t('Models')} -->
<!-- {$i18n.t('Sub-agents')} -->
<!-- {$i18n.t('Evaluations')} -->
<!-- {$i18n.t('Integrations')} -->
<!-- {$i18n.t('Documents')} -->
@ -431,6 +441,8 @@
clip-rule="evenodd"
/>
</svg>
{:else if tab.id === 'subagents'}
<User className="size-4" />
{:else if tab.id === 'documents'}
<svg
xmlns="http://www.w3.org/2000/svg"
@ -573,6 +585,8 @@
/>
{:else if selectedTab === 'models'}
<Models />
{:else if selectedTab === 'subagents'}
<Subagents />
{:else if selectedTab === 'evaluations'}
<Evaluations />
{:else if selectedTab === 'integrations'}

View file

@ -0,0 +1,200 @@
<script lang="ts">
import { getContext, onMount } from 'svelte';
import { toast } from 'svelte-sonner';
import { getSubagentsConfig, setSubagentsConfig } from '$lib/apis/configs';
import Spinner from '$lib/components/common/Spinner.svelte';
import Switch from '$lib/components/common/Switch.svelte';
const i18n = getContext('i18n');
let loading = true;
let saving = false;
let enabled = false;
let backgroundEnabled = false;
let maxConcurrent = 20;
let maxAsync = 20;
let maxIterations = 30;
let maxOutput = 30000;
let systemPrompt = '';
onMount(async () => {
try {
const config = await getSubagentsConfig(localStorage.token);
enabled = config?.ENABLE_SUBAGENTS ?? false;
backgroundEnabled = config?.SUBAGENTS_BACKGROUND_ENABLED ?? false;
maxConcurrent = Number(config?.SUBAGENTS_MAX_CONCURRENT) || 20;
maxAsync = Number(config?.SUBAGENTS_MAX_ASYNC) || 20;
maxIterations = Number(config?.SUBAGENTS_MAX_ITERATIONS) || 30;
maxOutput = Number(config?.SUBAGENTS_MAX_OUTPUT) || 30000;
systemPrompt = config?.SUBAGENTS_SYSTEM_PROMPT ?? '';
} catch (error) {
toast.error(`${error}`);
} finally {
loading = false;
}
});
const save = async () => {
saving = true;
try {
await setSubagentsConfig(localStorage.token, {
ENABLE_SUBAGENTS: enabled,
SUBAGENTS_BACKGROUND_ENABLED: backgroundEnabled,
SUBAGENTS_MAX_CONCURRENT: maxConcurrent,
SUBAGENTS_MAX_ASYNC: maxAsync,
SUBAGENTS_MAX_ITERATIONS: maxIterations,
SUBAGENTS_MAX_OUTPUT: maxOutput,
SUBAGENTS_SYSTEM_PROMPT: systemPrompt
});
toast.success($i18n.t('Settings saved successfully!'));
} catch (error) {
toast.error(`${error}`);
} finally {
saving = false;
}
};
</script>
<form class="flex h-full flex-col justify-between text-sm" on:submit|preventDefault={save}>
<div class="h-full overflow-y-auto scrollbar-hidden">
<div class="mt-0.5 mb-4 text-base font-medium">{$i18n.t('Sub-agents')}</div>
{#if loading}
<div class="flex justify-center py-8"><Spinner className="size-6" /></div>
{:else}
<div class="flex flex-col gap-2.5">
<label class="flex cursor-pointer items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Enable sub-agents')}
</span>
<Switch bind:state={enabled} />
</label>
<p class="-mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t(
'Allow the AI to delegate tasks to sub-agents. Each sub-agent creates a real chat with full tool access. Uses additional LLM calls.'
)}
</p>
{#if enabled}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-concurrent">
{$i18n.t('Max concurrent')}
</label>
<div class="mt-1 flex items-center gap-1.5">
<input
id="sa-concurrent"
type="number"
bind:value={maxConcurrent}
min="-1"
class="h-7 w-16 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:focus:border-blue-500"
/>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('simultaneous sub-agents')}
</span>
</div>
</div>
<div>
<label class="flex cursor-pointer items-center justify-between">
<span class="text-xs text-gray-600 dark:text-gray-400">
{$i18n.t('Enable background sub-agents')}
</span>
<Switch bind:state={backgroundEnabled} />
</label>
<p class="mt-1 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t(
'Allow delegated sub-agents to keep running while the parent chat continues.'
)}
</p>
</div>
{#if backgroundEnabled}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-async">
{$i18n.t('Max background')}
</label>
<div class="mt-1 flex items-center gap-1.5">
<input
id="sa-async"
type="number"
bind:value={maxAsync}
min="-1"
class="h-7 w-16 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:focus:border-blue-500"
/>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('background sub-agents')}
</span>
</div>
</div>
{/if}
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-iterations">
{$i18n.t('Max iterations')}
</label>
<div class="mt-1 flex items-center gap-1.5">
<input
id="sa-iterations"
type="number"
bind:value={maxIterations}
min="1"
max="100"
class="h-7 w-16 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:focus:border-blue-500"
/>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('tool loops per sub-agent')}
</span>
</div>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-output">
{$i18n.t('Max output')}
</label>
<div class="mt-1 flex items-center gap-1.5">
<input
id="sa-output"
type="number"
bind:value={maxOutput}
min="1000"
max="100000"
step="1000"
class="h-7 w-20 rounded-lg border border-gray-200 bg-gray-100 px-2 text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:focus:border-blue-500"
/>
<span class="text-[0.6875rem] text-gray-400 dark:text-gray-600">chars</span>
</div>
</div>
<div>
<label class="text-xs text-gray-600 dark:text-gray-400" for="sa-prompt">
{$i18n.t('System prompt')}
</label>
<textarea
id="sa-prompt"
bind:value={systemPrompt}
rows="4"
placeholder={$i18n.t('You are a sub-agent...')}
class="mt-1 w-full resize-y rounded-lg border border-gray-200 bg-gray-100 px-2 py-1.5 font-mono text-xs text-gray-700 outline-hidden transition-colors focus:border-blue-400 dark:border-white/10 dark:bg-white/5 dark:text-gray-300 dark:focus:border-blue-500"
></textarea>
<p class="mt-0.5 text-[0.6875rem] text-gray-400 dark:text-gray-600">
{$i18n.t('Leave empty for the built-in default.')}
</p>
</div>
{/if}
</div>
{/if}
</div>
{#if !loading}
<div class="flex justify-end pt-6 text-sm font-medium">
<button
class="rounded-full bg-black px-3.5 py-1.5 text-sm font-medium text-white transition hover:bg-gray-900 disabled:opacity-50 dark:bg-white dark:text-black dark:hover:bg-gray-100"
type="submit"
disabled={saving}
>
{$i18n.t('Save')}
</button>
</div>
{/if}
</form>

View file

@ -0,0 +1,67 @@
<script lang="ts">
import { getContext } from 'svelte';
import type { Readable } from 'svelte/store';
import ChevronDown from '$lib/components/icons/ChevronDown.svelte';
type SubagentResult = {
delegation_id?: string;
delegation_ids?: string[];
subagent_chat_id?: string;
subagent_chat_ids?: string[];
};
const i18n = getContext<Readable<{ t: (value: string) => string }>>('i18n');
export let content: string;
export let result: SubagentResult;
let expanded = false;
$: delegationIds = Array.isArray(result.delegation_ids) ? result.delegation_ids : [];
$: delegationLabel =
result.delegation_id ?? (delegationIds.length > 1 ? `${delegationIds.length} tasks` : '');
$: summary = (() => {
const line = content
.split('\n')
.map((value) => value.trim())
.find((value) => value && !value.startsWith('['));
if (!line) return delegationLabel;
return line.length > 96 ? `${line.slice(0, 96)}...` : line;
})();
</script>
<div class="w-full min-w-0 pb-1">
<button
type="button"
class="w-full min-w-0 flex items-center gap-2 text-left text-gray-500 dark:text-gray-400 hover:text-gray-700 dark:hover:text-gray-300 transition-colors"
aria-expanded={expanded}
on:click={() => (expanded = !expanded)}
>
<span class="w-1.5 h-1.5 rounded-full bg-gray-300 dark:bg-gray-600 shrink-0"></span>
<span class="text-[0.75rem] font-medium shrink-0">
{$i18n.t('Background sub-agent finished')}
</span>
{#if summary}
<span class="text-[0.75rem] truncate min-w-0 flex-1">{summary}</span>
{/if}
{#if delegationLabel}
<span
class="hidden sm:inline text-[0.6875rem] font-mono text-gray-400 dark:text-gray-600 shrink-0"
>
{delegationLabel}
</span>
{/if}
<ChevronDown
className="size-3 text-gray-400 dark:text-gray-600 shrink-0 transition-transform duration-150 {expanded
? 'rotate-180'
: ''}"
/>
</button>
{#if expanded}
<div
class="mt-2 ml-3 border-l border-gray-100 dark:border-white/8 pl-3 text-[0.78125rem] leading-relaxed text-gray-600 dark:text-gray-400 whitespace-pre-wrap break-words"
>
{content}
</div>
{/if}
</div>

View file

@ -16,11 +16,19 @@
import Markdown from './Markdown.svelte';
import Image from '$lib/components/common/Image.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import SubagentResultRow from './SubagentResultRow.svelte';
import localizedFormat from 'dayjs/plugin/localizedFormat';
const i18n = getContext('i18n');
dayjs.extend(localizedFormat);
type SubagentResult = {
async_subagent_result: true;
delegation_id?: string;
delegation_ids?: string[];
subagent_chat_id?: string;
subagent_chat_ids?: string[];
};
export let user;
@ -54,6 +62,7 @@
let editScrollContainer: HTMLDivElement;
let message = structuredClone(history.messages[messageId]);
let subagentResult: SubagentResult | undefined;
$: if (history.messages) {
const source = history.messages[messageId];
if (source) {
@ -64,6 +73,7 @@
}
}
}
$: subagentResult = message?.meta?.async_subagent_result ? message.meta : undefined;
const copyToClipboard = async (text) => {
const res = await _copyToClipboard(text);
@ -133,7 +143,7 @@
id="message-{message.id}"
style="scroll-margin-top: 3rem;"
>
{#if !($settings?.chatBubble ?? true)}
{#if !($settings?.chatBubble ?? true) && !subagentResult}
<div class={`shrink-0 ltr:mr-3 rtl:ml-3 mt-1`}>
<ProfileImage
src={user?.id
@ -143,8 +153,8 @@
/>
</div>
{/if}
<div class="flex-auto w-0 max-w-full pl-1">
{#if !($settings?.chatBubble ?? true)}
<div class="flex-auto w-0 max-w-full {subagentResult ? '' : 'pl-1'}">
{#if !($settings?.chatBubble ?? true) && !subagentResult}
<div>
<Name>
{#if message.user}
@ -179,7 +189,7 @@
{/if}
</Name>
</div>
{:else if message.timestamp}
{:else if message.timestamp && !subagentResult}
<div class="flex justify-end pr-2 text-xs">
<div
class="text-[0.65rem] font-medium first-letter:capitalize mb-0.5 {($settings?.highContrastMode ??
@ -365,6 +375,8 @@
</div>
</div>
</div>
{:else if subagentResult}
<SubagentResultRow content={message.content} result={subagentResult} />
{:else if message.content !== ''}
<div class="w-full">
<div class="flex {($settings?.chatBubble ?? true) ? 'justify-end pb-1' : 'w-full'}">
@ -394,7 +406,7 @@
</div>
{/if}
{#if edit !== true}
{#if edit !== true && !subagentResult}
<div
class=" flex {($settings?.chatBubble ?? true)
? 'justify-end'

View file

@ -127,6 +127,20 @@ function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string,
const callId = item.call_id ?? '';
const resultItem = toolOutputByCallId[callId];
const isDone = isDoneStatus(item.status) || !!resultItem;
let name = item.name ?? '';
if (name === 'delegate_task') {
try {
const args =
typeof item.arguments === 'string'
? JSON.parse(item.arguments || '{}')
: (item.arguments ?? {});
const task = typeof args.task === 'string' && args.task ? args.task : '?';
const label = args.background ? 'Background sub-agent' : 'Sub-agent';
name = `${label}: "${task.length > 60 ? `${task.slice(0, 60)}...` : task}"`;
} catch {
name = 'Sub-agent';
}
}
return {
summary: isDone ? 'Tool Executed' : 'Executing...',
@ -134,7 +148,7 @@ function buildToolCallToken(item: OutputItem, toolOutputByCallId: Record<string,
attributes: {
type: 'tool_calls',
id: callId,
name: item.name ?? '',
name,
done: isDone ? 'true' : 'false',
arguments: stringifyAttribute(item.arguments ?? ''),
files: stringifyAttribute(resultItem?.files),

View file

@ -999,7 +999,7 @@
<div class="self-center relative">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class=" size-7 object-cover rounded-full"
class="size-5.5 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}
/>
@ -1627,7 +1627,7 @@
<div class=" self-center mr-3 relative flex-shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class=" size-7 object-cover rounded-full"
class="size-5.5 object-cover rounded-full"
alt={$i18n.t('Open User Profile Menu')}
aria-label={$i18n.t('Open User Profile Menu')}
/>

View file

@ -122,49 +122,13 @@
<div slot="content">
<div
class="{className} rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg text-sm"
class="{className} user-menu rounded-xl p-0.5 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg text-xs"
>
{#if profile}
<div class=" flex gap-3.5 w-full p-2.5 items-center">
<div class=" items-center flex shrink-0">
<img
src={`${WEBUI_API_BASE_URL}/users/${$user?.id}/profile/image`}
class=" size-10 object-cover rounded-full"
alt="profile"
/>
</div>
<div class=" flex flex-col w-full flex-1">
<div class="font-medium line-clamp-1 pr-2">
{$user.name}
</div>
<div class=" flex items-center gap-2">
{#if $user?.is_active ?? true}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
</span>
</div>
<span class="text-xs"> {$i18n.t('Active')} </span>
{:else}
<div>
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-gray-500" />
</span>
</div>
<span class="text-xs"> {$i18n.t('Away')} </span>
{/if}
</div>
</div>
</div>
{#if $user?.status_emoji || $user?.status_message}
<div class="mx-1">
<div class="user-menu-status">
<button
class="mb-1 w-full gap-2 px-2.5 py-1.5 rounded-xl bg-gray-50 dark:text-white dark:bg-gray-900/50 text-black transition text-xs flex items-center"
class="w-full gap-2 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none text-xs flex items-center"
type="button"
on:click={() => {
show = false;
@ -184,9 +148,10 @@
{$user?.status_message}
</Tooltip>
<div class="self-start">
<div class="self-center">
<Tooltip content={$i18n.t('Clear status')}>
<button
class="flex size-5 items-center justify-center"
type="button"
on:click={async (e) => {
e.preventDefault();
@ -213,9 +178,9 @@
</button>
</div>
{:else}
<div class="mx-1">
<div class="user-menu-status">
<button
class="mb-1 w-full px-3 py-1.5 gap-1 rounded-xl bg-gray-50 dark:text-white dark:bg-gray-900/50 text-black transition text-xs flex items-center justify-center"
class="w-full gap-1 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none text-xs flex items-center justify-center"
type="button"
on:click={() => {
show = false;
@ -234,7 +199,7 @@
{/if}
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
@ -257,7 +222,7 @@
<a
href="/admin"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) {
return;
@ -279,7 +244,7 @@
{/if}
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
type="button"
on:click={async () => {
show = false;
@ -306,7 +271,7 @@
<a
href="/workspace"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@ -344,7 +309,7 @@
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
class="p-1 mr-1 rounded-lg hover:bg-gray-100/60 dark:hover:bg-gray-700/60 transition"
on:click|preventDefault|stopPropagation={() => togglePin('workspace')}
>
{#if isPinned('workspace')}
@ -363,7 +328,7 @@
<a
href="/notes"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@ -388,7 +353,7 @@
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
class="p-1 mr-1 rounded-lg hover:bg-gray-100/60 dark:hover:bg-gray-700/60 transition"
on:click|preventDefault|stopPropagation={() => togglePin('notes')}
>
{#if isPinned('notes')}
@ -407,7 +372,7 @@
<a
href="/calendar"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@ -441,7 +406,7 @@
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
class="p-1 mr-1 rounded-lg hover:bg-gray-100/60 dark:hover:bg-gray-700/60 transition"
on:click|preventDefault|stopPropagation={() => togglePin('calendar')}
>
{#if isPinned('calendar')}
@ -460,7 +425,7 @@
<a
href="/automations"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@ -498,7 +463,7 @@
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
class="p-1 mr-1 rounded-lg hover:bg-gray-100/60 dark:hover:bg-gray-700/60 transition"
on:click|preventDefault|stopPropagation={() => togglePin('automations')}
>
{#if isPinned('automations')}
@ -517,7 +482,7 @@
<a
href="/playground"
draggable="false"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex flex-1 rounded-xl py-1.5 px-3 hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
on:click={async (e) => {
if (e.metaKey || e.ctrlKey || e.shiftKey || e.button === 1) return;
e.preventDefault();
@ -542,7 +507,7 @@
>
<button
type="button"
class="p-1 mr-1 rounded-lg hover:bg-gray-100 dark:hover:bg-gray-700 transition"
class="p-1 mr-1 rounded-lg hover:bg-gray-100/60 dark:hover:bg-gray-700/60 transition"
on:click|preventDefault|stopPropagation={() => togglePin('playground')}
>
{#if isPinned('playground')}
@ -566,7 +531,7 @@
href="https://docs.openwebui.com"
target="_blank"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
id="chat-share-button"
on:click={() => {
show = false;
@ -583,7 +548,7 @@
href="https://github.com/open-webui/open-webui/releases"
target="_blank"
draggable="false"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
id="chat-share-button"
on:click={() => {
show = false;
@ -597,7 +562,7 @@
{/if}
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
type="button"
id="chat-share-button"
on:click={async () => {
@ -620,7 +585,7 @@
<hr class=" border-gray-50/30 dark:border-gray-800/30 my-1 p-0" />
<button
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50 dark:hover:bg-gray-800 transition cursor-pointer select-none"
class="flex rounded-xl py-1.5 px-3 w-full hover:bg-gray-50/60 dark:hover:bg-gray-800/60 transition cursor-pointer select-none"
type="button"
on:click={async () => {
const res = await userSignOut();
@ -647,7 +612,7 @@
: ''}
>
<div
class="flex rounded-xl py-1 px-3 text-xs gap-2.5 items-center"
class="flex rounded-xl px-2 py-0.5 text-[10px] gap-1.5 items-center"
on:mouseenter={() => {
if ($config?.features?.enable_public_active_users_count || role === 'admin') {
getUsageInfo();
@ -655,8 +620,8 @@
}}
>
<div class=" flex items-center">
<span class="relative flex size-2">
<span class="relative inline-flex rounded-full size-2 bg-green-500" />
<span class="relative flex size-1.5">
<span class="relative inline-flex rounded-full size-1.5 bg-green-500" />
</span>
</div>
@ -664,7 +629,7 @@
<span class="">
{$i18n.t('Active Users')}:
</span>
<span class=" font-semibold">
<span class="font-medium">
{usage?.user_count}
</span>
</div>
@ -675,3 +640,34 @@
</div>
</div>
</Dropdown>
<style>
.user-menu > button,
.user-menu > a,
.user-menu > div > a {
height: 1.6875rem;
padding: 0 0.5rem;
border-radius: 0.75rem;
}
.user-menu > button > div:first-child,
.user-menu > a > div:first-child,
.user-menu > div > a > div:first-child {
margin-right: 0.5rem;
}
.user-menu > .user-menu-status > button {
height: 1.6875rem;
padding: 0 0.5rem;
border-radius: 0.75rem;
}
.user-menu > hr {
margin: 0.125rem 0.25rem;
}
.user-menu :global(svg) {
width: 0.875rem;
height: 0.875rem;
}
</style>

View file

@ -616,7 +616,10 @@
}
}
if ((event.chat_id !== $chatId && !$temporaryChatEnabled) || isInBackground) {
if (
!event?.internal &&
((event.chat_id !== $chatId && !$temporaryChatEnabled) || isInBackground)
) {
if (type === 'chat:completion') {
const { done, content, output, title } = data;
const displayTitle = title || $i18n.t('New Chat');