Merge pull request #21312 from open-webui/skills

feat: skills
This commit is contained in:
Tim Baek
2026-02-11 16:01:08 -06:00
committed by GitHub
28 changed files with 2571 additions and 10 deletions
+2
View File
@@ -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"])
@@ -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")
+340
View File
@@ -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()
+1 -6
View File
@@ -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
+432
View File
@@ -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
+58
View File
@@ -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 <available_skills>.
: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)})
+25
View File
@@ -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 = "<available_skills>\n"
for skill in available_skills:
manifest += f"<skill>\n<name>{skill.name}</name>\n<description>{skill.description or ''}</description>\n</skill>\n"
manifest += "</available_skills>"
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,
+5
View File
@@ -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,
+325
View File
@@ -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;
};
+35
View File
@@ -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)
),
+13 -1
View File
@@ -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;
@@ -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 === '$'}
<Skills
bind:this={suggestionElement}
{query}
bind:filteredItems
onSelect={(e) => {
const { type, data } = e;
if (type === 'skill') {
command({
id: `${data.id}|${data.name}`,
label: data.name
});
onSelect({
type: 'skill',
data: data
});
}
}}
/>
{/if}
{:else}
<div class="py-4 flex flex-col w-full rounded-xl text-gray-700 dark:text-gray-300">
@@ -0,0 +1,90 @@
<script lang="ts">
import { getContext, onDestroy } from 'svelte';
import { getSkillItems } from '$lib/apis/skills';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import Keyframes from '$lib/components/icons/Keyframes.svelte';
const i18n = getContext('i18n');
export let query = '';
export let onSelect = (e) => {};
let selectedIdx = 0;
export let filteredItems = [];
let searchDebounceTimer: ReturnType<typeof setTimeout>;
$: if (query !== undefined) {
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
getItems();
}, 200);
}
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
const getItems = async () => {
const res = await getSkillItems(localStorage.token, query).catch(() => null);
if (res) {
filteredItems = res.items;
}
};
$: if (query) {
selectedIdx = 0;
}
export const selectUp = () => {
selectedIdx = Math.max(0, selectedIdx - 1);
};
export const selectDown = () => {
selectedIdx = Math.min(selectedIdx + 1, filteredItems.length - 1);
};
export const select = async () => {
const skill = filteredItems[selectedIdx];
if (skill) {
onSelect({ type: 'skill', data: skill });
}
};
</script>
<div class="px-2 text-xs text-gray-500 py-1">
{$i18n.t('Skills')}
</div>
{#if filteredItems.length > 0}
{#each filteredItems as skill, skillIdx}
<Tooltip content={skill.description || skill.name} placement="top-start">
<button
class="px-2.5 py-1.5 rounded-xl w-full text-left {skillIdx === selectedIdx
? 'bg-gray-50 dark:bg-gray-800 selected-command-option-button'
: ''}"
type="button"
on:click={() => {
onSelect({ type: 'skill', data: skill });
}}
on:mousemove={() => {
selectedIdx = skillIdx;
}}
on:focus={() => {}}
data-selected={skillIdx === selectedIdx}
>
<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">
<Keyframes className="size-4" />
</div>
<div class="truncate">
{skill.name}
</div>
<div class="ml-2 text-xs text-gray-500 truncate">
{skill.id}
</div>
</div>
</button>
</Tooltip>
{/each}
{/if}
@@ -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 () => {
@@ -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') {
@@ -0,0 +1,20 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
<path d="M16 5H19M22 5H19M19 5V2M19 5V8" />
<path
d="M16.8189 14.3287L11.4948 20.3183C10.6992 21.2134 9.30076 21.2134 8.50518 20.3183L3.18109 14.3287C2.50752 13.571 2.50752 12.429 3.18109 11.6713L8.50518 5.68167C9.30076 4.78664 10.6992 4.78664 11.4948 5.68167L16.8189 11.6713C17.4925 12.429 17.4925 13.571 16.8189 14.3287Z"
/>
</svg>
+21
View File
@@ -0,0 +1,21 @@
<script lang="ts">
export let className = 'size-4';
export let strokeWidth = '1.5';
</script>
<svg
xmlns="http://www.w3.org/2000/svg"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width={strokeWidth}
stroke-linecap="round"
stroke-linejoin="round"
class={className}
>
<path
d="M13.8476 13.317L9.50515 18.2798C8.70833 19.1905 7.29167 19.1905 6.49485 18.2798L2.15238 13.317C1.49259 12.563 1.49259 11.437 2.15238 10.683L6.49485 5.72018C7.29167 4.80952 8.70833 4.80952 9.50515 5.72017L13.8476 10.683C14.5074 11.437 14.5074 12.563 13.8476 13.317Z"
/>
<path d="M13 19L17.8844 13.3016C18.5263 12.5526 18.5263 11.4474 17.8844 10.6984L13 5" />
<path d="M17 19L21.8844 13.3016C22.5263 12.5526 22.5263 11.4474 21.8844 10.6984L17 5" />
</svg>
@@ -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 @@
<ToolsSelector bind:selectedToolIds={toolIds} tools={$tools ?? []} />
</div>
<div class="my-4">
<SkillsSelector bind:selectedSkillIds={skillIds} />
</div>
{#if ($functions ?? []).filter((func) => func.type === 'filter').length > 0 || ($functions ?? []).filter((func) => func.type === 'action').length > 0}
<hr class=" border-gray-100/30 dark:border-gray-850/30 my-4" />
@@ -0,0 +1,62 @@
<script lang="ts">
import Checkbox from '$lib/components/common/Checkbox.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import { getContext, onMount } from 'svelte';
import { getSkillItems } from '$lib/apis/skills';
export let selectedSkillIds: string[] = [];
let _skills: Record<string, any> = {};
const i18n = getContext('i18n');
onMount(async () => {
const res = await getSkillItems(localStorage.token).catch(() => null);
const skills = res?.items ?? [];
_skills = skills.reduce((acc: Record<string, any>, skill: any) => {
acc[skill.id] = {
...skill,
selected: selectedSkillIds.includes(skill.id)
};
return acc;
}, {});
});
</script>
<div>
<div class="flex w-full justify-between mb-1">
<div class=" self-center text-xs font-medium text-gray-500">{$i18n.t('Skills')}</div>
</div>
<div class="flex flex-col mb-1">
{#if Object.keys(_skills).length > 0}
<div class=" flex items-center flex-wrap">
{#each Object.keys(_skills) as skill, skillIdx}
<div class=" flex items-center gap-2 mr-3">
<div class="self-center flex items-center">
<Checkbox
state={_skills[skill].selected ? 'checked' : 'unchecked'}
on:change={(e) => {
_skills[skill].selected = e.detail === 'checked';
selectedSkillIds = Object.keys(_skills).filter((s) => _skills[s].selected);
}}
/>
</div>
<Tooltip content={_skills[skill]?.description ?? _skills[skill].id}>
<div class=" py-0.5 text-sm w-full capitalize font-medium">
{_skills[skill].name}
</div>
</Tooltip>
</div>
{/each}
</div>
{/if}
</div>
<div class=" text-xs dark:text-gray-700">
{$i18n.t('To select skills here, add them to the "Skills" workspace first.')}
</div>
</div>
+513
View File
@@ -0,0 +1,513 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import fileSaver from 'file-saver';
const { saveAs } = fileSaver;
import { onMount, getContext, tick, onDestroy } from 'svelte';
const i18n = getContext('i18n');
import { WEBUI_NAME, user, skills as _skills } from '$lib/stores';
import { goto } from '$app/navigation';
import {
getSkills,
getSkillById,
getSkillItems,
exportSkills,
deleteSkillById,
toggleSkillById
} from '$lib/apis/skills';
import { capitalizeFirstLetter, parseFrontmatter, formatSkillName } from '$lib/utils';
import TagInput from '$lib/components/common/Tags/TagInput.svelte';
import Tooltip from '../common/Tooltip.svelte';
import ConfirmDialog from '../common/ConfirmDialog.svelte';
import DeleteConfirmDialog from '$lib/components/common/ConfirmDialog.svelte';
import EllipsisHorizontal from '../icons/EllipsisHorizontal.svelte';
import GarbageBin from '../icons/GarbageBin.svelte';
import Search from '../icons/Search.svelte';
import Plus from '../icons/Plus.svelte';
import XMark from '../icons/XMark.svelte';
import Spinner from '../common/Spinner.svelte';
import ViewSelector from './common/ViewSelector.svelte';
import Badge from '$lib/components/common/Badge.svelte';
import Switch from '../common/Switch.svelte';
import SkillMenu from './Skills/SkillMenu.svelte';
import Pagination from '../common/Pagination.svelte';
let shiftKey = false;
let loaded = false;
let importFiles;
let importInputElement: HTMLInputElement;
let query = '';
let searchDebounceTimer: ReturnType<typeof setTimeout>;
let selectedSkill = null;
let showDeleteConfirm = false;
let filteredItems = null;
let total = null;
let loading = false;
let tagsContainerElement: HTMLDivElement;
let viewOption = '';
let page = 1;
const loadSkillItems = async () => {
if (!loaded) return;
loading = true;
try {
const res = await getSkillItems(
localStorage.token,
query,
viewOption,
page
).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
filteredItems = res.items;
total = res.total;
}
} catch (err) {
console.error(err);
} finally {
loading = false;
}
};
// Debounce only query changes
$: if (query !== undefined) {
loading = true;
clearTimeout(searchDebounceTimer);
searchDebounceTimer = setTimeout(() => {
page = 1;
loadSkillItems();
}, 300);
}
// Immediate response to page/filter changes
$: if (page && viewOption !== undefined) {
loadSkillItems();
}
const cloneHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_skill) {
sessionStorage.skill = JSON.stringify({
..._skill,
id: `${_skill.id}_clone`,
name: `${_skill.name} (Clone)`
});
goto('/workspace/skills/create');
}
};
const exportHandler = async (skill) => {
const _skill = await getSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_skill) {
let blob = new Blob([JSON.stringify([_skill])], {
type: 'application/json'
});
saveAs(blob, `skill-${_skill.id}-export-${Date.now()}.json`);
}
};
const deleteHandler = async (skill) => {
const res = await deleteSkillById(localStorage.token, skill.id).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Skill deleted successfully'));
}
page = 1;
loadSkillItems();
await _skills.set(await getSkills(localStorage.token));
};
onMount(async () => {
viewOption = localStorage?.workspaceViewOption || '';
loaded = true;
const onKeyDown = (event) => {
if (event.key === 'Shift') {
shiftKey = true;
}
};
const onKeyUp = (event) => {
if (event.key === 'Shift') {
shiftKey = false;
}
};
const onBlur = () => {
shiftKey = false;
};
window.addEventListener('keydown', onKeyDown);
window.addEventListener('keyup', onKeyUp);
window.addEventListener('blur-sm', onBlur);
return () => {
clearTimeout(searchDebounceTimer);
window.removeEventListener('keydown', onKeyDown);
window.removeEventListener('keyup', onKeyUp);
window.removeEventListener('blur-sm', onBlur);
};
});
onDestroy(() => {
clearTimeout(searchDebounceTimer);
});
</script>
<svelte:head>
<title>
{$i18n.t('Skills')}{$WEBUI_NAME}
</title>
</svelte:head>
{#if loaded}
<div class="flex flex-col gap-1 px-1 mt-1.5 mb-3">
<div class="flex justify-between items-center">
<div class="flex items-center md:self-center text-xl font-medium px-0.5 gap-2 shrink-0">
<div>
{$i18n.t('Skills')}
</div>
<div class="text-lg font-medium text-gray-500 dark:text-gray-500">
{total ?? ''}
</div>
</div>
<div class="flex w-full justify-end gap-1.5">
<input
bind:this={importInputElement}
bind:files={importFiles}
type="file"
accept=".md"
hidden
on:change={() => {
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}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={() => {
importInputElement.click();
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Import')}
</div>
</button>
{/if}
{#if total && ($user?.role === 'admin' || $user?.permissions?.workspace?.skills)}
<button
class="flex text-xs items-center space-x-1 px-3 py-1.5 rounded-xl bg-gray-50 hover:bg-gray-100 dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-gray-200 transition"
on:click={async () => {
const _skills = await exportSkills(localStorage.token).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (_skills) {
let blob = new Blob([JSON.stringify(_skills)], {
type: 'application/json'
});
saveAs(blob, `skills-export-${Date.now()}.json`);
}
}}
>
<div class=" self-center font-medium line-clamp-1">
{$i18n.t('Export')}
</div>
</button>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills}
<a
class=" px-2 py-1.5 rounded-xl bg-black text-white dark:bg-white dark:text-black transition font-medium text-sm flex items-center"
href="/workspace/skills/create"
>
<Plus className="size-3" strokeWidth="2.5" />
<div class=" hidden md:block md:ml-1 text-xs">{$i18n.t('New Skill')}</div>
</a>
{/if}
</div>
</div>
</div>
<div
class="py-2 bg-white dark:bg-gray-900 rounded-3xl border border-gray-100/30 dark:border-gray-850/30"
>
<div class=" flex w-full space-x-2 py-0.5 px-3.5 pb-2">
<div class="flex flex-1">
<div class=" self-center ml-1 mr-3">
<Search className="size-3.5" />
</div>
<input
class=" w-full text-sm pr-4 py-1 rounded-r-xl outline-hidden bg-transparent"
bind:value={query}
placeholder={$i18n.t('Search Skills')}
/>
{#if query}
<div class="self-center pl-1.5 translate-y-[0.5px] rounded-l-xl bg-transparent">
<button
class="p-0.5 rounded-full hover:bg-gray-100 dark:hover:bg-gray-900 transition"
on:click={() => {
query = '';
}}
>
<XMark className="size-3" strokeWidth="2" />
</button>
</div>
{/if}
</div>
</div>
<div
class="px-3 flex w-full bg-transparent overflow-x-auto scrollbar-none -mx-1"
on:wheel={(e) => {
if (e.deltaY !== 0) {
e.preventDefault();
e.currentTarget.scrollLeft += e.deltaY;
}
}}
>
<div
class="flex gap-0.5 w-fit text-center text-sm rounded-full bg-transparent px-1.5 whitespace-nowrap"
bind:this={tagsContainerElement}
>
<ViewSelector
bind:value={viewOption}
onChange={async (value) => {
localStorage.workspaceViewOption = value;
page = 1;
await tick();
}}
/>
</div>
</div>
{#if filteredItems === null || loading}
<div class="w-full h-full flex justify-center items-center my-16 mb-24">
<Spinner className="size-5" />
</div>
{:else if (filteredItems ?? []).length !== 0}
<div class=" my-2 gap-2 grid px-3 lg:grid-cols-2">
{#each filteredItems as skill}
<Tooltip content={skill?.description ?? skill?.id}>
<div
class=" flex space-x-4 text-left w-full px-3 py-2.5 transition rounded-2xl {skill.write_access
? 'cursor-pointer dark:hover:bg-gray-850/50 hover:bg-gray-50'
: 'cursor-not-allowed opacity-60'}"
>
{#if skill.write_access}
<a
class=" flex flex-1 space-x-3.5 cursor-pointer w-full"
href={`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`}
>
<div class="flex items-center text-left">
<div class=" flex-1 self-center">
<Tooltip content={skill.id} placement="top-start">
<div class="flex items-center gap-2">
<div class="line-clamp-1 text-sm">
{skill.name}
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
</div>
</Tooltip>
<div class="px-0.5">
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={skill?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
</div>
</div>
</div>
</a>
{:else}
<div class=" flex flex-1 space-x-3.5 w-full">
<div class="flex items-center text-left w-full">
<div class="flex-1 self-center w-full">
<div class="flex items-center justify-between w-full gap-2">
<Tooltip content={skill.id} placement="top-start">
<div class="flex items-center gap-2">
<div class="line-clamp-1 text-sm">
{skill.name}
</div>
{#if !skill.is_active}
<Badge type="muted" content={$i18n.t('Inactive')} />
{/if}
</div>
</Tooltip>
<Badge type="muted" content={$i18n.t('Read Only')} />
</div>
<div class="px-0.5">
<div class="text-xs text-gray-500 shrink-0">
<Tooltip
content={skill?.user?.email ?? $i18n.t('Deleted User')}
className="flex shrink-0"
placement="top-start"
>
{$i18n.t('By {{name}}', {
name: capitalizeFirstLetter(
skill?.user?.name ?? skill?.user?.email ?? $i18n.t('Deleted User')
)
})}
</Tooltip>
</div>
</div>
</div>
</div>
</div>
{/if}
{#if skill.write_access}
<div class="flex flex-row gap-0.5 self-center">
{#if shiftKey}
<Tooltip content={$i18n.t('Delete')}>
<button
class="self-center w-fit text-sm px-2 py-2 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
on:click={() => {
deleteHandler(skill);
}}
>
<GarbageBin />
</button>
</Tooltip>
{:else}
<SkillMenu
editHandler={() => {
goto(`/workspace/skills/edit?id=${encodeURIComponent(skill.id)}`);
}}
cloneHandler={() => {
cloneHandler(skill);
}}
exportHandler={() => {
exportHandler(skill);
}}
deleteHandler={async () => {
selectedSkill = skill;
showDeleteConfirm = true;
}}
onClose={() => {}}
>
<button
class="self-center w-fit text-sm p-1.5 dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-white/5 rounded-xl"
type="button"
>
<EllipsisHorizontal className="size-5" />
</button>
</SkillMenu>
{/if}
<button
on:click|stopPropagation|preventDefault
>
<Tooltip
content={skill.is_active ? $i18n.t('Enabled') : $i18n.t('Disabled')}
>
<Switch
bind:state={skill.is_active}
on:change={async () => {
toggleSkillById(localStorage.token, skill.id);
}}
/>
</Tooltip>
</button>
</div>
{/if}
</div>
</Tooltip>
{/each}
</div>
{#if total > 30}
<div class="flex justify-center mt-4 mb-2">
<Pagination bind:page count={total} perPage={30} />
</div>
{/if}
{:else}
<div class=" w-full h-full flex flex-col justify-center items-center my-16 mb-24">
<div class="max-w-md text-center">
<div class=" text-3xl mb-3">📝</div>
<div class=" text-lg font-medium mb-1">{$i18n.t('No skills found')}</div>
<div class=" text-gray-500 text-center text-xs">
{$i18n.t('Try adjusting your search or filter to find what you are looking for.')}
</div>
</div>
</div>
{/if}
</div>
<DeleteConfirmDialog
bind:show={showDeleteConfirm}
title={$i18n.t('Delete skill?')}
on:confirm={() => {
deleteHandler(selectedSkill);
}}
>
<div class=" text-sm text-gray-500 truncate">
{$i18n.t('This will delete')} <span class=" font-medium">{selectedSkill.name}</span>.
</div>
</DeleteConfirmDialog>
{:else}
<div class="w-full h-full flex justify-center items-center">
<Spinner className="size-5" />
</div>
{/if}
@@ -0,0 +1,263 @@
<script lang="ts">
import { onMount, tick, getContext } from 'svelte';
import Textarea from '$lib/components/common/Textarea.svelte';
import { toast } from 'svelte-sonner';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import LockClosed from '$lib/components/icons/LockClosed.svelte';
import ChevronLeft from '$lib/components/icons/ChevronLeft.svelte';
import AccessControlModal from '../common/AccessControlModal.svelte';
import { user } from '$lib/stores';
import { slugify, parseFrontmatter, formatSkillName } from '$lib/utils';
import Spinner from '$lib/components/common/Spinner.svelte';
import { updateSkillAccessGrants } from '$lib/apis/skills';
import { goto } from '$app/navigation';
export let onSubmit: Function;
export let edit = false;
export let skill = null;
export let clone = false;
export let disabled = false;
const i18n = getContext('i18n');
let loading = false;
let name = '';
let id = '';
let description = '';
let content = '';
let accessGrants = [];
let showAccessControlModal = false;
let hasManualEdit = false;
let hasManualName = false;
let hasManualDescription = false;
let isFrontmatterDetected = false;
// Auto-detect frontmatter and fill name/description in create mode
$: if (!edit && content) {
const fm = parseFrontmatter(content);
if (fm.name) {
isFrontmatterDetected = true;
if (!hasManualName) {
name = formatSkillName(fm.name);
}
if (!hasManualEdit) {
id = fm.name;
}
} else {
isFrontmatterDetected = false;
}
if (fm.description && !hasManualDescription) {
description = fm.description;
}
} else if (!edit && !content) {
isFrontmatterDetected = false;
}
$: if (!edit && !hasManualEdit && !isFrontmatterDetected) {
id = name !== '' ? slugify(name) : '';
}
function handleIdInput(e: Event) {
hasManualEdit = true;
}
function handleNameInput(e: Event) {
hasManualName = true;
}
function handleDescriptionInput(e: Event) {
hasManualDescription = true;
}
const submitHandler = async () => {
if (disabled) {
toast.error($i18n.t('You do not have permission to edit this skill.'));
return;
}
loading = true;
await onSubmit({
id,
name,
description,
content,
is_active: true,
meta: { tags: [] },
access_grants: accessGrants
});
loading = false;
};
onMount(async () => {
if (skill) {
name = skill.name || '';
await tick();
id = skill.id || '';
description = skill.description || '';
content = skill.content || '';
accessGrants = skill?.access_grants === undefined ? [] : skill?.access_grants;
if (name) hasManualName = true;
if (description) hasManualDescription = true;
if (id) hasManualEdit = true;
}
});
</script>
<AccessControlModal
bind:show={showAccessControlModal}
bind:accessGrants
accessRoles={['read', 'write']}
share={$user?.permissions?.sharing?.skills || $user?.role === 'admin'}
sharePublic={$user?.permissions?.sharing?.public_skills || $user?.role === 'admin' || edit}
onChange={async () => {
if (edit && skill?.id) {
try {
await updateSkillAccessGrants(localStorage.token, skill.id, accessGrants);
toast.success($i18n.t('Saved'));
} catch (error) {
toast.error(`${error}`);
}
}
}}
/>
<div class=" flex flex-col justify-between w-full overflow-y-auto h-full">
<div class="mx-auto w-full md:px-0 h-full">
<form
class=" flex flex-col max-h-[100dvh] h-full"
on:submit|preventDefault={submitHandler}
>
<div class="flex flex-col flex-1 overflow-auto h-0 rounded-lg">
<div class="w-full mb-2 flex flex-col gap-0.5">
<div class="flex w-full items-center">
<div class=" shrink-0 mr-2">
<Tooltip content={$i18n.t('Back')}>
<button
class="w-full text-left text-sm py-1.5 px-1 rounded-lg dark:text-gray-300 dark:hover:text-white hover:bg-black/5 dark:hover:bg-gray-850"
on:click={() => {
goto('/workspace/skills');
}}
type="button"
>
<ChevronLeft strokeWidth="2.5" />
</button>
</Tooltip>
</div>
<div class="flex-1">
<Tooltip content={$i18n.t('e.g. Code Review Guidelines')} placement="top-start">
<input
class="w-full text-2xl font-medium bg-transparent outline-hidden font-primary"
type="text"
placeholder={$i18n.t('Skill Name')}
bind:value={name}
on:input={handleNameInput}
required
{disabled}
/>
</Tooltip>
</div>
<div class="self-center shrink-0">
{#if !disabled}
<button
class="bg-gray-50 hover:bg-gray-100 text-black dark:bg-gray-850 dark:hover:bg-gray-800 dark:text-white transition px-2 py-1 rounded-full flex gap-1 items-center"
type="button"
on:click={() => (showAccessControlModal = true)}
>
<LockClosed strokeWidth="2.5" className="size-3.5" />
<div class="text-sm font-medium shrink-0">
{$i18n.t('Access')}
</div>
</button>
{:else}
<span class="text-xs text-gray-500 bg-gray-100 dark:bg-gray-800 px-2 py-1 rounded-full"
>{$i18n.t('Read Only')}</span
>
{/if}
</div>
</div>
<div class=" flex gap-2 px-1 items-center">
{#if edit}
<div class="text-sm text-gray-500 shrink-0">
{id}
</div>
{:else}
<Tooltip className="w-full" content={$i18n.t('e.g. code-review-guidelines')} placement="top-start">
<input
class="w-full text-sm disabled:text-gray-500 bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Skill ID')}
bind:value={id}
on:input={handleIdInput}
required
disabled={edit}
/>
</Tooltip>
{/if}
<Tooltip
className="w-full self-center items-center flex"
content={$i18n.t('e.g. Step-by-step instructions for code reviews')}
placement="top-start"
>
<input
class="w-full text-sm bg-transparent outline-hidden"
type="text"
placeholder={$i18n.t('Skill Description')}
bind:value={description}
on:input={handleDescriptionInput}
{disabled}
/>
</Tooltip>
</div>
</div>
<div class="mb-2 flex-1 overflow-auto h-0 rounded-lg">
<div class="h-full flex flex-col">
<div
class="bg-gray-50 dark:bg-gray-900 rounded-xl border border-gray-100/50 dark:border-gray-850/50 flex-1 min-h-0 overflow-hidden flex flex-col"
>
{#if disabled}
<div class="px-4 py-3 overflow-y-auto flex-1">
<pre class="text-xs whitespace-pre-wrap font-mono">{content}</pre>
</div>
{:else}
<textarea
class="w-full flex-1 text-xs bg-transparent outline-hidden resize-none font-mono px-4 py-3"
bind:value={content}
placeholder={$i18n.t('Enter skill instructions in markdown...')}
required
/>
{/if}
</div>
</div>
</div>
<div class="pb-3 flex justify-end">
{#if !disabled}
<button
class="px-3.5 py-1.5 text-sm font-medium bg-black hover:bg-gray-900 text-white dark:bg-white dark:text-black dark:hover:bg-gray-100 transition rounded-full flex items-center"
type="submit"
disabled={loading}
>
{$i18n.t(edit ? 'Save' : 'Save & Create')}
{#if loading}
<div class="ml-1.5">
<Spinner />
</div>
{/if}
</button>
{/if}
</div>
</div>
</form>
</div>
</div>
@@ -0,0 +1,105 @@
<script lang="ts">
import { DropdownMenu } from 'bits-ui';
import { flyAndScale } from '$lib/utils/transitions';
import { getContext } from 'svelte';
import Dropdown from '$lib/components/common/Dropdown.svelte';
import GarbageBin from '$lib/components/icons/GarbageBin.svelte';
import Tooltip from '$lib/components/common/Tooltip.svelte';
import DocumentDuplicate from '$lib/components/icons/DocumentDuplicate.svelte';
import Download from '$lib/components/icons/Download.svelte';
import { user } from '$lib/stores';
const i18n = getContext('i18n');
export let editHandler: Function;
export let cloneHandler: Function;
export let exportHandler: Function;
export let deleteHandler: Function;
export let onClose: Function;
let show = false;
</script>
<Dropdown
bind:show
on:change={(e) => {
if (e.detail === false) {
onClose();
}
}}
>
<Tooltip content={$i18n.t('More')}>
<slot />
</Tooltip>
<div slot="content">
<DropdownMenu.Content
class="w-full max-w-[170px] rounded-2xl px-1 py-1 border border-gray-100 dark:border-gray-800 z-50 bg-white dark:bg-gray-850 dark:text-white shadow-lg"
sideOffset={-2}
side="bottom"
align="start"
transition={flyAndScale}
>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
editHandler();
}}
>
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
stroke-width="1.5"
stroke="currentColor"
class="w-4 h-4"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
d="M16.862 4.487l1.687-1.688a1.875 1.875 0 112.652 2.652L6.832 19.82a4.5 4.5 0 01-1.897 1.13l-2.685.8.8-2.685a4.5 4.5 0 011.13-1.897L16.863 4.487zm0 0L19.5 7.125"
/>
</svg>
<div class="flex items-center">{$i18n.t('Edit')}</div>
</DropdownMenu.Item>
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
cloneHandler();
}}
>
<DocumentDuplicate />
<div class="flex items-center">{$i18n.t('Clone')}</div>
</DropdownMenu.Item>
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills}
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
exportHandler();
}}
>
<Download />
<div class="flex items-center">{$i18n.t('Export')}</div>
</DropdownMenu.Item>
{/if}
<hr class="border-gray-50 dark:border-gray-850/30 my-1" />
<DropdownMenu.Item
class="flex gap-2 items-center px-3 py-1.5 text-sm cursor-pointer hover:bg-gray-50 dark:hover:bg-gray-800 rounded-xl"
on:click={() => {
deleteHandler();
}}
>
<GarbageBin />
<div class="flex items-center">{$i18n.t('Delete')}</div>
</DropdownMenu.Item>
</DropdownMenu.Content>
</div>
</Dropdown>
+1
View File
@@ -66,6 +66,7 @@ export const models: Writable<Model[]> = writable([]);
export const prompts: Writable<null | Prompt[]> = writable(null);
export const knowledge: Writable<null | Document[]> = writable(null);
export const tools = writable(null);
export const skills = writable(null);
export const functions = writable(null);
export const toolServers = writable([]);
+18
View File
@@ -1699,3 +1699,21 @@ export const getCodeBlockContents = (content: string): object => {
js: jsContent.trim()
};
};
export const parseFrontmatter = (content) => {
const match = content.match(/^---\s*\n([\s\S]*?)\n---/);
if (match) {
const frontmatter = {};
match[1].split('\n').forEach((line) => {
const [key, ...value] = line.split(':');
if (key && value) {
frontmatter[key.trim()] = value.join(':').trim().replace(/^["']|["']$/g, '');
}
});
return frontmatter;
}
return {};
};
export const formatSkillName = (name) => {
return name.replace(/[-_]/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase());
};
+11
View File
@@ -121,6 +121,17 @@
{$i18n.t('Tools')}
</a>
{/if}
{#if $user?.role === 'admin' || $user?.permissions?.workspace?.skills}
<a
class="min-w-fit p-1.5 {$page.url.pathname.includes('/workspace/skills')
? ''
: 'text-gray-300 dark:text-gray-600 hover:text-gray-700 dark:hover:text-white'} transition"
href="/workspace/skills"
>
{$i18n.t('Skills')}
</a>
{/if}
</div>
</div>
@@ -0,0 +1,5 @@
<script>
import Skills from '$lib/components/workspace/Skills.svelte';
</script>
<Skills />
@@ -0,0 +1,56 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { skills } from '$lib/stores';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { createNewSkill, getSkills } from '$lib/apis/skills';
import SkillEditor from '$lib/components/workspace/Skills/SkillEditor.svelte';
let skill: {
name: string;
id: string;
description: string;
content: string;
is_active: boolean;
access_grants: any[];
} | null = null;
let clone = false;
const onSubmit = async (_skill) => {
const res = await createNewSkill(localStorage.token, _skill).catch((error) => {
toast.error(`${error}`);
return null;
});
if (res) {
toast.success($i18n.t('Skill created successfully'));
await skills.set(await getSkills(localStorage.token));
await goto('/workspace/skills');
}
};
onMount(async () => {
if (sessionStorage.skill) {
const _skill = JSON.parse(sessionStorage.skill);
sessionStorage.removeItem('skill');
clone = true;
skill = {
name: _skill.name || 'Skill',
id: _skill.id || '',
description: _skill.description || '',
content: _skill.content || '',
is_active: _skill.is_active ?? true,
access_grants: _skill.access_grants !== undefined ? _skill.access_grants : []
};
}
});
</script>
{#key skill}
<SkillEditor {skill} {onSubmit} {clone} />
{/key}
@@ -0,0 +1,71 @@
<script lang="ts">
import { toast } from 'svelte-sonner';
import { goto } from '$app/navigation';
import { skills } from '$lib/stores';
import { onMount, getContext } from 'svelte';
const i18n = getContext('i18n');
import { getSkillById, getSkills, updateSkillById } from '$lib/apis/skills';
import { page } from '$app/stores';
import SkillEditor from '$lib/components/workspace/Skills/SkillEditor.svelte';
let skill = null;
let disabled = false;
$: skillId = $page.url.searchParams.get('id');
const onSubmit = async (_skill) => {
const updatedSkill = await updateSkillById(localStorage.token, skillId, _skill).catch(
(error) => {
toast.error(`${error}`);
return null;
}
);
if (updatedSkill) {
toast.success($i18n.t('Skill updated successfully'));
await skills.set(await getSkills(localStorage.token));
skill = {
id: updatedSkill.id,
name: updatedSkill.name,
description: updatedSkill.description,
content: updatedSkill.content,
is_active: updatedSkill.is_active,
access_grants:
updatedSkill?.access_grants === undefined ? [] : updatedSkill?.access_grants
};
}
};
onMount(async () => {
if (skillId) {
const _skill = await getSkillById(localStorage.token, skillId).catch((error) => {
toast.error(`${error}`);
return null;
});
if (_skill) {
disabled = !_skill.write_access ?? true;
skill = {
id: _skill.id,
name: _skill.name,
description: _skill.description,
content: _skill.content,
is_active: _skill.is_active,
access_grants:
_skill?.access_grants === undefined ? [] : _skill?.access_grants
};
} else {
goto('/workspace/skills');
}
} else {
goto('/workspace/skills');
}
});
</script>
{#if skill}
<SkillEditor {skill} {onSubmit} {disabled} edit />
{/if}