This commit is contained in:
Timothy Jaeryang Baek
2026-02-11 15:12:37 -06:00
parent f7c5965a70
commit 64c37ab968
4 changed files with 180 additions and 30 deletions
+88 -1
View File
@@ -9,7 +9,7 @@ from open_webui.models.groups import Groups
from open_webui.models.access_grants import AccessGrantModel, AccessGrants from open_webui.models.access_grants import AccessGrantModel, AccessGrants
from pydantic import BaseModel, ConfigDict, Field from pydantic import BaseModel, ConfigDict, Field
from sqlalchemy import BigInteger, Boolean, Column, String, Text from sqlalchemy import BigInteger, Boolean, Column, String, Text, or_
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
@@ -95,6 +95,11 @@ class SkillForm(BaseModel):
access_grants: Optional[list[dict]] = None access_grants: Optional[list[dict]] = None
class SkillListResponse(BaseModel):
items: list[SkillAccessResponse] = []
total: int = 0
class SkillsTable: class SkillsTable:
def _get_access_grants( def _get_access_grants(
self, skill_id: str, db: Optional[Session] = None self, skill_id: str, db: Optional[Session] = None
@@ -200,6 +205,88 @@ class SkillsTable:
) )
] ]
def search_skills(
self,
user_id: str,
filter: dict,
skip: int = 0,
limit: int = 30,
db: Optional[Session] = None,
) -> SkillListResponse:
try:
with get_db_context(db) as db:
query = db.query(Skill)
query_key = filter.get("query")
if query_key:
query = query.filter(
or_(
Skill.name.ilike(f"%{query_key}%"),
Skill.description.ilike(f"%{query_key}%"),
Skill.id.ilike(f"%{query_key}%"),
)
)
# Only active skills
query = query.filter(Skill.is_active == True)
query = query.order_by(Skill.updated_at.desc())
# Apply access control if not admin bypass
if "user_id" in filter:
user_group_ids = {
group.id
for group in Groups.get_groups_by_member_id(
filter["user_id"], db=db
)
}
all_results = query.all()
accessible = [
s
for s in all_results
if s.user_id == filter["user_id"]
or AccessGrants.has_access(
user_id=filter["user_id"],
resource_type="skill",
resource_id=s.id,
permission="read",
user_group_ids=user_group_ids,
db=db,
)
]
total = len(accessible)
items = accessible[skip : skip + limit] if limit else accessible[skip:]
else:
total = query.count()
if skip:
query = query.offset(skip)
if limit:
query = query.limit(limit)
items = query.all()
user_ids = list(set(s.user_id for s in items))
users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else []
users_dict = {u.id: u for u in users}
skill_responses = []
for skill in items:
user = users_dict.get(skill.user_id)
skill_model = self._to_skill_model(skill, db=db)
skill_responses.append(
SkillAccessResponse(
**SkillUserResponse(
**skill_model.model_dump(),
user=user.model_dump() if user else None,
).model_dump(),
write_access=False,
)
)
return SkillListResponse(items=skill_responses, total=total)
except Exception as e:
log.exception(f"Error searching skills: {e}")
return SkillListResponse(items=[], total=0)
def update_skill_by_id( def update_skill_by_id(
self, id: str, updated: dict, db: Optional[Session] = None self, id: str, updated: dict, db: Optional[Session] = None
) -> Optional[SkillModel]: ) -> Optional[SkillModel]:
+32
View File
@@ -14,6 +14,7 @@ from open_webui.models.skills import (
SkillResponse, SkillResponse,
SkillUserResponse, SkillUserResponse,
SkillAccessResponse, SkillAccessResponse,
SkillListResponse,
Skills, Skills,
) )
from open_webui.models.access_grants import AccessGrants from open_webui.models.access_grants import AccessGrants
@@ -26,6 +27,7 @@ from open_webui.constants import ERROR_MESSAGES
log = logging.getLogger(__name__) log = logging.getLogger(__name__)
PAGE_ITEM_COUNT = 30
router = APIRouter() router = APIRouter()
@@ -98,6 +100,36 @@ async def get_skill_list(
] ]
############################
# SearchSkills
############################
@router.get("/search", response_model=SkillListResponse)
async def search_skills(
query: Optional[str] = None,
page: Optional[int] = 1,
user=Depends(get_verified_user),
db: Session = Depends(get_session),
):
page = max(page, 1)
limit = PAGE_ITEM_COUNT
skip = (page - 1) * limit
filter = {}
if query:
filter["query"] = query
if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL):
filter["user_id"] = user.id
result = Skills.search_skills(
user.id, filter=filter, skip=skip, limit=limit, db=db
)
return result
############################ ############################
# ExportSkills # ExportSkills
############################ ############################
+39
View File
@@ -93,6 +93,45 @@ export const getSkillList = async (token: string = '') => {
return res; return res;
}; };
export const searchSkills = async (
token: string = '',
query: string | null = null,
page: number | null = null
) => {
let error = null;
const searchParams = new URLSearchParams();
if (query) searchParams.append('query', query);
if (page) searchParams.append('page', page.toString());
const res = await fetch(`${WEBUI_API_BASE_URL}/skills/search?${searchParams.toString()}`, {
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();
})
.then((json) => {
return json;
})
.catch((err) => {
error = err.detail;
console.error(err);
return null;
});
if (error) {
throw error;
}
return res;
};
export const exportSkills = async (token: string = '') => { export const exportSkills = async (token: string = '') => {
let error = null; let error = null;
@@ -1,9 +1,6 @@
<script lang="ts"> <script lang="ts">
import Fuse from 'fuse.js'; import { getContext, onDestroy } from 'svelte';
import { searchSkills } from '$lib/apis/skills';
import { getContext } from 'svelte';
import { skills } from '$lib/stores';
import { getSkillList } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte'; import Tooltip from '$lib/components/common/Tooltip.svelte';
import Keyframes from '$lib/components/icons/Keyframes.svelte'; import Keyframes from '$lib/components/icons/Keyframes.svelte';
@@ -15,31 +12,26 @@
let selectedIdx = 0; let selectedIdx = 0;
export let filteredItems = []; export let filteredItems = [];
let _skills = []; let searchDebounceTimer: ReturnType<typeof setTimeout>;
const loadSkills = async () => { $: if (query !== undefined) {
if ($skills) { clearTimeout(searchDebounceTimer);
_skills = $skills; searchDebounceTimer = setTimeout(() => {
} else { getItems();
_skills = await getSkillList(localStorage.token); }, 200);
skills.set(_skills); }
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
const getItems = async () => {
const res = await searchSkills(localStorage.token, query).catch(() => null);
if (res) {
filteredItems = res.items;
} }
}; };
loadSkills();
$: fuse = new Fuse(
(_skills ?? []).filter((s) => s.enabled !== false),
{
keys: ['name', 'id', 'meta.description'],
threshold: 0.5
}
);
$: filteredItems = query
? fuse.search(query).map((e) => e.item)
: (_skills ?? []).filter((s) => s.enabled !== false);
$: if (query) { $: if (query) {
selectedIdx = 0; selectedIdx = 0;
} }
@@ -81,16 +73,16 @@
on:focus={() => {}} on:focus={() => {}}
data-selected={skillIdx === selectedIdx} data-selected={skillIdx === selectedIdx}
> >
<div class="flex text-black dark:text-gray-100 line-clamp-1"> <div class="flex text-black dark:text-gray-100 line-clamp-1 items-center">
<div class="flex items-center justify-center size-5 mr-2 shrink-0"> <div class="flex items-center justify-center size-5 mr-2 shrink-0">
<Keyframes className="size-4" /> <Keyframes className="size-4" />
</div> </div>
<div class="truncate"> <div class="truncate">
{skill.name} {skill.name}
</div> </div>
{#if skill.meta?.description} {#if skill.description}
<div class="ml-2 text-xs text-gray-500 truncate"> <div class="ml-2 text-xs text-gray-500 truncate">
{skill.meta.description} {skill.description}
</div> </div>
{/if} {/if}
</div> </div>