This commit is contained in:
Timothy Jaeryang Baek 2026-11-02 12:45:15 +09:00
parent 6441877c01
commit ddb43c83ba
13 changed files with 83 additions and 16 deletions

View file

@ -2238,6 +2238,7 @@ async def stop_tasks_by_chat_id_endpoint(request: Request, chat_id: str, user=De
if event_emitter:
await event_emitter({'type': 'chat:completion', 'data': {'done': True, 'output': output}})
await event_emitter({'type': 'chat:tasks:cancel'})
await event_emitter({'type': 'chat:active', 'data': {'active': False}})
return result

View file

@ -10,7 +10,7 @@ from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
from open_webui.internal.db import Base, get_async_db_context
from open_webui.utils.response import normalize_usage
from open_webui.utils.session_records import normalize_tool_output, recorded_timestamp
from open_webui.utils.session_records import has_pending_tool_approval, normalize_tool_output, recorded_timestamp
from pydantic import BaseModel, ConfigDict
from sqlalchemy import (
JSON,
@ -356,6 +356,20 @@ class ChatMessageTable:
message = await db.get(ChatMessage, id)
return ChatMessageModel.model_validate(message) if message else None
async def get_chat_ids_with_pending_approval(
self, chat_ids: list[str], db: Optional[AsyncSession] = None
) -> set[str]:
if not chat_ids:
return set()
async with get_async_db_context(db) as db:
result = await db.execute(
select(ChatMessage.chat_id, ChatMessage.output)
.where(ChatMessage.chat_id.in_(chat_ids))
.where(ChatMessage.role == 'assistant')
.where(ChatMessage.done.is_(False))
)
return {chat_id for chat_id, output in result.all() if has_pending_tool_approval(output)}
async def has_unfinished_assistant_by_chat_id(
self,
chat_id: str,

View file

@ -356,6 +356,7 @@ class ChatTitleIdResponse(BaseModel):
last_read_at: int | None = None
snippet: str | None = None
active: bool = False
pending_approval: bool = False
archived: bool = False

View file

@ -179,7 +179,9 @@ def set_message_authors(chat: dict, user: UserModel) -> None:
async def add_active_state_to_chat_list(
request: Request, chat_list: list[ChatTitleIdResponse]
) -> list[ChatTitleIdResponse]:
pending_approval_ids = await ChatMessages.get_chat_ids_with_pending_approval([chat.id for chat in chat_list])
for chat in chat_list:
chat.pending_approval = chat.id in pending_approval_ids
chat.active = False
if not await has_active_tasks(request.app.state.redis, chat.id):
continue

View file

@ -619,6 +619,9 @@ async def get_shared_folder_chats(
user_ids.update(uid for ids in participant_ids.values() for uid in ids)
users = await Users.get_users_by_user_ids(list(user_ids), db=db) if user_ids else []
names = {u.id: u.name for u in users}
pending_approval_ids = await ChatMessages.get_chat_ids_with_pending_approval(
[chat['id'] for chat in chats], db=db
)
for chat in chats:
uid = chat['user_id']
chat['owner_name'] = names.get(uid, 'Unknown')
@ -632,6 +635,7 @@ async def get_shared_folder_chats(
),
)
chat['active'] = False
chat['pending_approval'] = chat['id'] in pending_approval_ids
if chat['user_id'] != user.id:
chat['last_read_at'] = chat['updated_at']
if await has_active_tasks(request.app.state.redis, chat['id']):

View file

@ -37,6 +37,7 @@ from open_webui.env import (
)
from open_webui.models.access_grants import AccessGrants
from open_webui.models.channels import Channels
from open_webui.models.chat_messages import ChatMessages
from open_webui.models.chats import Chats
from open_webui.models.config import Config
from open_webui.models.folders import Folders
@ -1624,6 +1625,13 @@ async def get_event_emitter(request_info, update_db=True):
if internal and event_data.get('type') == 'notification':
return
if event_data.get('type') == 'chat:active' and is_saved_chat_id(chat_id):
pending_approval_ids = await ChatMessages.get_chat_ids_with_pending_approval([chat_id])
event_data = {
**event_data,
'data': {**(event_data.get('data') or {}), 'pending_approval': chat_id in pending_approval_ids},
}
room = f'user:{user_id}'
if is_temporary_chat_id(chat_id):
owner = await get_temporary_chat(redis, chat_id)

View file

@ -3327,6 +3327,9 @@ async def pause_for_tool_approval(chat_id: str, message_id: str, output: list[di
},
touch=False,
)
event_emitter = await get_event_emitter(metadata, update_db=False)
if event_emitter:
await event_emitter({'type': 'chat:active', 'data': {'active': True}})
def get_response_data(response):

View file

@ -6,6 +6,21 @@ import re
import time
def has_pending_tool_approval(output):
if not isinstance(output, list):
return False
items = [item for item in output if isinstance(item, dict)]
result_ids = {item.get('call_id') for item in items if item.get('type') == 'function_call_output'}
return any(
item.get('type') == 'function_call'
and (item.get('call_id') or item.get('id'))
and (item.get('call_id') or item.get('id')) not in result_ids
and item.get('status') in {'pending', 'queued', 'requires_approval'}
and item.get('approved') is not True
for item in items
)
def recorded_timestamp(value):
try:
value = float(value)

View file

@ -258,7 +258,7 @@
{
setFolderItems?: () => unknown;
upsertChat?: (chat: Record<string, unknown>) => unknown;
setChatActive?: (chatId: string, active: boolean) => boolean;
setChatActive?: (chatId: string, active: boolean, pendingApproval: boolean) => boolean;
setChatReadAt?: (chatId: string, lastReadAt: number) => boolean;
setAllChatsRead?: () => unknown;
}
@ -912,6 +912,7 @@
type: string;
data: {
active?: boolean;
pending_approval?: boolean;
folder_id?: string | null;
last_read_at?: number;
folder_unread_counts?: Record<string, number>;
@ -921,10 +922,12 @@
if (event.data?.type === 'chat:active') {
const eventData = event.data.data ?? {};
const active = eventData.active ?? false;
const found = setChatActive(event.chat_id, active);
const pendingApproval = eventData.pending_approval ?? false;
const found = setChatActive(event.chat_id, active, pendingApproval);
let foundInFolder = false;
for (const folder of Object.values(folderRegistry)) {
foundInFolder = folder?.setChatActive?.(event.chat_id, active) || foundInFolder;
foundInFolder =
folder?.setChatActive?.(event.chat_id, active, pendingApproval) || foundInFolder;
}
if (!foundInFolder && active && eventData.folder_id) {
await folderRegistry[eventData.folder_id]?.setFolderItems?.();
@ -1778,6 +1781,7 @@
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
pendingApproval={chat.pending_approval ?? false}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {
@ -1846,6 +1850,7 @@
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
pendingApproval={chat.pending_approval ?? false}
{shiftKey}
selected={selectedChatId === chat.id}
on:select={() => {

View file

@ -93,6 +93,7 @@
export let updatedAt: number | null = null;
export let lastReadAt: number | null = null;
export let active = false;
export let pendingApproval = false;
export let selected = false;
export let shiftKey = false;
@ -563,19 +564,26 @@
</script>
{#snippet chatItemContent()}
<!-- Loading spinner for active chat (left side) -->
{#if active}
{#if pendingApproval || active || unread}
<div class="shrink-0 self-center pr-2">
<Spinner className="size-3" />
<div class="size-3 flex items-center justify-center">
{#if pendingApproval}
<div
class="size-1.5 bg-yellow-500 rounded-full"
role="status"
aria-label={$i18n.t('Waiting for approval')}
title={$i18n.t('Waiting for approval')}
></div>
{:else if active}
<Spinner className="size-3" />
{:else}
<div class="size-1.5 bg-sky-500 rounded-full"></div>
{/if}
</div>
</div>
{/if}
<div class="flex self-center flex-1 w-full min-w-0">
{#if unread}
<div class="shrink-0 self-center pr-2.5 flex transition-opacity duration-300">
<div class="size-1.5 bg-sky-500 rounded-full"></div>
</div>
{/if}
<div
dir="auto"
class="text-left self-center overflow-hidden w-full h-5 truncate {unread

View file

@ -383,7 +383,7 @@
chats = mergeFolderChats(chats ?? [], [chat]);
}
},
setChatActive: (chatId, active) => {
setChatActive: (chatId, active, pendingApproval) => {
if (chats) {
let found = false;
chats = sortFolderChats(
@ -392,7 +392,7 @@
return chat;
}
found = true;
return { ...chat, active };
return { ...chat, active, pending_approval: pendingApproval };
})
);
return found;
@ -1023,6 +1023,7 @@
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
pendingApproval={chat.pending_approval ?? false}
ownerName={chat.owner_name ?? null}
participants={chat.participants ?? []}
ownerUserId={folders[folderId]?.showOwnerInfo ||

View file

@ -136,6 +136,7 @@
updatedAt={chat.updated_at}
lastReadAt={chat.last_read_at}
active={chat.active ?? false}
pendingApproval={chat.pending_approval ?? false}
ownerName={chat.owner_name}
participants={chat.participants ?? []}
ownerUserId={chat.user_id}

View file

@ -116,14 +116,18 @@ export const loadNextChatListPage = async (token: string = ''): Promise<ChatList
}
};
export const setChatActive = (chatId: string, active: boolean): boolean => {
export const setChatActive = (
chatId: string,
active: boolean,
pendingApproval = false
): boolean => {
let found = false;
const updateChat = (chat: ChatListItem) => {
if (chat.id !== chatId) {
return chat;
}
found = true;
return { ...chat, active };
return { ...chat, active, pending_approval: pendingApproval };
};
chatsStore.update((items) => (items ? items.map(updateChat) : items));