From 185bca8552ee3f87ea95fdcad32a433924881b9a Mon Sep 17 00:00:00 2001 From: Timothy Jaeryang Baek Date: Wed, 15 Jul 2026 22:34:52 -0400 Subject: [PATCH] refac --- backend/open_webui/models/chats.py | 30 ++- backend/open_webui/routers/notes.py | 124 +++++++++-- src/app.css | 2 +- src/lib/apis/notes/index.ts | 56 +++++ .../channel/Messages/Message.svelte | 16 +- src/lib/components/chat/Chat.svelte | 198 ++++++++++++++++-- .../chat/EmbeddedChatHistoryItem.svelte | 88 ++++++++ .../chat/Messages/ContentRenderer.svelte | 40 ++-- src/lib/components/chat/Messages/Error.svelte | 6 +- .../components/chat/Messages/Markdown.svelte | 3 +- .../Markdown/ConsecutiveDetailsGroup.svelte | 4 +- .../Messages/Markdown/MarkdownTokens.svelte | 23 +- .../Messages/MultiResponseMessages.svelte | 6 +- src/lib/components/chat/Messages/Name.svelte | 2 +- .../chat/Messages/ResponseMessage.svelte | 6 +- .../Messages/StructuredOutputRenderer.svelte | 78 +++---- .../chat/Messages/UserMessage.svelte | 24 ++- src/lib/components/common/Collapsible.svelte | 2 +- .../components/common/ToolCallDisplay.svelte | 2 +- src/lib/components/notes/NoteEditor.svelte | 139 ++++++++++-- 20 files changed, 693 insertions(+), 156 deletions(-) create mode 100644 src/lib/components/chat/EmbeddedChatHistoryItem.svelte diff --git a/backend/open_webui/models/chats.py b/backend/open_webui/models/chats.py index e53a074e2e..771883e377 100644 --- a/backend/open_webui/models/chats.py +++ b/backend/open_webui/models/chats.py @@ -424,11 +424,27 @@ class ChatTable: Chat.meta['type'].as_string() == 'note', Chat.meta['note_id'].as_string() == note_id, ) - .order_by(Chat.created_at.asc()) + .order_by(Chat.updated_at.desc(), Chat.created_at.desc()) ) chat = result.scalars().first() return ChatModel.model_validate(chat) if chat else None + async def get_internal_chats_by_note_id( + self, note_id: str, user_id: str, db: AsyncSession | None = None + ) -> list[ChatModel]: + async with get_async_db_context(db) as session: + result = await session.execute( + select(Chat) + .where( + Chat.user_id == user_id, + Chat.meta['internal'].as_boolean().is_(True), + Chat.meta['type'].as_string() == 'note', + Chat.meta['note_id'].as_string() == note_id, + ) + .order_by(Chat.updated_at.desc(), Chat.created_at.desc()) + ) + return [ChatModel.model_validate(chat) for chat in result.scalars().all()] + def _chat_import_form_to_chat_model(self, user_id: str, form_data: ChatImportForm) -> ChatModel: id = str(uuid.uuid4()) chat = ChatModel( @@ -1545,14 +1561,12 @@ class ChatTable: # Check if there are any tags to filter if 'none' in tag_ids: - stmt = stmt.filter( - text(""" + stmt = stmt.filter(text(""" NOT EXISTS ( SELECT 1 FROM json_each(Chat.meta, '$.tags') AS tag ) - """) - ) + """)) elif tag_ids: stmt = stmt.filter( and_( @@ -1595,14 +1609,12 @@ class ChatTable: ).params(title_key=f'%{search_text}%', content_key=search_text.lower()) if 'none' in tag_ids: - stmt = stmt.filter( - text(""" + stmt = stmt.filter(text(""" NOT EXISTS ( SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') AS tag ) - """) - ) + """)) elif tag_ids: stmt = stmt.filter( and_( diff --git a/backend/open_webui/routers/notes.py b/backend/open_webui/routers/notes.py index 465d1bf1a1..5e2ffe1ec5 100644 --- a/backend/open_webui/routers/notes.py +++ b/backend/open_webui/routers/notes.py @@ -47,14 +47,6 @@ def _truncate_note_data(data: Optional[dict], max_length: int = 1000) -> Optiona return {'content': {'md': md[:max_length]}} -def _note_chat_system_prompt(note_id: str) -> str: - return ( - f'You are chatting with note {note_id}. Use view_note with this note id to read the current note. ' - 'For edits, use replace_note_content for whole-note changes or replace_note_text ' - 'for targeted exact text replacement.' - ) - - async def _normalize_note_chat_payload(chat: ChatResponse, note_id: str, db: AsyncSession) -> ChatResponse: payload = {**(chat.chat or {})} params = {**(payload.get('params') or {})} @@ -63,7 +55,11 @@ async def _normalize_note_chat_payload(chat: ChatResponse, note_id: str, db: Asy if params.pop('note_id', None) is not None: changed = True - system = _note_chat_system_prompt(note_id) + system = ( + f'You are chatting with note {note_id}. Use view_note with this note id to read the current note. ' + 'For edits, use replace_note_content for whole-note changes or replace_note_text ' + 'for targeted exact text replacement.' + ) if params.get('system') != system: params['system'] = system changed = True @@ -375,7 +371,6 @@ async def get_note_chat_by_id( log.info('[note-chat] reusing hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) return await _normalize_note_chat_payload(chat, note.id, db) - meta = {'internal': True, 'type': 'note', 'note_id': note.id} chat_id = str(uuid4()) chat = await Chats.insert_new_chat( chat_id, @@ -385,18 +380,123 @@ async def get_note_chat_by_id( 'id': chat_id, 'title': 'Chat', 'models': [''], - 'params': {'system': _note_chat_system_prompt(note.id)}, + 'params': { + 'system': ( + f'You are chatting with note {note.id}. Use view_note with this note id to read the current note. ' + 'For edits, use replace_note_content for whole-note changes or replace_note_text ' + 'for targeted exact text replacement.' + ) + }, 'history': {'messages': {}, 'currentId': None}, 'messages': [], 'tags': [], } ), db=db, - internal_meta=meta, + internal_meta={'internal': True, 'type': 'note', 'note_id': note.id}, ) if not chat: log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id) raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + + log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) + return chat + + +@router.get('/{id}/chats', response_model=list[ChatResponse]) +async def get_note_chats_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', await Config.get('user.permissions'), db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + chats = await Chats.get_internal_chats_by_note_id(note.id, user.id, db=db) + return [await _normalize_note_chat_payload(chat, note.id, db) for chat in chats] + + +@router.post('/{id}/chat', response_model=ChatResponse) +async def create_note_chat_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: AsyncSession = Depends(get_async_session), +): + if user.role != 'admin' and not await has_permission( + user.id, 'features.notes', await Config.get('user.permissions'), db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + note = await Notes.get_note_by_id(id, db=db) + if not note: + raise HTTPException(status_code=status.HTTP_404_NOT_FOUND, detail=ERROR_MESSAGES.NOT_FOUND) + + if user.role != 'admin' and ( + user.id != note.user_id + and not await AccessGrants.has_access( + user_id=user.id, + resource_type='note', + resource_id=note.id, + permission='read', + db=db, + ) + ): + raise HTTPException(status_code=status.HTTP_403_FORBIDDEN, detail=ERROR_MESSAGES.DEFAULT()) + + chat_id = str(uuid4()) + chat = await Chats.insert_new_chat( + chat_id, + user.id, + ChatForm( + chat={ + 'id': chat_id, + 'title': 'Chat', + 'models': [''], + 'params': { + 'system': ( + f'You are chatting with note {note.id}. Use view_note with this note id to read the current note. ' + 'For edits, use replace_note_content for whole-note changes or replace_note_text ' + 'for targeted exact text replacement.' + ) + }, + 'history': {'messages': {}, 'currentId': None}, + 'messages': [], + 'tags': [], + } + ), + db=db, + internal_meta={'internal': True, 'type': 'note', 'note_id': note.id}, + ) + if not chat: + log.error('[note-chat] failed creating hidden chat note_id=%s user_id=%s', note.id, user.id) + raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=ERROR_MESSAGES.DEFAULT()) + log.info('[note-chat] created hidden chat note_id=%s chat_id=%s user_id=%s', note.id, chat.id, user.id) return chat diff --git a/src/app.css b/src/app.css index 527e92046d..702891eba8 100644 --- a/src/app.css +++ b/src/app.css @@ -122,7 +122,7 @@ textarea::-webkit-scrollbar-corner { } .markdown-prose { - @apply prose dark:prose-invert max-w-none break-words font-normal leading-relaxed prose-p:my-0 prose-p:font-normal prose-p:leading-relaxed prose-headings:my-1 prose-headings:font-normal prose-headings:leading-snug prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:-my-0 prose-ol:-my-0 prose-li:-my-0 prose-li:font-normal prose-pre:my-0 prose-table:my-0 prose-blockquote:my-0 prose-blockquote:font-normal prose-hr:my-4 prose-img:my-1 [&>:first-child]:mt-0 [&>:last-child]:mb-0; + @apply prose prose-sm dark:prose-invert max-w-none break-words font-normal leading-relaxed prose-p:mt-0 prose-p:mb-2 prose-p:font-normal prose-p:leading-relaxed prose-headings:mt-2 prose-headings:mb-1 prose-headings:font-normal prose-headings:leading-snug prose-h1:text-xl prose-h2:text-lg prose-h3:text-base prose-strong:font-medium prose-code:before:content-none prose-code:after:content-none prose-ul:my-2 prose-ol:my-2 prose-li:my-0.5 prose-li:font-normal prose-pre:my-3 prose-table:my-0 prose-blockquote:my-3 prose-blockquote:font-normal prose-hr:my-4 prose-hr:border-gray-50 prose-hr:dark:border-gray-850/30 prose-img:my-2 [&>:first-child]:mt-0 [&>:last-child]:mb-0 [&_p:first-child]:mt-0 [&_h1:first-child]:mt-0 [&_h2:first-child]:mt-0 [&_h3:first-child]:mt-0 [&_h4:first-child]:mt-0 [&_h5:first-child]:mt-0 [&_h6:first-child]:mt-0; } .markdown-prose-sm { diff --git a/src/lib/apis/notes/index.ts b/src/lib/apis/notes/index.ts index b5603beeba..ee33948521 100644 --- a/src/lib/apis/notes/index.ts +++ b/src/lib/apis/notes/index.ts @@ -254,6 +254,62 @@ export const getNoteChatById = async (token: string, id: string) => { return res; }; +export const getNoteChatsById = async (token: string, id: string) => { + let error = null; + const url = `${WEBUI_API_BASE_URL}/notes/${id}/chats`; + + const res = await fetch(url, { + method: 'GET', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const createNoteChatById = async (token: string, id: string) => { + let error = null; + const url = `${WEBUI_API_BASE_URL}/notes/${id}/chat`; + + const res = await fetch(url, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + } + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + export const updateNoteById = async (token: string, id: string, note: NoteItem) => { let error = null; diff --git a/src/lib/components/channel/Messages/Message.svelte b/src/lib/components/channel/Messages/Message.svelte index 4d9e401f8c..3393f9fab7 100644 --- a/src/lib/components/channel/Messages/Message.svelte +++ b/src/lib/components/channel/Messages/Message.svelte @@ -525,16 +525,18 @@ {:else} -
+
{#if (message?.content ?? '').trim() === '' && message?.meta?.model_id} {:else} - {#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null} + + {#if message.created_at !== message.updated_at && (message?.meta?.model_id ?? null) === null}({$i18n.t('edited')}){/if} {/if} diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index bd6600357f..6058792cbc 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -107,6 +107,8 @@ import EventConfirmDialog from '../common/ConfirmDialog.svelte'; import DeleteConfirmDialog from '../common/ConfirmDialog.svelte'; import WebSearchConfirmDialog from '../common/ConfirmDialog.svelte'; + import Dropdown from '../common/Dropdown.svelte'; + import DropdownMenu from '../common/DropdownMenu.svelte'; import Placeholder from './Placeholder.svelte'; import FilesOverlay from './MessageInput/FilesOverlay.svelte'; import NotificationToast from '../NotificationToast.svelte'; @@ -116,14 +118,25 @@ import Sidebar from '../icons/Sidebar.svelte'; import Image from '../common/Image.svelte'; import XMark from '../icons/XMark.svelte'; + import EditPencilIcon from '../layout/Sidebar/icons/EditPencil.svelte'; + import ChevronRight from '../icons/ChevronRight.svelte'; + import EmbeddedChatHistoryItem from './EmbeddedChatHistoryItem.svelte'; export let chatIdProp = ''; export let embedded = false; export let embeddedTitle = ''; + export let embeddedChats = []; + export let embeddedDraftKey = ''; export let initialFiles = []; export let selectedText = ''; export let onInsertToNote: ((content: string) => void) | null = null; export let onCloseEmbedded: (() => void) | null = null; + export let onNewEmbeddedChat: (() => void | Promise) | null = null; + export let onCreateEmbeddedChat: (() => any | Promise) | null = null; + export let onSelectEmbeddedChat: ((chatId: string) => void | Promise) | null = null; + export let onDeleteEmbeddedChat: ((chatId: string) => void | Promise) | null = null; + export let onEmbeddedChatTitle: ((chatId: string, title: string) => void | Promise) | null = + null; let loading = true; $: chatContainerId = embedded ? 'note-chat-container' : 'chat-container'; @@ -133,6 +146,9 @@ 'Extract action items from this note.', 'Rewrite the selected text.' ]; + let showEmbeddedChatHistory = false; + let embeddedChatOptionsId = ''; + let deletingEmbeddedChatId = ''; const eventTarget = new EventTarget(); let controlPane: Pane | undefined; @@ -278,10 +294,7 @@ }; $: contextUsage = getContextUsage() ?? serverContextUsage; - $: embeddedHeaderTitle = - embeddedTitle || - ($chatTitle && !$chatTitle.startsWith('Chat:') ? $chatTitle : '') || - $i18n.t('Chat'); + $: embeddedHeaderTitle = embeddedTitle || $chatTitle || $i18n.t('Chat'); let selectedToolIds = []; let selectedSkillIds = []; @@ -364,12 +377,12 @@ let params = {}; let appliedInitialFilesKey = ''; let loadedChatIdProp = ''; + let loadedEmbeddedDraftKey = ''; - const fileKey = (file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`; const mergeFiles = (current, incoming) => { const seen = new Set(); return [...(incoming ?? []), ...(current ?? [])].filter((file) => { - const key = fileKey(file); + const key = `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`; if (seen.has(key)) return false; seen.add(key); return true; @@ -378,7 +391,9 @@ const applyInitialFiles = () => { if (!embedded || !initialFiles?.length) return; - const key = JSON.stringify(initialFiles.map(fileKey)); + const key = JSON.stringify( + initialFiles.map((file) => `${file?.type ?? ''}:${file?.id ?? file?.url ?? file?.name ?? ''}`) + ); if (key === appliedInitialFilesKey) return; files = mergeFiles(files, initialFiles); @@ -389,10 +404,6 @@ embedded && selectedText?.trim() ? `${text}\n\nSelected note text:\n${selectedText.trim()}` : text; - const submitEmbeddedPrompt = async (text: string) => { - await tick(); - await submitHandler(withSelectedText(text)); - }; const noteChatDebug = (message: string, data: Record = {}) => { if (!embedded) return; console.info('[note-chat]', message, { @@ -415,6 +426,12 @@ navigateHandler(); } + $: if (embedded && embeddedDraftKey && embeddedDraftKey !== loadedEmbeddedDraftKey) { + noteChatDebug('embedded draft requested', { embeddedDraftKey }); + loadedEmbeddedDraftKey = embeddedDraftKey; + initEmbeddedDraft(); + } + let saveControlsTimer; $: if (!loading && !$temporaryChatEnabled && $chatId && params && chatFiles) { clearTimeout(saveControlsTimer); @@ -502,6 +519,46 @@ } }; + const initEmbeddedDraft = async () => { + clearTimeout(saveControlsTimer); + await saveControls(); + + if ($chatId && !$temporaryChatEnabled) { + updateLastReadAt($chatId); + } + + loading = true; + loadedChatIdProp = ''; + chat = null; + tags = []; + taskIds = null; + chatTasks = []; + serverContextUsage = null; + history = { + messages: {}, + currentId: null + }; + params = {}; + chatFiles = []; + files = []; + selectedToolIds = []; + selectedSkillIds = []; + selectedFilterIds = []; + webSearchEnabled = false; + imageGenerationEnabled = false; + codeInterpreterEnabled = false; + prompt = ''; + messageInput?.setText(''); + await chatId.set(''); + await chatTitle.set(''); + + await setDefaults(); + loading = false; + await tick(); + applyInitialFiles(); + document.getElementById('chat-input')?.focus(); + }; + const onSelect = async (e) => { const { type, data } = e; @@ -888,6 +945,9 @@ message.favorite = data.favorite; } else if (type === 'chat:title') { chatTitle.set(data); + if (embedded && $chatId) { + await onEmbeddedChatTitle?.($chatId, data); + } await refreshChatList(localStorage.token); } else if (type === 'chat:tags') { chat = await getChatById(localStorage.token, $chatId); @@ -2655,9 +2715,26 @@ } history = history; - // New chat — backend generates the chat_id on first request + // Empty embedded drafts create their backing chat only when the first message is sent. if (!_chatId) { - if ($temporaryChatEnabled) { + if (embedded && onCreateEmbeddedChat) { + const createdChat = await onCreateEmbeddedChat(); + if (!createdChat?.id) { + toast.error($i18n.t('Failed to create chat')); + return; + } + + chat = createdChat; + _chatId = createdChat.id; + loadedChatIdProp = _chatId; + await chatId.set(_chatId); + await chatTitle.set(createdChat?.chat?.title ?? createdChat?.title ?? $i18n.t('Chat')); + + params = structuredClone(createdChat?.chat?.params ?? {}); + delete params.note_id; + chatFiles = mergeFiles(chatFiles, createdChat?.chat?.files ?? []); + applyInitialFiles(); + } else if ($temporaryChatEnabled) { _chatId = `local:${$socket?.id}`; await chatId.set(_chatId); } @@ -2954,7 +3031,11 @@ ...(continueResponse ? { assistant_message_id: responseMessageId } : {}), background_tasks: { - ...(!$temporaryChatEnabled && !_chatId && (userMessage?.parentId ?? null) === null + ...(!$temporaryChatEnabled && + (!_chatId || + (embedded && + (userMessage?.parentId ?? null) === null && + createMessagesList(_history, responseMessageId).length === 2)) ? { title_generation: $settings?.title?.auto ?? true, tags_generation: $settings?.autoTags ?? true @@ -3540,13 +3621,88 @@
-
- {embeddedHeaderTitle} +
+ { + if (!state) embeddedChatOptionsId = ''; + }} + > + + +
+ + {#if onNewEmbeddedChat && Object.keys(history?.messages ?? {}).length > 0 && !loading} + +
+ {/if} + {#if embeddedChats.length > 0} + {#each embeddedChats as item} + { + showEmbeddedChatHistory = false; + await onSelectEmbeddedChat?.(item.id); + }} + onDelete={async (id) => { + if (!id || deletingEmbeddedChatId) return; + + deletingEmbeddedChatId = id; + embeddedChatOptionsId = ''; + try { + await onDeleteEmbeddedChat?.(id); + } finally { + deletingEmbeddedChatId = ''; + } + }} + onMenuOpenChange={(id, state) => { + embeddedChatOptionsId = state ? id : ''; + }} + /> + {/each} + {:else} +
+ {$i18n.t('No chat history')} +
+ {/if} +
+
+
diff --git a/src/lib/components/chat/EmbeddedChatHistoryItem.svelte b/src/lib/components/chat/EmbeddedChatHistoryItem.svelte new file mode 100644 index 0000000000..5c91909382 --- /dev/null +++ b/src/lib/components/chat/EmbeddedChatHistoryItem.svelte @@ -0,0 +1,88 @@ + + + + + +
+ + + +
+ +
+ diff --git a/src/lib/components/chat/Messages/ContentRenderer.svelte b/src/lib/components/chat/Messages/ContentRenderer.svelte index 9a021f73fb..bd34de9e90 100644 --- a/src/lib/components/chat/Messages/ContentRenderer.svelte +++ b/src/lib/components/chat/Messages/ContentRenderer.svelte @@ -283,31 +283,35 @@ onPreview={previewHandler} /> {:else if $settings?.renderMarkdownInAssistantMessages ?? true} - +
+ +
{:else} {@const extracted = extractDetailsBlocks(content)} {#if extracted.detailsContent} - +
+ +
{/if} {#if extracted.plainContent} -
{extracted.plainContent}
+
{extracted.plainContent}
{/if} {/if}
diff --git a/src/lib/components/chat/Messages/Error.svelte b/src/lib/components/chat/Messages/Error.svelte index 5cb7679ed1..d5ecab4c74 100644 --- a/src/lib/components/chat/Messages/Error.svelte +++ b/src/lib/components/chat/Messages/Error.svelte @@ -38,11 +38,11 @@
- + -
+
{message}
diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 5b699578ed..96b22a4238 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -10,8 +10,7 @@ import citationExtension from '$lib/utils/marked/citation-extension'; const options = { - throwOnError: false, - breaks: true + throwOnError: false }; marked.use(markedKatexExtension(options)); diff --git a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte index 990740d133..27770102aa 100644 --- a/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte +++ b/src/lib/components/chat/Messages/Markdown/ConsecutiveDetailsGroup.svelte @@ -112,7 +112,7 @@