mirror of
https://github.com/open-webui/open-webui.git
synced 2026-08-27 16:04:50 -06:00
@@ -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")
|
||||
@@ -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()
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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)})
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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)
|
||||
),
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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([]);
|
||||
|
||||
@@ -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());
|
||||
};
|
||||
|
||||
@@ -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}
|
||||
Reference in New Issue
Block a user