diff --git a/ai-hub/app/api/routes/api.py b/ai-hub/app/api/routes/api.py index efa4a46..7161f26 100644 --- a/ai-hub/app/api/routes/api.py +++ b/ai-hub/app/api/routes/api.py @@ -9,6 +9,7 @@ from .stt import create_stt_router from .user import create_users_router from .nodes import create_nodes_router +from .skills import create_skills_router def create_api_router(services: ServiceContainer) -> APIRouter: """ @@ -25,5 +26,6 @@ router.include_router(create_stt_router(services)) router.include_router(create_users_router(services)) router.include_router(create_nodes_router(services)) + router.include_router(create_skills_router(services)) return router \ No newline at end of file diff --git a/ai-hub/app/api/routes/skills.py b/ai-hub/app/api/routes/skills.py new file mode 100644 index 0000000..40a0e6d --- /dev/null +++ b/ai-hub/app/api/routes/skills.py @@ -0,0 +1,104 @@ +from fastapi import APIRouter, Depends, HTTPException +from sqlalchemy.orm import Session +from typing import List +from app.db.database import get_db +from app.api.dependencies import ServiceContainer, get_current_user +from app.db import models +from app.api import schemas + +def create_skills_router(services: ServiceContainer) -> APIRouter: + router = APIRouter(prefix="/skills", tags=["Skills"]) + + @router.get("/", response_model=List[schemas.SkillResponse]) + def list_skills( + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + """List all skills accessible to the user (their own, group skills, and system skills).""" + system_skills = db.query(models.Skill).filter(models.Skill.is_system == True).all() + user_skills = db.query(models.Skill).filter( + models.Skill.owner_id == current_user.id, + models.Skill.is_system == False + ).all() + group_skills = [] + if current_user.group_id: + group_skills = db.query(models.Skill).filter( + models.Skill.group_id == current_user.group_id, + models.Skill.owner_id != current_user.id, + models.Skill.is_system == False + ).all() + return system_skills + user_skills + group_skills + + @router.post("/", response_model=schemas.SkillResponse) + def create_skill( + skill: schemas.SkillCreate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + """Create a new skill.""" + existing = db.query(models.Skill).filter(models.Skill.name == skill.name).first() + if existing: + raise HTTPException(status_code=400, detail="Skill with this name already exists") + + db_skill = models.Skill( + name=skill.name, + description=skill.description, + skill_type=skill.skill_type, + config=skill.config, + owner_id=current_user.id, + group_id=skill.group_id, + is_system=skill.is_system if current_user.role == 'admin' else False + ) + db.add(db_skill) + db.commit() + db.refresh(db_skill) + return db_skill + + @router.put("/{skill_id}", response_model=schemas.SkillResponse) + def update_skill( + skill_id: int, + skill_update: schemas.SkillUpdate, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + """Update an existing skill. User must be admin or the owner.""" + db_skill = db.query(models.Skill).filter(models.Skill.id == skill_id).first() + if not db_skill: + raise HTTPException(status_code=404, detail="Skill not found") + + if db_skill.owner_id != current_user.id and current_user.role != 'admin': + raise HTTPException(status_code=403, detail="Not authorized to update this skill") + + if skill_update.name is not None and skill_update.name != db_skill.name: + existing = db.query(models.Skill).filter(models.Skill.name == skill_update.name).first() + if existing: + raise HTTPException(status_code=400, detail="Skill with this name already exists") + + for key, value in skill_update.model_dump(exclude_unset=True).items(): + if key == 'is_system' and current_user.role != 'admin': + continue + setattr(db_skill, key, value) + + db.commit() + db.refresh(db_skill) + return db_skill + + @router.delete("/{skill_id}") + def delete_skill( + skill_id: int, + db: Session = Depends(get_db), + current_user: models.User = Depends(get_current_user) + ): + """Delete a skill.""" + db_skill = db.query(models.Skill).filter(models.Skill.id == skill_id).first() + if not db_skill: + raise HTTPException(status_code=404, detail="Skill not found") + + if db_skill.owner_id != current_user.id and current_user.role != 'admin': + raise HTTPException(status_code=403, detail="Not authorized to delete this skill") + + db.delete(db_skill) + db.commit() + return {"message": "Skill deleted"} + + return router diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 7d8b7af..4777f1f 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -69,11 +69,38 @@ stt: dict = Field(default_factory=dict) statuses: Optional[dict] = Field(default_factory=dict) +# --- Config & General Schemas --- class ConfigResponse(BaseModel): """Schema for returning user preferences alongside effective settings.""" preferences: UserPreferences effective: dict = Field(default_factory=dict) +# --- Skill Schemas --- +class SkillBase(BaseModel): + name: str + description: Optional[str] = None + skill_type: str = "local" # local, remote_grpc, mcp + config: dict = Field(default_factory=dict) + is_system: bool = False + +class SkillCreate(SkillBase): + group_id: Optional[str] = None + +class SkillUpdate(BaseModel): + name: Optional[str] = None + description: Optional[str] = None + skill_type: Optional[str] = None + config: Optional[dict] = None + is_system: Optional[bool] = None + group_id: Optional[str] = None + +class SkillResponse(SkillBase): + id: int + owner_id: str + group_id: Optional[str] = None + created_at: datetime + model_config = ConfigDict(from_attributes=True) + # --- Chat Schemas --- class ChatRequest(BaseModel): """Defines the shape of a request to the /chat endpoint.""" diff --git a/ui/client-app/src/App.js b/ui/client-app/src/App.js index 9e49cf6..aac4d04 100644 --- a/ui/client-app/src/App.js +++ b/ui/client-app/src/App.js @@ -8,6 +8,7 @@ import SettingsPage from "./pages/SettingsPage"; import ProfilePage from "./pages/ProfilePage"; import NodesPage from "./pages/NodesPage"; +import SkillsPage from "./pages/SkillsPage"; import { getUserStatus, logout, getUserProfile } from "./services/apiService"; const Icon = ({ path, onClick, className }) => ( @@ -33,7 +34,7 @@ const [userId, setUserId] = useState(null); const [userProfile, setUserProfile] = useState(null); - const authenticatedPages = ["voice-chat", "coding-assistant", "settings", "profile", "nodes"]; + const authenticatedPages = ["voice-chat", "coding-assistant", "settings", "profile", "nodes", "skills"]; useEffect(() => { @@ -140,6 +141,8 @@ return ; case "nodes": return ; + case "skills": + return ; case "login": return ; default: diff --git a/ui/client-app/src/components/Navbar.js b/ui/client-app/src/components/Navbar.js index a70712d..8c2fdcf 100644 --- a/ui/client-app/src/components/Navbar.js +++ b/ui/client-app/src/components/Navbar.js @@ -7,6 +7,7 @@ { name: "Voice Chat", icon: "M12 1a3 3 0 0 1 3 3v7a3 3 0 1 1-6 0V4a3 3 0 0 1 3-3zm5 10a5 5 0 0 1-10 0H5a7 7 0 0 0 14 0h-2zm-5 11v-4h-2v4h2z", page: "voice-chat" }, { name: "Coding Assistant", icon: "M9 16l-4-4 4-4M15 16l4-4-4-4", page: "coding-assistant" }, { name: "Agent Nodes", icon: "M5 12h14M12 5l7 7-7 7", page: "nodes" }, + { name: "Skills & Workflows", icon: "M12 2l-1 4h-4l3 3-1 4 3-2 3 2-1-4 3-3h-4z", page: "skills" }, { name: "History", icon: "M22 12h-4l-3 9L9 3l-3 9H2", page: "history", disabled: true }, { name: "Favorites", icon: "M20.84 4.61a5.5 5.5 0 0 0-7.78 0L12 5.67l-1.06-1.06a5.5 5.5 0 0 0-7.78 7.78l1.06 1.06L12 21.23l7.78-7.78 1.06-1.06a5.5 5.5 0 0 0 0-7.78z", page: "favorites", disabled: true }, diff --git a/ui/client-app/src/pages/SkillsPage.js b/ui/client-app/src/pages/SkillsPage.js new file mode 100644 index 0000000..23a906d --- /dev/null +++ b/ui/client-app/src/pages/SkillsPage.js @@ -0,0 +1,311 @@ +import React, { useState, useEffect } from 'react'; +import { getSkills, createSkill, updateSkill, deleteSkill } from '../services/apiService'; + +export default function SkillsPage({ user, Icon }) { + const [skills, setSkills] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [isModalOpen, setIsModalOpen] = useState(false); + const [editingSkill, setEditingSkill] = useState(null); + + const [formData, setFormData] = useState({ + name: '', + description: '', + skill_type: 'local', + config: '{}', + is_system: false, + group_id: '' + }); + + const fetchSkills = async () => { + try { + setLoading(true); + const data = await getSkills(); + setSkills(data); + setError(null); + } catch (err) { + setError("Failed to load skills."); + } finally { + setLoading(false); + } + }; + + useEffect(() => { + fetchSkills(); + }, []); + + const openModal = (skill = null) => { + if (skill) { + setEditingSkill(skill); + setFormData({ + name: skill.name, + description: skill.description || '', + skill_type: skill.skill_type, + config: JSON.stringify(skill.config, null, 2), + is_system: skill.is_system, + group_id: skill.group_id || '' + }); + } else { + setEditingSkill(null); + setFormData({ + name: '', + description: '', + skill_type: 'local', + config: '{}', + is_system: false, + group_id: '' + }); + } + setIsModalOpen(true); + }; + + const handleClone = (skill) => { + setEditingSkill(null); // Force it to act like a 'Create' + setFormData({ + name: `${skill.name}_clone`, + description: skill.description || '', + skill_type: skill.skill_type, + config: JSON.stringify(skill.config, null, 2), + is_system: false, // cloned skills are never system by default + group_id: skill.group_id || '' + }); + setIsModalOpen(true); + }; + + const closeModal = () => { + setIsModalOpen(false); + setEditingSkill(null); + }; + + const handleSave = async () => { + try { + let configObj = {}; + try { + configObj = JSON.parse(formData.config); + } catch (e) { + alert("Invalid JSON in config"); + return; + } + + const payload = { + name: formData.name, + description: formData.description, + skill_type: formData.skill_type, + config: configObj, + group_id: formData.group_id || null, + is_system: formData.is_system + }; + + if (editingSkill) { + await updateSkill(editingSkill.id, payload); + } else { + await createSkill(payload); + } + closeModal(); + fetchSkills(); + } catch (err) { + alert("Error saving skill"); + } + }; + + const handleDelete = async (id) => { + if (!window.confirm("Are you sure you want to delete this skill?")) return; + try { + await deleteSkill(id); + fetchSkills(); + } catch (err) { + alert("Error deleting skill"); + } + }; + + const isAdmin = user?.role === 'admin'; + + return ( +
+
+
+

+ Skills & Workflows +

+

+ Create, manage, and share AI capabilities and workflows. +

+
+ +
+ +
+ {loading ? ( +
+
+
+ ) : error ? ( +
{error}
+ ) : ( +
+ {skills.map((skill) => ( +
+
+

+ {skill.name} + {skill.is_system && ( + System + )} + {skill.group_id && !skill.is_system && ( + Group + )} +

+
+ + {(isAdmin || skill.owner_id === user?.id) && ( + <> + + + + )} +
+
+ +

+ {skill.description || "No description provided."} +

+ +
+ + TYPE: {skill.skill_type} + + Created: {new Date(skill.created_at).toLocaleDateString()} +
+
+ ))} + {skills.length === 0 && ( +
+ +

No skills found.

+

Create your first skill to extend the AI's capabilities.

+
+ )} +
+ )} +
+ + {isModalOpen && ( +
+
+
+

+ + {editingSkill ? 'Edit Skill Configuration' : 'Create New Skill'} +

+ +
+ +
+
+
+ + setFormData({ ...formData, name: e.target.value })} + className="w-full bg-gray-50 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 rounded-lg px-4 py-2 focus:ring-2 focus:ring-indigo-500 focus:border-transparent outline-none transition-all placeholder-gray-400" + placeholder="e.g. github_search" + /> +
+
+ + +
+
+ +
+ +