diff --git a/backend/open_webui/main.py b/backend/open_webui/main.py index 72cb31fcfe..4d838d251c 100644 --- a/backend/open_webui/main.py +++ b/backend/open_webui/main.py @@ -90,6 +90,7 @@ from open_webui.routers import ( knowledge, prompts, evaluations, + skills, tools, users, utils, @@ -1467,6 +1468,7 @@ app.include_router(models.router, prefix="/api/v1/models", tags=["models"]) app.include_router(knowledge.router, prefix="/api/v1/knowledge", tags=["knowledge"]) app.include_router(prompts.router, prefix="/api/v1/prompts", tags=["prompts"]) app.include_router(tools.router, prefix="/api/v1/tools", tags=["tools"]) +app.include_router(skills.router, prefix="/api/v1/skills", tags=["skills"]) app.include_router(memories.router, prefix="/api/v1/memories", tags=["memories"]) app.include_router(folders.router, prefix="/api/v1/folders", tags=["folders"]) diff --git a/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py new file mode 100644 index 0000000000..26e9e66240 --- /dev/null +++ b/backend/open_webui/migrations/versions/a1b2c3d4e5f6_add_skill_table.py @@ -0,0 +1,45 @@ +"""Add skill table + +Revision ID: a1b2c3d4e5f6 +Revises: f1e2d3c4b5a6 +Create Date: 2026-02-11 09:30:00.000000 + +""" + +from typing import Sequence, Union + +from alembic import op +import sqlalchemy as sa + +from open_webui.migrations.util import get_existing_tables + +revision: str = "a1b2c3d4e5f6" +down_revision: Union[str, None] = "f1e2d3c4b5a6" +branch_labels: Union[str, Sequence[str], None] = None +depends_on: Union[str, Sequence[str], None] = None + + +def upgrade() -> None: + existing_tables = set(get_existing_tables()) + + if "skill" not in existing_tables: + op.create_table( + "skill", + sa.Column("id", sa.String(), nullable=False, primary_key=True), + sa.Column("user_id", sa.String(), nullable=False), + sa.Column("name", sa.Text(), nullable=False, unique=True), + sa.Column("description", sa.Text(), nullable=True), + sa.Column("content", sa.Text(), nullable=False), + sa.Column("meta", sa.JSON(), nullable=True), + sa.Column("is_active", sa.Boolean(), nullable=False), + sa.Column("updated_at", sa.BigInteger(), nullable=False), + sa.Column("created_at", sa.BigInteger(), nullable=False), + ) + op.create_index("idx_skill_user_id", "skill", ["user_id"]) + op.create_index("idx_skill_updated_at", "skill", ["updated_at"]) + + +def downgrade() -> None: + op.drop_index("idx_skill_updated_at", table_name="skill") + op.drop_index("idx_skill_user_id", table_name="skill") + op.drop_table("skill") diff --git a/backend/open_webui/models/skills.py b/backend/open_webui/models/skills.py new file mode 100644 index 0000000000..00239e6afd --- /dev/null +++ b/backend/open_webui/models/skills.py @@ -0,0 +1,340 @@ +import logging +import time +from typing import Optional + +from sqlalchemy.orm import Session +from open_webui.internal.db import Base, JSONField, get_db, get_db_context +from open_webui.models.users import Users, UserResponse +from open_webui.models.groups import Groups +from open_webui.models.access_grants import AccessGrantModel, AccessGrants + +from pydantic import BaseModel, ConfigDict, Field +from sqlalchemy import BigInteger, Boolean, Column, String, Text, or_ + + +log = logging.getLogger(__name__) + +#################### +# Skills DB Schema +#################### + + +class Skill(Base): + __tablename__ = "skill" + + id = Column(String, primary_key=True, unique=True) + user_id = Column(String) + name = Column(Text, unique=True) + description = Column(Text, nullable=True) + content = Column(Text) + meta = Column(JSONField) + is_active = Column(Boolean, default=True) + + updated_at = Column(BigInteger) + created_at = Column(BigInteger) + + +class SkillMeta(BaseModel): + tags: Optional[list[str]] = [] + + +class SkillModel(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + model_config = ConfigDict(from_attributes=True) + + +#################### +# Forms +#################### + + +class SkillUserModel(SkillModel): + user: Optional[UserResponse] = None + + +class SkillResponse(BaseModel): + id: str + user_id: str + name: str + description: Optional[str] = None + meta: SkillMeta + is_active: bool = True + access_grants: list[AccessGrantModel] = Field(default_factory=list) + updated_at: int # timestamp in epoch + created_at: int # timestamp in epoch + + +class SkillUserResponse(SkillResponse): + user: Optional[UserResponse] = None + + model_config = ConfigDict(extra="allow") + + +class SkillAccessResponse(SkillUserResponse): + write_access: Optional[bool] = False + + +class SkillForm(BaseModel): + id: str + name: str + description: Optional[str] = None + content: str + meta: SkillMeta = SkillMeta() + is_active: bool = True + access_grants: Optional[list[dict]] = None + + +class SkillListResponse(BaseModel): + items: list[SkillUserResponse] = [] + total: int = 0 + + +class SkillAccessListResponse(BaseModel): + items: list[SkillAccessResponse] = [] + total: int = 0 + + +class SkillsTable: + def _get_access_grants( + self, skill_id: str, db: Optional[Session] = None + ) -> list[AccessGrantModel]: + return AccessGrants.get_grants_by_resource("skill", skill_id, db=db) + + def _to_skill_model(self, skill: Skill, db: Optional[Session] = None) -> SkillModel: + skill_data = SkillModel.model_validate(skill).model_dump(exclude={"access_grants"}) + skill_data["access_grants"] = self._get_access_grants(skill_data["id"], db=db) + return SkillModel.model_validate(skill_data) + + def insert_new_skill( + self, + user_id: str, + form_data: SkillForm, + db: Optional[Session] = None, + ) -> Optional[SkillModel]: + with get_db_context(db) as db: + try: + result = Skill( + **{ + **form_data.model_dump(exclude={"access_grants"}), + "user_id": user_id, + "updated_at": int(time.time()), + "created_at": int(time.time()), + } + ) + db.add(result) + db.commit() + db.refresh(result) + AccessGrants.set_access_grants( + "skill", result.id, form_data.access_grants, db=db + ) + if result: + return self._to_skill_model(result, db=db) + else: + return None + except Exception as e: + log.exception(f"Error creating a new skill: {e}") + return None + + def get_skill_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.get(Skill, id) + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skill_by_name( + self, name: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + skill = db.query(Skill).filter_by(name=name).first() + return self._to_skill_model(skill, db=db) if skill else None + except Exception: + return None + + def get_skills(self, db: Optional[Session] = None) -> list[SkillUserModel]: + with get_db_context(db) as db: + all_skills = db.query(Skill).order_by(Skill.updated_at.desc()).all() + + user_ids = list(set(skill.user_id for skill in all_skills)) + + users = Users.get_users_by_user_ids(user_ids, db=db) if user_ids else [] + users_dict = {user.id: user for user in users} + + skills = [] + for skill in all_skills: + user = users_dict.get(skill.user_id) + skills.append( + SkillUserModel.model_validate( + { + **self._to_skill_model(skill, db=db).model_dump(), + "user": user.model_dump() if user else None, + } + ) + ) + return skills + + def get_skills_by_user_id( + self, user_id: str, permission: str = "write", db: Optional[Session] = None + ) -> list[SkillUserModel]: + skills = self.get_skills(db=db) + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user_id, db=db) + } + + return [ + skill + for skill in skills + if skill.user_id == user_id + or AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission=permission, + user_group_ids=user_group_ids, + db=db, + ) + ] + + 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: + from open_webui.models.users import User, UserModel + + # Join with User table for user filtering + query = db.query(Skill, User).outerjoin( + User, User.id == Skill.user_id + ) + + if filter: + 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}%"), + User.name.ilike(f"%{query_key}%"), + User.email.ilike(f"%{query_key}%"), + ) + ) + + view_option = filter.get("view_option") + if view_option == "created": + query = query.filter(Skill.user_id == user_id) + elif view_option == "shared": + query = query.filter(Skill.user_id != user_id) + + # Apply access grant filtering + query = AccessGrants.has_permission_filter( + db=db, + query=query, + DocumentModel=Skill, + filter=filter, + resource_type="skill", + permission="read", + ) + + query = query.order_by(Skill.updated_at.desc()) + + # Count BEFORE pagination + total = query.count() + + if skip: + query = query.offset(skip) + if limit: + query = query.limit(limit) + + items = query.all() + + skills = [] + for skill, user in items: + skills.append( + SkillUserResponse( + **self._to_skill_model(skill, db=db).model_dump(), + user=( + UserResponse( + **UserModel.model_validate(user).model_dump() + ) + if user + else None + ), + ) + ) + + return SkillListResponse(items=skills, total=total) + except Exception as e: + log.exception(f"Error searching skills: {e}") + return SkillListResponse(items=[], total=0) + + def update_skill_by_id( + self, id: str, updated: dict, db: Optional[Session] = None + ) -> Optional[SkillModel]: + try: + with get_db_context(db) as db: + access_grants = updated.pop("access_grants", None) + db.query(Skill).filter_by(id=id).update( + {**updated, "updated_at": int(time.time())} + ) + db.commit() + if access_grants is not None: + AccessGrants.set_access_grants("skill", id, access_grants, db=db) + + skill = db.query(Skill).get(id) + db.refresh(skill) + return self._to_skill_model(skill, db=db) + except Exception: + return None + + def toggle_skill_by_id( + self, id: str, db: Optional[Session] = None + ) -> Optional[SkillModel]: + with get_db_context(db) as db: + try: + skill = db.query(Skill).filter_by(id=id).first() + if not skill: + return None + + skill.is_active = not skill.is_active + skill.updated_at = int(time.time()) + db.commit() + db.refresh(skill) + + return self._to_skill_model(skill, db=db) + except Exception: + return None + + def delete_skill_by_id(self, id: str, db: Optional[Session] = None) -> bool: + try: + with get_db_context(db) as db: + AccessGrants.revoke_all_access("skill", id, db=db) + db.query(Skill).filter_by(id=id).delete() + db.commit() + + return True + except Exception: + return False + + +Skills = SkillsTable() diff --git a/backend/open_webui/routers/memories.py b/backend/open_webui/routers/memories.py index e8546b9efd..db2bf5e238 100644 --- a/backend/open_webui/routers/memories.py +++ b/backend/open_webui/routers/memories.py @@ -18,11 +18,6 @@ log = logging.getLogger(__name__) router = APIRouter() -@router.get("/ef") -async def get_embeddings(request: Request): - return {"result": await request.app.state.EMBEDDING_FUNCTION("hello world")} - - ############################ # GetMemories ############################ @@ -165,7 +160,7 @@ async def reset_memory_from_vector_db( user=Depends(get_verified_user), ): """Reset user's memory vector embeddings. - + CRITICAL: We intentionally do NOT use Depends(get_session) here. This endpoint generates embeddings for ALL user memories in parallel using asyncio.gather(). A user with 100 memories would trigger 100 embedding API diff --git a/backend/open_webui/routers/skills.py b/backend/open_webui/routers/skills.py new file mode 100644 index 0000000000..04ba56caee --- /dev/null +++ b/backend/open_webui/routers/skills.py @@ -0,0 +1,432 @@ +import logging +from typing import Optional + +from open_webui.models.groups import Groups +from pydantic import BaseModel + +from fastapi import APIRouter, Depends, HTTPException, Request, status +from sqlalchemy.orm import Session + +from open_webui.internal.db import get_session +from open_webui.models.skills import ( + SkillForm, + SkillModel, + SkillResponse, + SkillUserResponse, + SkillAccessResponse, + SkillAccessListResponse, + Skills, +) +from open_webui.models.access_grants import AccessGrants +from open_webui.utils.auth import get_admin_user, get_verified_user +from open_webui.utils.access_control import has_access, has_permission + +from open_webui.config import BYPASS_ADMIN_ACCESS_CONTROL +from open_webui.constants import ERROR_MESSAGES + + +log = logging.getLogger(__name__) + +PAGE_ITEM_COUNT = 30 + +router = APIRouter() + + +############################ +# GetSkills +############################ + + +@router.get("/", response_model=list[SkillUserResponse]) +async def get_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + skills = Skills.get_skills(db=db) + else: + user_group_ids = { + group.id for group in Groups.get_groups_by_member_id(user.id, db=db) + } + all_skills = Skills.get_skills(db=db) + skills = [ + skill + for skill in all_skills + if skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=user_group_ids, + db=db, + ) + ] + + return skills + + +############################ +# GetSkillList +############################ + + +@router.get("/list", response_model=SkillAccessListResponse) +async def get_skill_list( + query: Optional[str] = None, + view_option: Optional[str] = None, + page: Optional[int] = 1, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + limit = PAGE_ITEM_COUNT + + page = max(1, page) + skip = (page - 1) * limit + + filter = {} + if query: + filter["query"] = query + if view_option: + filter["view_option"] = view_option + + if not (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL): + groups = Groups.get_groups_by_member_id(user.id, db=db) + if groups: + filter["group_ids"] = [group.id for group in groups] + + filter["user_id"] = user.id + + result = Skills.search_skills( + user.id, filter=filter, skip=skip, limit=limit, db=db + ) + + return SkillAccessListResponse( + items=[ + SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + for skill in result.items + ], + total=result.total, + ) + + +############################ +# ExportSkills +############################ + + +@router.get("/export", response_model=list[SkillModel]) +async def export_skills( + request: Request, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, + "workspace.skills", + request.app.state.config.USER_PERMISSIONS, + db=db, + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + if user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL: + return Skills.get_skills(db=db) + else: + return Skills.get_skills_by_user_id(user.id, "read", db=db) + + +############################ +# CreateNewSkill +############################ + + +@router.post("/create", response_model=Optional[SkillResponse]) +async def create_new_skill( + request: Request, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + if user.role != "admin" and not has_permission( + user.id, "workspace.skills", request.app.state.config.USER_PERMISSIONS, db=db + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + form_data.id = form_data.id.lower().replace(" ", "-") + + existing = Skills.get_skill_by_id(form_data.id, db=db) + if existing is not None: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.ID_TAKEN, + ) + + try: + skill = Skills.insert_new_skill(user.id, form_data, db=db) + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error creating skill"), + ) + except Exception as e: + log.exception(f"Failed to create skill: {e}") + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# GetSkillById +############################ + + +@router.get("/id/{id}", response_model=Optional[SkillAccessResponse]) +async def get_skill_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + skill = Skills.get_skill_by_id(id, db=db) + + if skill: + if ( + user.role == "admin" + or skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="read", + db=db, + ) + ): + return SkillAccessResponse( + **skill.model_dump(), + write_access=( + (user.role == "admin" and BYPASS_ADMIN_ACCESS_CONTROL) + or user.id == skill.user_id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.ACCESS_PROHIBITED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# UpdateSkillById +############################ + + +@router.post("/id/{id}/update", response_model=Optional[SkillModel]) +async def update_skill_by_id( + request: Request, + id: str, + form_data: SkillForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + try: + updated = { + **form_data.model_dump(exclude={"id"}), + } + + skill = Skills.update_skill_by_id(id, updated, db=db) + + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error updating skill"), + ) + except Exception as e: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT(str(e)), + ) + + +############################ +# UpdateSkillAccessById +############################ + + +class SkillAccessGrantsForm(BaseModel): + access_grants: list[dict] + + +@router.post("/id/{id}/access/update", response_model=Optional[SkillModel]) +async def update_skill_access_by_id( + id: str, + form_data: SkillAccessGrantsForm, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + AccessGrants.set_access_grants( + "skill", id, form_data.access_grants, db=db + ) + + return Skills.get_skill_by_id(id, db=db) + + +############################ +# ToggleSkillById +############################ + + +@router.post("/id/{id}/toggle", response_model=Optional[SkillModel]) +async def toggle_skill_by_id( + id: str, user=Depends(get_verified_user), db: Session = Depends(get_session) +): + skill = Skills.get_skill_by_id(id, db=db) + if skill: + if ( + user.role == "admin" + or skill.user_id == user.id + or AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + ): + skill = Skills.toggle_skill_by_id(id, db=db) + + if skill: + return skill + else: + raise HTTPException( + status_code=status.HTTP_400_BAD_REQUEST, + detail=ERROR_MESSAGES.DEFAULT("Error toggling skill"), + ) + else: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + else: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + +############################ +# DeleteSkillById +############################ + + +@router.delete("/id/{id}/delete", response_model=bool) +async def delete_skill_by_id( + request: Request, + id: str, + user=Depends(get_verified_user), + db: Session = Depends(get_session), +): + skill = Skills.get_skill_by_id(id, db=db) + if not skill: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail=ERROR_MESSAGES.NOT_FOUND, + ) + + if ( + skill.user_id != user.id + and not AccessGrants.has_access( + user_id=user.id, + resource_type="skill", + resource_id=skill.id, + permission="write", + db=db, + ) + and user.role != "admin" + ): + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=ERROR_MESSAGES.UNAUTHORIZED, + ) + + result = Skills.delete_skill_by_id(id, db=db) + return result diff --git a/backend/open_webui/tools/builtin.py b/backend/open_webui/tools/builtin.py index 9dd3ea1bc7..6014bd7bd6 100644 --- a/backend/open_webui/tools/builtin.py +++ b/backend/open_webui/tools/builtin.py @@ -1881,3 +1881,61 @@ async def query_knowledge_bases( except Exception as e: log.exception(f"query_knowledge_bases error: {e}") return json.dumps({"error": str(e)}) + + +# ============================================================================= +# SKILLS TOOLS +# ============================================================================= + + +async def view_skill( + name: str, + __request__: Request = None, + __user__: dict = None, +) -> str: + """ + Load the full instructions of a skill by its name from the available skills manifest. + Use this when you need detailed instructions for a skill listed in . + + :param name: The name of the skill to load (as shown in the manifest) + :return: The full skill instructions as markdown content + """ + if __request__ is None: + return json.dumps({"error": "Request context not available"}) + + if not __user__: + return json.dumps({"error": "User context not available"}) + + try: + from open_webui.models.skills import Skills + from open_webui.models.access_grants import AccessGrants + + user_id = __user__.get("id") + + # Direct DB lookup by unique name + skill = Skills.get_skill_by_name(name) + + if not skill or not skill.is_active: + return json.dumps({"error": f"Skill '{name}' not found"}) + + # Check user access + user_role = __user__.get("role", "user") + if user_role != "admin" and skill.user_id != user_id: + user_group_ids = [group.id for group in Groups.get_groups_by_member_id(user_id)] + if not AccessGrants.has_access( + user_id=user_id, + resource_type="skill", + resource_id=skill.id, + permission="read", + user_group_ids=set(user_group_ids), + ): + return json.dumps({"error": "Access denied"}) + + return json.dumps({ + "name": skill.name, + "content": skill.content, + }, ensure_ascii=False) + except Exception as e: + log.exception(f"view_skill error: {e}") + return json.dumps({"error": str(e)}) + diff --git a/backend/open_webui/utils/middleware.py b/backend/open_webui/utils/middleware.py index 58ea3f249f..d65e231302 100644 --- a/backend/open_webui/utils/middleware.py +++ b/backend/open_webui/utils/middleware.py @@ -2097,6 +2097,30 @@ async def process_chat_payload(request, form_data, user, metadata, model): tool_ids = form_data.pop("tool_ids", None) files = form_data.pop("files", None) + # Skills: inject manifest only — model uses view_skill tool to load full content on-demand + user_skill_ids = form_data.pop("skill_ids", None) or [] + model_skill_ids = model.get("info", {}).get("meta", {}).get("skillIds", []) + + all_skill_ids = list(set(user_skill_ids + model_skill_ids)) + available_skills = [] + if all_skill_ids: + from open_webui.models.skills import Skills as SkillsModel + + accessible_skill_ids = {s.id for s in SkillsModel.get_skills_by_user_id(user.id, "read")} + available_skills = [ + s for sid in all_skill_ids + if sid in accessible_skill_ids and (s := SkillsModel.get_skill_by_id(sid)) and s.is_active + ] + + if available_skills: + manifest = "\n" + for skill in available_skills: + manifest += f"\n{skill.name}\n{skill.description or ''}\n\n" + manifest += "" + form_data["messages"] = add_or_update_system_message( + manifest, form_data["messages"], append=True + ) + prompt = get_last_user_message(form_data["messages"]) # TODO: re-enable URL extraction from prompt # urls = [] @@ -2325,6 +2349,7 @@ async def process_chat_payload(request, form_data, user, metadata, model): { **extra_params, "__event_emitter__": event_emitter, + "__skill_ids__": [s.id for s in available_skills], }, features, model, diff --git a/backend/open_webui/utils/tools.py b/backend/open_webui/utils/tools.py index 88479f40d8..7e8520a1ac 100644 --- a/backend/open_webui/utils/tools.py +++ b/backend/open_webui/utils/tools.py @@ -77,6 +77,7 @@ from open_webui.tools.builtin import ( search_knowledge_files, query_knowledge_files, view_knowledge_file, + view_skill, ) import copy @@ -506,6 +507,10 @@ def get_builtin_tools( ] ) + # Skills tools - view_skill allows model to load full skill instructions on demand + if extra_params.get("__skill_ids__"): + builtin_functions.append(view_skill) + for func in builtin_functions: callable = get_async_tool_function_and_apply_extra_params( func, diff --git a/src/lib/apis/skills/index.ts b/src/lib/apis/skills/index.ts new file mode 100644 index 0000000000..14543046b9 --- /dev/null +++ b/src/lib/apis/skills/index.ts @@ -0,0 +1,325 @@ +import { WEBUI_API_BASE_URL } from '$lib/constants'; + +export const createNewSkill = async (token: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/create`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const getSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/`, { + 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 getSkillList = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list`, { + 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 getSkillItems = async ( + token: string = '', + query: string | null = null, + viewOption: string | null = null, + page: number | null = null +) => { + let error = null; + + const searchParams = new URLSearchParams(); + if (query) searchParams.append('query', query); + if (viewOption) searchParams.append('view_option', viewOption); + if (page) searchParams.append('page', page.toString()); + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/list?${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; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const exportSkills = async (token: string = '') => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/export`, { + 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 getSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}`, { + 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 updateSkillById = async (token: string, id: string, skill: object) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + ...skill + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const updateSkillAccessGrants = async ( + token: string, + id: string, + accessGrants: any[] +) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/access/update`, { + method: 'POST', + headers: { + Accept: 'application/json', + 'Content-Type': 'application/json', + authorization: `Bearer ${token}` + }, + body: JSON.stringify({ + access_grants: accessGrants + }) + }) + .then(async (res) => { + if (!res.ok) throw await res.json(); + return res.json(); + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const toggleSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/toggle`, { + 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(); + }) + .then((json) => { + return json; + }) + .catch((err) => { + error = err.detail; + console.error(err); + return null; + }); + + if (error) { + throw error; + } + + return res; +}; + +export const deleteSkillById = async (token: string, id: string) => { + let error = null; + + const res = await fetch(`${WEBUI_API_BASE_URL}/skills/id/${id}/delete`, { + method: 'DELETE', + 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; +}; diff --git a/src/lib/components/chat/Chat.svelte b/src/lib/components/chat/Chat.svelte index e144885bbd..1bd004fc3b 100644 --- a/src/lib/components/chat/Chat.svelte +++ b/src/lib/components/chat/Chat.svelte @@ -2023,6 +2023,40 @@ } } + // Parse skill mentions (<$skillId|label>) from user messages + const skillMentionRegex = /<\$([^|>]+)\|?[^>]*>/g; + const skillIds = []; + for (const message of messages) { + const content = typeof message.content === 'string' ? message.content : message.content?.[0]?.text ?? ''; + for (const match of content.matchAll(skillMentionRegex)) { + if (!skillIds.includes(match[1])) { + skillIds.push(match[1]); + } + } + } + + // Strip skill mentions from message content + if (skillIds.length > 0) { + messages = messages.map((message) => { + if (typeof message.content === 'string') { + return { + ...message, + content: message.content.replace(/<\$[^>]+>/g, '').trim() + }; + } else if (Array.isArray(message.content)) { + return { + ...message, + content: message.content.map((part) => + part.type === 'text' + ? { ...part, text: part.text.replace(/<\$[^>]+>/g, '').trim() } + : part + ) + }; + } + return message; + }); + } + const res = await generateOpenAIChatCompletion( localStorage.token, { @@ -2044,6 +2078,7 @@ filter_ids: selectedFilterIds.length > 0 ? selectedFilterIds : undefined, tool_ids: toolIds.length > 0 ? toolIds : undefined, + skill_ids: skillIds.length > 0 ? skillIds : undefined, tool_servers: ($toolServers ?? []).filter( (server, idx) => toolServerIds.includes(idx) || toolServerIds.includes(server?.id) ), diff --git a/src/lib/components/chat/MessageInput.svelte b/src/lib/components/chat/MessageInput.svelte index 1f16526c4b..2955d83ec2 100644 --- a/src/lib/components/chat/MessageInput.svelte +++ b/src/lib/components/chat/MessageInput.svelte @@ -390,7 +390,7 @@ let command = ''; export let showCommands = false; - $: showCommands = ['/', '#', '@'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); + $: showCommands = ['/', '#', '@', '$'].includes(command?.charAt(0)) || '\\#' === command?.slice(0, 2); let suggestions = null; let showTools = false; @@ -946,6 +946,18 @@ } } }) + }, + { + char: '$', + render: getSuggestionRenderer(CommandSuggestionList, { + i18n, + onSelect: (e) => { + document.getElementById('chat-input')?.focus(); + }, + + insertTextHandler: insertTextAtCursor, + onUpload: () => {} + }) } ]; loaded = true; diff --git a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte index f4bd5b83f8..1a3a836894 100644 --- a/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte +++ b/src/lib/components/chat/MessageInput/CommandSuggestionList.svelte @@ -7,6 +7,7 @@ import Prompts from './Commands/Prompts.svelte'; import Knowledge from './Commands/Knowledge.svelte'; import Models from './Commands/Models.svelte'; + import Skills from './Commands/Skills.svelte'; import Spinner from '$lib/components/common/Spinner.svelte'; import { onMount } from 'svelte'; @@ -138,6 +139,27 @@ } }} /> + {:else if char === '$'} + { + const { type, data } = e; + + if (type === 'skill') { + command({ + id: `${data.id}|${data.name}`, + label: data.name + }); + + onSelect({ + type: 'skill', + data: data + }); + } + }} + /> {/if} {:else}
diff --git a/src/lib/components/chat/MessageInput/Commands/Skills.svelte b/src/lib/components/chat/MessageInput/Commands/Skills.svelte new file mode 100644 index 0000000000..ecff6c38c0 --- /dev/null +++ b/src/lib/components/chat/MessageInput/Commands/Skills.svelte @@ -0,0 +1,90 @@ + + +
+ {$i18n.t('Skills')} +
+ +{#if filteredItems.length > 0} + {#each filteredItems as skill, skillIdx} + + + + {/each} +{/if} diff --git a/src/lib/components/chat/Messages/Markdown.svelte b/src/lib/components/chat/Messages/Markdown.svelte index 459b4b6b52..67698648d3 100644 --- a/src/lib/components/chat/Messages/Markdown.svelte +++ b/src/lib/components/chat/Messages/Markdown.svelte @@ -46,7 +46,7 @@ marked.use(footnoteExtension(options)); marked.use(disableSingleTilde); marked.use({ - extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' })] + extensions: [mentionExtension({ triggerChar: '@' }), mentionExtension({ triggerChar: '#' }), mentionExtension({ triggerChar: '$' })] }); $: (async () => { diff --git a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte index 19f23b2aa0..9497444be6 100644 --- a/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte +++ b/src/lib/components/chat/Messages/Markdown/MarkdownInlineTokens/MentionToken.svelte @@ -25,8 +25,23 @@ const init = () => { const _id = token?.id; - // split by : and take first part as idType and second part as id + triggerChar = token?.triggerChar ?? '@'; + if (triggerChar === '$') { + // Skill mention: id format is "skillId|label" + const pipeIdx = _id?.indexOf('|') ?? -1; + if (pipeIdx > 0) { + id = _id.substring(0, pipeIdx); + label = _id.substring(pipeIdx + 1); + } else { + id = _id; + label = _id; + } + idType = null; + return; + } + + // split by : and take first part as idType and second part as id const parts = _id?.split(':'); if (parts) { idType = parts[0]; @@ -37,7 +52,6 @@ } label = token?.label ?? id; - triggerChar = token?.triggerChar ?? '@'; if (triggerChar === '#') { if (idType === 'C') { diff --git a/src/lib/components/icons/KeyframePlus.svelte b/src/lib/components/icons/KeyframePlus.svelte new file mode 100644 index 0000000000..349fcf89f3 --- /dev/null +++ b/src/lib/components/icons/KeyframePlus.svelte @@ -0,0 +1,20 @@ + + + + + + diff --git a/src/lib/components/icons/Keyframes.svelte b/src/lib/components/icons/Keyframes.svelte new file mode 100644 index 0000000000..20e3531e16 --- /dev/null +++ b/src/lib/components/icons/Keyframes.svelte @@ -0,0 +1,21 @@ + + + + + + + diff --git a/src/lib/components/workspace/Models/ModelEditor.svelte b/src/lib/components/workspace/Models/ModelEditor.svelte index a47d738f79..05d17ddaf6 100644 --- a/src/lib/components/workspace/Models/ModelEditor.svelte +++ b/src/lib/components/workspace/Models/ModelEditor.svelte @@ -12,6 +12,7 @@ import Tags from '$lib/components/common/Tags.svelte'; import Knowledge from '$lib/components/workspace/Models/Knowledge.svelte'; import ToolsSelector from '$lib/components/workspace/Models/ToolsSelector.svelte'; + import SkillsSelector from '$lib/components/workspace/Models/SkillsSelector.svelte'; import FiltersSelector from '$lib/components/workspace/Models/FiltersSelector.svelte'; import ActionsSelector from '$lib/components/workspace/Models/ActionsSelector.svelte'; import Capabilities from '$lib/components/workspace/Models/Capabilities.svelte'; @@ -89,6 +90,7 @@ let knowledge = []; let toolIds = []; + let skillIds = []; let filterIds = []; let defaultFilterIds = []; @@ -166,6 +168,14 @@ } } + if (skillIds.length > 0) { + info.meta.skillIds = skillIds; + } else { + if (info.meta.skillIds) { + delete info.meta.skillIds; + } + } + if (filterIds.length > 0) { info.meta.filterIds = filterIds; } else { @@ -293,6 +303,7 @@ }); toolIds = model?.meta?.toolIds ?? []; + skillIds = model?.meta?.skillIds ?? []; filterIds = model?.meta?.filterIds ?? []; defaultFilterIds = model?.meta?.defaultFilterIds ?? []; actionIds = model?.meta?.actionIds ?? []; @@ -736,6 +747,10 @@
+
+ +
+ {#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
diff --git a/src/lib/components/workspace/Models/SkillsSelector.svelte b/src/lib/components/workspace/Models/SkillsSelector.svelte new file mode 100644 index 0000000000..6a7a481064 --- /dev/null +++ b/src/lib/components/workspace/Models/SkillsSelector.svelte @@ -0,0 +1,62 @@ + + +
+
+
{$i18n.t('Skills')}
+
+ +
+ {#if Object.keys(_skills).length > 0} +
+ {#each Object.keys(_skills) as skill, skillIdx} +
+
+ { + _skills[skill].selected = e.detail === 'checked'; + selectedSkillIds = Object.keys(_skills).filter((s) => _skills[s].selected); + }} + /> +
+ + +
+ {_skills[skill].name} +
+
+
+ {/each} +
+ {/if} +
+ +
+ {$i18n.t('To select skills here, add them to the "Skills" workspace first.')} +
+
diff --git a/src/lib/components/workspace/Skills.svelte b/src/lib/components/workspace/Skills.svelte new file mode 100644 index 0000000000..d3595d48f7 --- /dev/null +++ b/src/lib/components/workspace/Skills.svelte @@ -0,0 +1,513 @@ + + + + + {$i18n.t('Skills')} • {$WEBUI_NAME} + + + +{#if loaded} +
+
+
+
+ {$i18n.t('Skills')} +
+ +
+ {total ?? ''} +
+
+ +
+ { + if (importFiles && importFiles.length > 0) { + const reader = new FileReader(); + reader.onload = (event) => { + const mdContent = event.target?.result; + if (typeof mdContent === 'string') { + const fm = parseFrontmatter(mdContent); + const fileName = importFiles[0].name.replace(/\.md$/, ''); + const rawName = fm.name || fileName; + const displayName = formatSkillName(rawName); + sessionStorage.skill = JSON.stringify({ + name: displayName, + id: fm.name || '', // Use raw frontmatter name as ID if available + description: fm.description || '', + content: mdContent, + is_active: true, + access_grants: [] + }); + goto('/workspace/skills/create'); + } + }; + reader.readAsText(importFiles[0]); + } + }} + /> + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills} + + {/if} + + {#if total && ($user?.role === 'admin' || $user?.permissions?.workspace?.skills)} + + {/if} + + {#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills} + + + + + + {/if} +
+
+
+ +
+
+
+
+ +
+ + {#if query} +
+ +
+ {/if} +
+
+ +
{ + if (e.deltaY !== 0) { + e.preventDefault(); + e.currentTarget.scrollLeft += e.deltaY; + } + }} + > +
+ { + localStorage.workspaceViewOption = value; + page = 1; + await tick(); + }} + /> +
+
+ + {#if filteredItems === null || loading} +
+ +
+ {:else if (filteredItems ?? []).length !== 0} +
+ {#each filteredItems as skill} + +
+ {#if skill.write_access} + +
+
+ +
+
+ {skill.name} +
+ {#if !skill.is_active} + + {/if} +
+
+
+
+ + {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User') + ) + })} + +
+
+
+
+
+ {:else} +
+
+
+
+ +
+
+ {skill.name} +
+ {#if !skill.is_active} + + {/if} +
+
+ +
+
+
+ + {$i18n.t('By {{name}}', { + name: capitalizeFirstLetter( + skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User') + ) + })} + +
+
+
+
+
+ {/if} + {#if skill.write_access} +
+ {#if shiftKey} + + + + {:else} + { + goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`); + }} + cloneHandler={() => { + cloneHandler(skill); + }} + exportHandler={() => { + exportHandler(skill); + }} + deleteHandler={async () => { + selectedSkill = skill; + showDeleteConfirm = true; + }} + onClose={() => {}} + > + + + {/if} + + +
+ {/if} +
+
+ {/each} +
+ + {#if total > 30} +
+ +
+ {/if} + {:else} +
+
+
📝
+
{$i18n.t('No skills found')}
+
+ {$i18n.t('Try adjusting your search or filter to find what you are looking for.')} +
+
+
+ {/if} +
+ + { + deleteHandler(selectedSkill); + }} + > +
+ {$i18n.t('This will delete')} {selectedSkill.name}. +
+
+{:else} +
+ +
+{/if} diff --git a/src/lib/components/workspace/Skills/SkillEditor.svelte b/src/lib/components/workspace/Skills/SkillEditor.svelte new file mode 100644 index 0000000000..5a760d002c --- /dev/null +++ b/src/lib/components/workspace/Skills/SkillEditor.svelte @@ -0,0 +1,263 @@ + + + { + if (edit && skill?.id) { + try { + await updateSkillAccessGrants(localStorage.token, skill.id, accessGrants); + toast.success($i18n.t('Saved')); + } catch (error) { + toast.error(`${error}`); + } + } + }} +/> + +
+
+
+