Skip to content

Commit 7c7fe44

Browse files
authored
Merge pull request open-webui#21443 from open-webui/dev
0.8.2
2 parents 883f1dd + 15b5f97 commit 7c7fe44

File tree

96 files changed

+1352
-424
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

96 files changed

+1352
-424
lines changed

CHANGELOG.md

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,29 @@ All notable changes to this project will be documented in this file.
55
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
66
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
77

8+
## [0.8.2] - 2026-02-16
9+
10+
### Added
11+
12+
- 🧠 **Skill content handling.** User-selected skills now have their full content injected into the chat, while model-attached skills only display name and description in the available skills list. This allows users to override skill behavior while model-attached skills remain flexible. [Commit](https://github.com/open-webui/open-webui/commit/393c0071dc612c5ac982fb37dfc0288cb9911439)
13+
- ⚙️ **Chat toggles now control built-in tools.** Users can now disable web search, image generation, and code execution on a per-conversation basis, even when those tools are enabled as builtin tools on the model. [#20641](https://github.com/open-webui/open-webui/issues/20641), [#21318](https://github.com/open-webui/open-webui/discussions/21318), [Commit](https://github.com/open-webui/open-webui/commit/c46ef3b63bcc1e2e9adbdd18fab82c4bbe33ff6c), [Commit](https://github.com/open-webui/open-webui/commit/f1a1e64d2e9ad953b2bc2a9543e9a308b7c669c8)
14+
- 🖼️ **Image preview in file modal.** Images uploaded to chats can now be previewed directly in the file management modal, making it easier to identify and manage image files. [#21413](https://github.com/open-webui/open-webui/issues/21413), [Commit](https://github.com/open-webui/open-webui/commit/e1b3e7252c1896c04d498547908f0fce111434e1)
15+
- 🏷️ **Batch tag operations.** Tag creation, deletion, and orphan cleanup for chats now use batch database queries instead of per-tag loops, significantly reducing database round trips when updating, archiving, or deleting chats with multiple tags. [Commit](https://github.com/open-webui/open-webui/commit/c748c3ede)
16+
- 💨 **Faster group list loading.** Group lists and search results now load with a single database query that joins member counts, replacing the previous pattern of fetching groups first and then counting members in a separate batch query. [Commit](https://github.com/open-webui/open-webui/commit/33308022f)
17+
- 🔐 **Skills sharing permissions.** Administrators can now control skills sharing and public sharing permissions per-group, matching the existing capabilities for tools, knowledge, and prompts. [Commit](https://github.com/open-webui/open-webui/commit/88401e91c)
18+
- ⚡ **Long content truncation in preview modals.** Citation and file content modals now truncate markdown-rendered content at 10,000 characters with a "Show all" expansion button, preventing UI jank when previewing very large documents.
19+
- 🌐 **Translation updates.** Translations for Spanish and German were enhanced and expanded.
20+
21+
### Fixed
22+
23+
- 🔐 **OAuth session error handling.** Corrupted OAuth sessions are now gracefully handled and automatically cleaned up instead of causing errors. [Commit](https://github.com/open-webui/open-webui/commit/7e224e4a536b07ec008613f06592e34050e7067c)
24+
- 🐛 **Task model selector validation.** The task model selector in admin settings now correctly accepts models based on the new access grants system instead of rejecting all models with an incorrect error. [Commit](https://github.com/open-webui/open-webui/commit/9a2595f0706d0c9d809ae7746001cf799f98db1d)
25+
- 🔗 **Tool call message preservation.** Models no longer hallucinate tool outputs in multi-turn conversations because tool call history is now properly preserved instead of being merged into assistant messages. [#21098](https://github.com/open-webui/open-webui/discussions/21098), [#20600](https://github.com/open-webui/open-webui/issues/20600), [Commit](https://github.com/open-webui/open-webui/commit/f2aca781c87244cffc130aa2722e700c19a81d66)
26+
- 🔧 **Tool server startup initialization.** External tool servers configured via the "TOOL_SERVER_CONNECTIONS" environment variable now initialize automatically on startup, eliminating the need to manually visit the Admin Panel and save for tools to become available. This enables proper GitOps and containerized deployments. [#18140](https://github.com/open-webui/open-webui/issues/18140), [#20914](https://github.com/open-webui/open-webui/pull/20914), [Commit](https://github.com/open-webui/open-webui/commit/f20cc6d7e6da493eb75ca1618f5cbd068fa57684)
27+
- ♻️ **Resource handle cleanup.** File handles are now properly closed during audio transcription and pipeline uploads, preventing resource leaks that could cause system instability over time. [#21411](https://github.com/open-webui/open-webui/issues/21411)
28+
- ⌨️ **Strikethrough shortcut conflict fix.** Pressing Ctrl+Shift+S to toggle the sidebar no longer causes text to become struck through in the chat input, by disabling the TipTap Strike extension's default keyboard shortcut when rich text mode is off. [Commit](https://github.com/open-webui/open-webui/commit/38ae91ae2)
29+
- 🔧 **Tool call finish_reason fix.** API responses now correctly set finish_reason to "tool_calls" instead of "stop" when tool calls are present, fixing an issue where external API clients (such as OpenCode) would halt prematurely after tool execution when routing Ollama models through the Open WebUI API. [#20896](https://github.com/open-webui/open-webui/issues/20896)
30+
831
## [0.8.1] - 2026-02-14
932

1033
### Added

backend/open_webui/config.py

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1385,6 +1385,18 @@ def reachable(host: str, port: int) -> bool:
13851385
== "true"
13861386
)
13871387

1388+
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING = (
1389+
os.environ.get("USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING", "False").lower()
1390+
== "true"
1391+
)
1392+
1393+
USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING = (
1394+
os.environ.get(
1395+
"USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING", "False"
1396+
).lower()
1397+
== "true"
1398+
)
1399+
13881400

13891401
USER_PERMISSIONS_NOTES_ALLOW_SHARING = (
13901402
os.environ.get("USER_PERMISSIONS_NOTES_ALLOW_SHARING", "False").lower() == "true"
@@ -1543,6 +1555,8 @@ def reachable(host: str, port: int) -> bool:
15431555
"public_prompts": USER_PERMISSIONS_WORKSPACE_PROMPTS_ALLOW_PUBLIC_SHARING,
15441556
"tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_SHARING,
15451557
"public_tools": USER_PERMISSIONS_WORKSPACE_TOOLS_ALLOW_PUBLIC_SHARING,
1558+
"skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_SHARING,
1559+
"public_skills": USER_PERMISSIONS_WORKSPACE_SKILLS_ALLOW_PUBLIC_SHARING,
15461560
"notes": USER_PERMISSIONS_NOTES_ALLOW_SHARING,
15471561
"public_notes": USER_PERMISSIONS_NOTES_ALLOW_PUBLIC_SHARING,
15481562
},

backend/open_webui/main.py

Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
MODELS,
6565
app as socket_app,
6666
periodic_usage_pool_cleanup,
67+
periodic_session_pool_cleanup,
6768
get_event_emitter,
6869
get_models_in_use,
6970
)
@@ -517,6 +518,7 @@
517518
process_chat_payload,
518519
process_chat_response,
519520
)
521+
from open_webui.utils.tools import set_tool_servers
520522

521523
from open_webui.utils.auth import (
522524
get_license_data,
@@ -634,6 +636,7 @@ async def lifespan(app: FastAPI):
634636
limiter.total_tokens = THREAD_POOL_SIZE
635637

636638
asyncio.create_task(periodic_usage_pool_cleanup())
639+
asyncio.create_task(periodic_session_pool_cleanup())
637640

638641
if app.state.config.ENABLE_BASE_MODELS_CACHE:
639642
await get_all_models(
@@ -656,6 +659,30 @@ async def lifespan(app: FastAPI):
656659
None,
657660
)
658661

662+
# Pre-fetch tool server specs so the first request doesn't pay the latency cost
663+
if len(app.state.config.TOOL_SERVER_CONNECTIONS) > 0:
664+
log.info("Initializing tool servers...")
665+
try:
666+
mock_request = Request(
667+
{
668+
"type": "http",
669+
"asgi.version": "3.0",
670+
"asgi.spec_version": "2.0",
671+
"method": "GET",
672+
"path": "/internal",
673+
"query_string": b"",
674+
"headers": Headers({}).raw,
675+
"client": ("127.0.0.1", 12345),
676+
"server": ("127.0.0.1", 80),
677+
"scheme": "http",
678+
"app": app,
679+
}
680+
)
681+
await set_tool_servers(mock_request)
682+
log.info(f"Initialized {len(app.state.TOOL_SERVERS)} tool server(s)")
683+
except Exception as e:
684+
log.warning(f"Failed to initialize tool servers at startup: {e}")
685+
659686
yield
660687

661688
if hasattr(app.state, "redis_task_command_listener"):

backend/open_webui/models/chats.py

Lines changed: 50 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -431,22 +431,29 @@ def update_chat_title_by_id(self, id: str, title: str) -> Optional[ChatModel]:
431431
def update_chat_tags_by_id(
432432
self, id: str, tags: list[str], user
433433
) -> Optional[ChatModel]:
434-
chat = self.get_chat_by_id(id)
435-
if chat is None:
436-
return None
434+
with get_db_context() as db:
435+
chat = db.get(Chat, id)
436+
if chat is None:
437+
return None
438+
439+
old_tags = chat.meta.get("tags", [])
440+
new_tags = [t for t in tags if t.replace(" ", "_").lower() != "none"]
441+
new_tag_ids = [t.replace(" ", "_").lower() for t in new_tags]
437442

438-
self.delete_all_tags_by_id_and_user_id(id, user.id)
443+
# Single meta update
444+
chat.meta = {**chat.meta, "tags": new_tag_ids}
445+
db.commit()
446+
db.refresh(chat)
439447

440-
for tag in chat.meta.get("tags", []):
441-
if self.count_chats_by_tag_name_and_user_id(tag, user.id) == 0:
442-
Tags.delete_tag_by_name_and_user_id(tag, user.id)
448+
# Batch-create any missing tag rows
449+
Tags.ensure_tags_exist(new_tags, user.id, db=db)
443450

444-
for tag_name in tags:
445-
if tag_name.lower() == "none":
446-
continue
451+
# Clean up orphaned old tags in one query
452+
removed = set(old_tags) - set(new_tag_ids)
453+
if removed:
454+
self.delete_orphan_tags_for_user(list(removed), user.id, db=db)
447455

448-
self.add_chat_tag_by_id_and_user_id_and_tag_name(id, user.id, tag_name)
449-
return self.get_chat_by_id(id)
456+
return ChatModel.model_validate(chat)
450457

451458
def get_chat_title_by_id(self, id: str) -> Optional[str]:
452459
chat = self.get_chat_by_id(id)
@@ -1267,8 +1274,8 @@ def get_chat_tags_by_id_and_user_id(
12671274
) -> list[TagModel]:
12681275
with get_db_context(db) as db:
12691276
chat = db.get(Chat, id)
1270-
tags = chat.meta.get("tags", [])
1271-
return [Tags.get_tag_by_name_and_user_id(tag, user_id) for tag in tags]
1277+
tag_ids = chat.meta.get("tags", [])
1278+
return Tags.get_tags_by_ids_and_user_id(tag_ids, user_id, db=db)
12721279

12731280
def get_chat_list_by_user_id_and_tag_name(
12741281
self,
@@ -1309,20 +1316,16 @@ def get_chat_list_by_user_id_and_tag_name(
13091316
def add_chat_tag_by_id_and_user_id_and_tag_name(
13101317
self, id: str, user_id: str, tag_name: str, db: Optional[Session] = None
13111318
) -> Optional[ChatModel]:
1312-
tag = Tags.get_tag_by_name_and_user_id(tag_name, user_id)
1313-
if tag is None:
1314-
tag = Tags.insert_new_tag(tag_name, user_id)
1319+
tag_id = tag_name.replace(" ", "_").lower()
1320+
Tags.ensure_tags_exist([tag_name], user_id, db=db)
13151321
try:
13161322
with get_db_context(db) as db:
13171323
chat = db.get(Chat, id)
1318-
1319-
tag_id = tag.id
13201324
if tag_id not in chat.meta.get("tags", []):
13211325
chat.meta = {
13221326
**chat.meta,
13231327
"tags": list(set(chat.meta.get("tags", []) + [tag_id])),
13241328
}
1325-
13261329
db.commit()
13271330
db.refresh(chat)
13281331
return ChatModel.model_validate(chat)
@@ -1332,40 +1335,53 @@ def add_chat_tag_by_id_and_user_id_and_tag_name(
13321335
def count_chats_by_tag_name_and_user_id(
13331336
self, tag_name: str, user_id: str, db: Optional[Session] = None
13341337
) -> int:
1335-
with get_db_context(db) as db: # Assuming `get_db()` returns a session object
1338+
with get_db_context(db) as db:
13361339
query = db.query(Chat).filter_by(user_id=user_id, archived=False)
1337-
1338-
# Normalize the tag_name for consistency
13391340
tag_id = tag_name.replace(" ", "_").lower()
13401341

13411342
if db.bind.dialect.name == "sqlite":
1342-
# SQLite JSON1 support for querying the tags inside the `meta` JSON field
13431343
query = query.filter(
13441344
text(
1345-
f"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
1345+
"EXISTS (SELECT 1 FROM json_each(Chat.meta, '$.tags') WHERE json_each.value = :tag_id)"
13461346
)
13471347
).params(tag_id=tag_id)
1348-
13491348
elif db.bind.dialect.name == "postgresql":
1350-
# PostgreSQL JSONB support for querying the tags inside the `meta` JSON field
13511349
query = query.filter(
13521350
text(
13531351
"EXISTS (SELECT 1 FROM json_array_elements_text(Chat.meta->'tags') elem WHERE elem = :tag_id)"
13541352
)
13551353
).params(tag_id=tag_id)
1356-
13571354
else:
13581355
raise NotImplementedError(
13591356
f"Unsupported dialect: {db.bind.dialect.name}"
13601357
)
13611358

1362-
# Get the count of matching records
1363-
count = query.count()
1364-
1365-
# Debugging output for inspection
1366-
log.info(f"Count of chats for tag '{tag_name}': {count}")
1359+
return query.count()
13671360

1368-
return count
1361+
def delete_orphan_tags_for_user(
1362+
self,
1363+
tag_ids: list[str],
1364+
user_id: str,
1365+
threshold: int = 0,
1366+
db: Optional[Session] = None,
1367+
) -> None:
1368+
"""Delete tag rows from *tag_ids* that appear in at most *threshold*
1369+
non-archived chats for *user_id*. One query to find orphans, one to
1370+
delete them.
1371+
1372+
Use threshold=0 after a tag is already removed from a chat's meta.
1373+
Use threshold=1 when the chat itself is about to be deleted (the
1374+
referencing chat still exists at query time).
1375+
"""
1376+
if not tag_ids:
1377+
return
1378+
with get_db_context(db) as db:
1379+
orphans = []
1380+
for tag_id in tag_ids:
1381+
count = self.count_chats_by_tag_name_and_user_id(tag_id, user_id, db=db)
1382+
if count <= threshold:
1383+
orphans.append(tag_id)
1384+
Tags.delete_tags_by_ids_and_user_id(orphans, user_id, db=db)
13691385

13701386
def count_chats_by_folder_id_and_user_id(
13711387
self, folder_id: str, user_id: str, db: Optional[Session] = None

backend/open_webui/models/groups.py

Lines changed: 38 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -164,7 +164,10 @@ def get_all_groups(self, db: Optional[Session] = None) -> list[GroupModel]:
164164

165165
def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse]:
166166
with get_db_context(db) as db:
167-
query = db.query(Group)
167+
member_count = func.count(GroupMember.user_id).label("member_count")
168+
query = db.query(Group, member_count).outerjoin(
169+
GroupMember, GroupMember.group_id == Group.id
170+
)
168171

169172
if filter:
170173
if "query" in filter:
@@ -179,9 +182,6 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse
179182
json_share_lower = func.lower(json_share_str)
180183

181184
if share_value:
182-
# Groups open to anyone: data is null, config.share is null, or share is true
183-
# Use case-insensitive string comparison to handle variations like "True", "TRUE"
184-
# Handle potential JSON boolean to string casting issues by checking for both string 'true' and boolean equivalence if possible,
185185
anyone_can_share = or_(
186186
Group.data.is_(None),
187187
json_share_str.is_(None),
@@ -190,7 +190,6 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse
190190
)
191191

192192
if member_id:
193-
# Also include member-only groups where user is a member
194193
member_groups_select = select(GroupMember.group_id).where(
195194
GroupMember.user_id == member_id
196195
)
@@ -211,21 +210,24 @@ def get_groups(self, filter, db: Optional[Session] = None) -> list[GroupResponse
211210
else:
212211
# Only apply member_id filter when share filter is NOT present
213212
if "member_id" in filter:
214-
query = query.join(
215-
GroupMember, GroupMember.group_id == Group.id
216-
).filter(GroupMember.user_id == filter["member_id"])
213+
query = query.filter(
214+
Group.id.in_(
215+
select(GroupMember.group_id).where(
216+
GroupMember.user_id == filter["member_id"]
217+
)
218+
)
219+
)
220+
221+
results = query.group_by(Group.id).order_by(Group.updated_at.desc()).all()
217222

218-
groups = query.order_by(Group.updated_at.desc()).all()
219-
group_ids = [group.id for group in groups]
220-
member_counts = self.get_group_member_counts_by_ids(group_ids, db=db)
221223
return [
222224
GroupResponse.model_validate(
223225
{
224226
**GroupModel.model_validate(group).model_dump(),
225-
"member_count": member_counts.get(group.id, 0),
227+
"member_count": count or 0,
226228
}
227229
)
228-
for group in groups
230+
for group, count in results
229231
]
230232

231233
def search_groups(
@@ -242,31 +244,42 @@ def search_groups(
242244
if "query" in filter:
243245
query = query.filter(Group.name.ilike(f"%{filter['query']}%"))
244246
if "member_id" in filter:
245-
query = query.join(
246-
GroupMember, GroupMember.group_id == Group.id
247-
).filter(GroupMember.user_id == filter["member_id"])
247+
query = query.filter(
248+
Group.id.in_(
249+
select(GroupMember.group_id).where(
250+
GroupMember.user_id == filter["member_id"]
251+
)
252+
)
253+
)
248254

249255
if "share" in filter:
250-
# 'share' is stored in data JSON, support both sqlite and postgres
251256
share_value = filter["share"]
252-
print("Filtering by share:", share_value)
253257
query = query.filter(
254258
Group.data.op("->>")("share") == str(share_value)
255259
)
256260

257261
total = query.count()
258-
query = query.order_by(Group.updated_at.desc())
259-
groups = query.offset(skip).limit(limit).all()
260-
group_ids = [group.id for group in groups]
261-
member_counts = self.get_group_member_counts_by_ids(group_ids, db=db)
262+
263+
member_count = func.count(GroupMember.user_id).label("member_count")
264+
results = (
265+
query.add_columns(member_count)
266+
.outerjoin(GroupMember, GroupMember.group_id == Group.id)
267+
.group_by(Group.id)
268+
.order_by(Group.updated_at.desc())
269+
.offset(skip)
270+
.limit(limit)
271+
.all()
272+
)
262273

263274
return {
264275
"items": [
265276
GroupResponse.model_validate(
266-
**GroupModel.model_validate(group).model_dump(),
267-
member_count=member_counts.get(group.id, 0),
277+
{
278+
**GroupModel.model_validate(group).model_dump(),
279+
"member_count": count or 0,
280+
}
268281
)
269-
for group in groups
282+
for group, count in results
270283
],
271284
"total": total,
272285
}

0 commit comments

Comments
 (0)