diff --git a/ai-hub/app/api/dependencies.py b/ai-hub/app/api/dependencies.py index 24efc59..82477c5 100644 --- a/ai-hub/app/api/dependencies.py +++ b/ai-hub/app/api/dependencies.py @@ -1,7 +1,7 @@ -# app/api/dependencies.py -from fastapi import Depends, HTTPException, status -from typing import List,Any +from fastapi import Depends, HTTPException, status, Header +from typing import List, Any, Optional, Annotated from sqlalchemy.orm import Session +from app.db import models from app.db.session import SessionLocal from app.core.retrievers.base_retriever import Retriever from app.core.services.document import DocumentService @@ -17,12 +17,24 @@ finally: db.close() -# This is another common dependency -async def get_current_user(token: str): - # In a real app, you would decode the token and fetch the user - if not token: - raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED) - return {"email": "user@example.com", "id": 1} # Dummy user +# Dependency to get current user object from X-User-ID header +async def get_current_user( + db: Session = Depends(get_db), + x_user_id: Annotated[Optional[str], Header()] = None +) -> models.User: + if not x_user_id: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="X-User-ID header is missing" + ) + + user = db.query(models.User).filter(models.User.id == x_user_id).first() + if not user: + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="User not found" + ) + return user class ServiceContainer: @@ -47,26 +59,23 @@ setattr(self, name, service) return self - def with_document_service(self, vector_store: FaissVectorStore): + def with_document_service(self, vector_store: Optional[FaissVectorStore]): """ Adds a DocumentService instance to the container. """ - self.document_service = DocumentService(vector_store=vector_store) + if vector_store: + self.document_service = DocumentService(vector_store=vector_store) return self - def with_rag_service(self, retrievers: List[Retriever], prompt_service = None): + def with_rag_service(self, retrievers: List[Retriever], prompt_service = None, tool_service = None): """ Adds a RAGService instance to the container. """ - self.rag_service = RAGService(retrievers=retrievers, prompt_service=prompt_service) + self.rag_service = RAGService(retrievers=retrievers, prompt_service=prompt_service, tool_service=tool_service) return self def __getattr__(self, name: str) -> Any: """ Allows services to be accessed directly as attributes (e.g., container.rag_service). """ - # This allows direct access to services that are not explicitly defined in __init__ - try: - return self.__getattribute__(name) - except AttributeError: - raise AttributeError(f"'{self.__class__.__name__}' object has no service named '{name}'") \ No newline at end of file + raise AttributeError(f"'{self.__class__.__name__}' object has no service named '{name}'") \ No newline at end of file diff --git a/ai-hub/app/api/routes/skills.py b/ai-hub/app/api/routes/skills.py index 441eb11..f088c2d 100644 --- a/ai-hub/app/api/routes/skills.py +++ b/ai-hub/app/api/routes/skills.py @@ -1,6 +1,6 @@ -from fastapi import APIRouter, Depends, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.orm import Session -from typing import List +from typing import List, Optional from app.db import models from app.api import schemas from app.api.dependencies import ServiceContainer, get_current_user, get_db @@ -11,21 +11,49 @@ @router.get("/", response_model=List[schemas.SkillResponse]) def list_skills( db: Session = Depends(get_db), - current_user: models.User = Depends(get_current_user) + current_user: models.User = Depends(get_current_user), + feature: Optional[str] = Query(None, description="Filter skills by feature (e.g., 'chat', 'voice')") ): - """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( + """List all skills accessible to the user.""" + # Start queries + system_query = db.query(models.Skill).filter(models.Skill.is_system == True) + user_query = db.query(models.Skill).filter( models.Skill.owner_id == current_user.id, models.Skill.is_system == False - ).all() + ) + + # Policy: Only show enabled skills to non-admins + if current_user.role != 'admin': + system_query = system_query.filter(models.Skill.is_enabled == True) + user_query = user_query.filter(models.Skill.is_enabled == True) + + # Target feature filtering (PostgreSQL JSONB contains or standard JSON) + if feature: + # Using standard list comparison as fallback or JSONB contains + system_skills = [s for s in system_query.all() if feature in (s.features or [])] + user_skills = [s for s in user_query.all() if feature in (s.features or [])] + else: + system_skills = system_query.all() + user_skills = user_query.all() + + # Skills shared with the user's group via Group Policy 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() + if current_user.group and current_user.group.policy: + group_skill_names = current_user.group.policy.get("skills", []) + if group_skill_names: + g_query = db.query(models.Skill).filter( + models.Skill.name.in_(group_skill_names), + models.Skill.owner_id != current_user.id, + models.Skill.is_system == False + ) + if current_user.role != 'admin': + g_query = g_query.filter(models.Skill.is_enabled == True) + + if feature: + group_skills = [s for s in g_query.all() if feature in (s.features or [])] + else: + group_skills = g_query.all() + return system_skills + user_skills + group_skills @router.post("/", response_model=schemas.SkillResponse) @@ -44,8 +72,10 @@ description=skill.description, skill_type=skill.skill_type, config=skill.config, + system_prompt=skill.system_prompt, + is_enabled=skill.is_enabled, + features=skill.features, 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) diff --git a/ai-hub/app/api/schemas.py b/ai-hub/app/api/schemas.py index 4777f1f..1f6bac5 100644 --- a/ai-hub/app/api/schemas.py +++ b/ai-hub/app/api/schemas.py @@ -81,23 +81,27 @@ description: Optional[str] = None skill_type: str = "local" # local, remote_grpc, mcp config: dict = Field(default_factory=dict) + system_prompt: Optional[str] = None + is_enabled: bool = True + features: List[str] = Field(default_factory=lambda: ["chat"]) is_system: bool = False class SkillCreate(SkillBase): - group_id: Optional[str] = None + pass class SkillUpdate(BaseModel): name: Optional[str] = None description: Optional[str] = None skill_type: Optional[str] = None config: Optional[dict] = None + system_prompt: Optional[str] = None + is_enabled: Optional[bool] = None + features: Optional[List[str]] = 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) diff --git a/ai-hub/app/app.py b/ai-hub/app/app.py index ca13a01..c71d329 100644 --- a/ai-hub/app/app.py +++ b/ai-hub/app/app.py @@ -61,6 +61,16 @@ except Exception as e: logger.error(f"[M6] Failed to start gRPC server: {e}") + # --- Bootstrap System Skills --- + try: + from app.core.skills.bootstrap import bootstrap_system_skills + # Use the context manager to ensure session is closed + from app.db.session import get_db_session + with get_db_session() as db: + bootstrap_system_skills(db) + except Exception as e: + logger.error(f"Failed to bootstrap system skills: {e}") + yield print("Application shutdown...") # --- Stop gRPC Orchestrator --- @@ -141,17 +151,21 @@ prompt_service = PromptService() # 9. Initialize the Service Container with all initialized services services = ServiceContainer() - services.with_rag_service(retrievers=retrievers, prompt_service=prompt_service) services.with_document_service(vector_store=vector_store) + # Core orchestration first + services.with_service("node_registry_service", service=NodeRegistryService()) + tool_service = ToolService(services=services) + services.with_service("tool_service", service=tool_service) + + services.with_rag_service(retrievers=retrievers, prompt_service=prompt_service, tool_service=tool_service) + services.with_service("stt_service", service=STTService(stt_provider=stt_provider)) services.with_service("tts_service", service=TTSService(tts_provider=tts_provider)) services.with_service("prompt_service", service=prompt_service) services.with_service("session_service", service=SessionService()) services.with_service("user_service", service=UserService()) - services.with_service("tool_service", service=ToolService()) - services.with_service("node_registry_service", service=NodeRegistryService()) app.state.services = services diff --git a/ai-hub/app/core/pipelines/rag_pipeline.py b/ai-hub/app/core/pipelines/rag_pipeline.py index fcd93ea..c63107c 100644 --- a/ai-hub/app/core/pipelines/rag_pipeline.py +++ b/ai-hub/app/core/pipelines/rag_pipeline.py @@ -6,7 +6,17 @@ # Define a default prompt template outside the class or as a class constant # This is inferred from the usage in the provided diff. -PROMPT_TEMPLATE = """Generate a natural and context-aware answer to the user's question using the provided knowledge and conversation history. +PROMPT_TEMPLATE = """You are the Cortex AI Assistant, a powerful orchestrator of decentralized agent nodes. + +## Architecture Highlights: +- You operate within a secure, gRPC-based mesh of Agent Nodes. +- You can execute shell commands, browse the web, and manage files on these nodes. +- You use 'skills' to interact with the physical world. + +{mesh_context} + +## Task: +Generate a natural and context-aware answer using the provided knowledge, conversation history, and available tools. Relevant excerpts from the knowledge base: {context} @@ -18,6 +28,18 @@ Answer:""" +VOICE_PROMPT_TEMPLATE = """You are a conversational voice assistant. +Keep your responses short, natural, and helpful. +Avoid using technical jargon or listing technical infrastructure details unless specifically asked. +Focus on being a friendly companion. + +Conversation History: +{chat_history} + +User Question: {question} + +Answer:""" + class RagPipeline: """ A flexible and extensible RAG pipeline updated to remove DSPy dependency. @@ -40,8 +62,12 @@ history: List[models.Message], llm_provider = None, prompt_service = None, + tool_service = None, + tools: List[Dict[str, Any]] = None, + mesh_context: str = "", db: Optional[Session] = None, user_id: Optional[str] = None, + feature_name: str = "chat", prompt_slug: str = "rag-pipeline" ) -> str: logging.debug(f"[RagPipeline.forward] Received question: '{question}'") @@ -53,25 +79,71 @@ context_text = self.context_postprocessor(context_chunks) template = PROMPT_TEMPLATE + if feature_name == "voice": + template = VOICE_PROMPT_TEMPLATE + if prompt_service and db and user_id: db_prompt = prompt_service.get_prompt_by_slug(db, prompt_slug, user_id) if db_prompt: template = db_prompt.content - prompt = template.format( + system_prompt = template.format( question=question, context=context_text, - chat_history=history_text + chat_history=history_text, + mesh_context=mesh_context ) - - prediction = await llm_provider.acompletion(prompt=prompt) - raw_response = prediction.choices[0].message.content - # Step 4: Optional response postprocessing - if self.response_postprocessor: - return self.response_postprocessor(raw_response) + # 1. Prepare initial messages + # We put the 'question' as the user message and use 'system_prompt' for instructions/context + messages = [ + {"role": "system", "content": system_prompt}, + {"role": "user", "content": question} + ] - return raw_response + # 2. Agentic Tool Loop (Max 5 turns to prevent infinite loops) + for _ in range(5): + request_kwargs = {} + if tools: + request_kwargs["tools"] = tools + request_kwargs["tool_choice"] = "auto" + + prediction = await llm_provider.acompletion(messages=messages, **request_kwargs) + message = prediction.choices[0].message + + # If no tool calls, we are done + if not getattr(message, "tool_calls", None): + raw_response = message.content or "" + if self.response_postprocessor: + return self.response_postprocessor(raw_response) + return raw_response + + # Process tool calls + messages.append(message) # Add assistant message with tool_calls + + for tool_call in message.tool_calls: + func_name = tool_call.function.name + func_args = {} + try: + import json + func_args = json.loads(tool_call.function.arguments) + except: pass + + logging.info(f"[🔧] Agent calling tool: {func_name} with {func_args}") + + if tool_service: + result = await tool_service.call_tool(func_name, func_args, db=db, user_id=user_id) + else: + result = {"success": False, "error": "Tool service not available"} + + messages.append({ + "role": "tool", + "tool_call_id": tool_call.id, + "name": func_name, + "content": str(result) + }) + + return "Agent loop reached maximum turns without a final response." def _build_prompt(self, context, history, question): return f"""Generate a natural and context-aware answer to the user's question using the provided knowledge and conversation history. diff --git a/ai-hub/app/core/services/rag.py b/ai-hub/app/core/services/rag.py index 6d11545..08eba39 100644 --- a/ai-hub/app/core/services/rag.py +++ b/ai-hub/app/core/services/rag.py @@ -12,9 +12,10 @@ Service for orchestrating conversational RAG pipelines. Manages chat interactions and message history for a session. """ - def __init__(self, retrievers: List[Retriever], prompt_service = None): + def __init__(self, retrievers: List[Retriever], prompt_service = None, tool_service = None): self.retrievers = retrievers self.prompt_service = prompt_service + self.tool_service = tool_service self.faiss_retriever = next((r for r in retrievers if isinstance(r, FaissDBRetriever)), None) async def chat_with_rag( @@ -89,6 +90,25 @@ print("Warning: FaissDBRetriever requested but not available. Proceeding without it.") rag_pipeline = RagPipeline() + + # Discover available tools (Skills) for the current user + # Filter by session.feature_name (e.g. 'chat' or 'voice') + tools = [] + if self.tool_service: + tools = self.tool_service.get_available_tools(db, session.user_id, feature=session.feature_name) + + # Gather information about attached nodes for the system prompt + mesh_context = "" + if session.attached_node_ids: + nodes = db.query(models.AgentNode).filter(models.AgentNode.node_id.in_(session.attached_node_ids)).all() + if nodes: + mesh_context = "Attached Agent Nodes (Infrastructure):\n" + for node in nodes: + mesh_context += f"- Node ID: {node.node_id}\n" + mesh_context += f" Name: {node.display_name}\n" + mesh_context += f" Description: {node.description or 'No description provided.'}\n" + mesh_context += f" Status: {node.last_status}\n" + mesh_context += "\n" answer_text = await rag_pipeline.forward( question=prompt, @@ -96,8 +116,12 @@ context_chunks = context_chunks, llm_provider = llm_provider, prompt_service = self.prompt_service, + tool_service = self.tool_service, + tools = tools, + mesh_context = mesh_context, db = db, user_id = user_id or session.user_id, + feature_name = session.feature_name, prompt_slug = "rag-pipeline" ) diff --git a/ai-hub/app/core/services/tool.py b/ai-hub/app/core/services/tool.py index e05f104..c76c4f2 100644 --- a/ai-hub/app/core/services/tool.py +++ b/ai-hub/app/core/services/tool.py @@ -12,23 +12,35 @@ Handles discovery, permission checks, and execution routing. """ - def __init__(self, local_skills: List[BaseSkill] = []): + def __init__(self, services: Any = None, local_skills: List[BaseSkill] = []): + self._services = services self._local_skills = {s.name: s for s in local_skills} - def get_available_tools(self, db: Session, user_id: str) -> List[Dict[str, Any]]: + def get_available_tools(self, db: Session, user_id: str, feature: str = None) -> List[Dict[str, Any]]: """ - Retrieves all tools the user is authorized to use. + Retrieves all tools the user is authorized to use, optionally filtered by feature. """ - # 1. Start with system/local skills - tools = [s.to_tool_definition() for s in self._local_skills.values()] + # 1. Fetch system/local skills and filter by feature if requested + local_skills = self._local_skills.values() + if feature: + local_skills = [s for s in local_skills if feature in getattr(s, "features", ["chat"])] - # 2. Add DB-defined skills with permission checks - db_skills = db.query(models.Skill).filter( + tools = [s.to_tool_definition() for s in local_skills] + + # 2. Add DB-defined skills (System skills or user-owned) + query = db.query(models.Skill).filter( (models.Skill.is_system == True) | (models.Skill.owner_id == user_id) - ).all() + ).filter(models.Skill.is_enabled == True) - # TODO: Implement more complex group-based permission logic + if feature: + # SQLAlchemy JSON containment check (SQLite specific or generic enough) + # For simplicity, we filter in Python if the DB driver is tricky + db_skills = query.all() + db_skills = [ds for ds in db_skills if feature in (ds.features or [])] + else: + db_skills = query.all() + for ds in db_skills: # Prevent duplicates if name overlaps with local if any(t["function"]["name"] == ds.name for t in tools): @@ -45,15 +57,78 @@ return tools - async def call_tool(self, tool_name: str, arguments: Dict[str, Any], **context) -> Any: + async def call_tool(self, tool_name: str, arguments: Dict[str, Any], db: Session = None, user_id: str = None) -> Any: """ Executes a registered skill. """ + # 1. Try local/native skill first if tool_name in self._local_skills: skill = self._local_skills[tool_name] result = await skill.execute(**arguments) return result.dict() - # TODO: Handle remote/gRPC skills or MCP skills here + # 2. Handle System / DB Skills + if db: + db_skill = db.query(models.Skill).filter(models.Skill.name == tool_name).first() + if db_skill and db_skill.is_system: + return await self._execute_system_skill(db_skill, arguments) + logger.error(f"Tool '{tool_name}' not found or handled yet.") return {"success": False, "error": "Tool not found"} + + async def _execute_system_skill(self, skill: models.Skill, args: Dict[str, Any]) -> Any: + """Routes system skill execution to the appropriate internal service.""" + orchestrator = getattr(self._services, "orchestrator", None) + if not orchestrator: + return {"success": False, "error": "Orchestrator not available"} + + assistant = orchestrator.assistant + node_id = args.get("node_id") + + if not node_id: + return {"success": False, "error": "node_id is required"} + + try: + if skill.name == "mesh_terminal_control": + # Maps to TaskAssistant.dispatch_single + cmd = args.get("command") + res = assistant.dispatch_single(node_id, cmd) + return {"success": True, "output": res} + + elif skill.name == "browser_automation_agent": + # Maps to TaskAssistant.dispatch_browser + from app.protos import agent_pb2 + action_str = args.get("action", "navigate").upper() + action_type = getattr(agent_pb2.BrowserAction, action_str, agent_pb2.BrowserAction.NAVIGATE) + + browser_action = agent_pb2.BrowserAction( + action=action_type, + url=args.get("url", ""), + ) + res = assistant.dispatch_browser(node_id, browser_action) + return {"success": True, "output": res} + + elif skill.name == "mesh_file_explorer": + # Maps to TaskAssistant.ls, cat, write, rm + action = args.get("action") + path = args.get("path") + + if action == "list": + res = assistant.ls(node_id, path) + elif action == "read": + res = assistant.cat(node_id, path) + elif action == "write": + content = args.get("content", "").encode('utf-8') + res = assistant.write(node_id, path, content) + elif action == "delete": + res = assistant.rm(node_id, path) + else: + return {"success": False, "error": f"Unsupported action: {action}"} + + return {"success": True, "output": res} + + except Exception as e: + logger.exception(f"System skill execution failed: {e}") + return {"success": False, "error": str(e)} + + return {"success": False, "error": "Skill execution logic not found"} diff --git a/ai-hub/app/core/skills/bootstrap.py b/ai-hub/app/core/skills/bootstrap.py new file mode 100644 index 0000000..fc8cfb3 --- /dev/null +++ b/ai-hub/app/core/skills/bootstrap.py @@ -0,0 +1,58 @@ +# app/core/skills/bootstrap.py +import logging +from sqlalchemy.orm import Session +from app.db import models +from .definitions import SYSTEM_SKILLS + +logger = logging.getLogger(__name__) + +def bootstrap_system_skills(db: Session): + """ + Ensure all hardcoded system skills from definitions.py exist in the database. + This runs on application startup (lifespan). + """ + logger.info("Checking for system skills bootstrapping...") + + # We need a system owner ID. For now, we'll try to find an admin user or use a 'system' id. + # In this DB, the root admin usually has a known ID or we can find it. + admin = db.query(models.User).filter(models.User.role == 'admin').first() + if not admin: + logger.warning("No admin user found to own system skills. Skipping bootstrap.") + return + + for skill_def in SYSTEM_SKILLS: + existing = db.query(models.Skill).filter(models.Skill.name == skill_def["name"]).first() + + if existing: + # We update it to ensure it matches the hardcoded version (RESTORE FACTORY) + # but preserve the skill's identity. + logger.info(f"Syncing system skill: {skill_def['name']}") + existing.description = skill_def.get("description") + existing.skill_type = skill_def.get("skill_type") + existing.config = skill_def.get("config") + existing.system_prompt = skill_def.get("system_prompt") + existing.is_enabled = skill_def.get("is_enabled", True) + existing.features = skill_def.get("features", ["chat"]) + existing.is_system = True + existing.owner_id = admin.id + else: + logger.info(f"Creating new system skill: {skill_def['name']}") + new_skill = models.Skill( + name=skill_def["name"], + description=skill_def.get("description"), + skill_type=skill_def.get("skill_type"), + config=skill_def["config"], + system_prompt=skill_def.get("system_prompt"), + is_enabled=skill_def.get("is_enabled", True), + features=skill_def.get("features", ["chat"]), + is_system=True, + owner_id=admin.id + ) + db.add(new_skill) + + try: + db.commit() + logger.info("System skills bootstrap completed.") + except Exception as e: + db.rollback() + logger.error(f"Failed to bootstrap system skills: {e}") diff --git a/ai-hub/app/core/skills/definitions.py b/ai-hub/app/core/skills/definitions.py new file mode 100644 index 0000000..766549f --- /dev/null +++ b/ai-hub/app/core/skills/definitions.py @@ -0,0 +1,91 @@ +# app/core/skills/definitions.py + +SYSTEM_SKILLS = [ + { + "name": "mesh_terminal_control", + "description": "Execute stateful shell commands and manage terminal sessions across the agent mesh.", + "system_prompt": "You are an expert linux terminal operator. When using this skill, you have direct access to a PTY. Format commands clearly and wait for confirmation if they are destructive.", + "skill_type": "remote_grpc", + "is_enabled": True, + "features": ["chat"], + "config": { + "service": "TerminalService", + "method": "Execute", + "capabilities": ["shell", "pty", "interactive"], + "parameters": { + "type": "object", + "properties": { + "command": {"type": "string", "description": "The shell command to execute."}, + "node_id": {"type": "string", "description": "The target node ID within the mesh."} + }, + "required": ["command", "node_id"] + } + }, + "is_system": True + }, + { + "name": "browser_automation_agent", + "description": "Perform web browsing, form filling, and UI testing on remote agent nodes using Playwright.", + "system_prompt": "You are an AI browsing assistant. Use the Playwright tool to navigate pages, extract information, and interact with web elements. Always provide reasoning for your actions.", + "skill_type": "remote_grpc", + "is_enabled": True, + "features": ["chat", "workflow"], + "config": { + "service": "BrowserService", + "method": "Navigate", + "capabilities": ["browser", "screenshot", "click"], + "parameters": { + "type": "object", + "properties": { + "url": {"type": "string", "description": "The URL to navigate to."}, + "action": {"type": "string", "enum": ["navigate", "click", "type", "screenshot"], "description": "The browser action to perform."}, + "node_id": {"type": "string", "description": "The target node ID."} + }, + "required": ["url", "action", "node_id"] + } + }, + "is_system": True + }, + { + "name": "voice_interaction_handler", + "description": "Handle real-time voice interruptions, tone analysis, and speech-to-speech feedback loops.", + "system_prompt": "You are a voice-first AI. Keep your responses concise and conversational. Focus on natural prosody and handle interruptions gracefully.", + "skill_type": "local", + "is_enabled": True, + "features": ["voice"], + "config": { + "interaction_mode": "speech-to-speech", + "latency_target": 300, + "parameters": { + "type": "object", + "properties": { + "mode": {"type": "string", "enum": ["active", "passive"], "description": "Voice interaction mode."} + } + } + }, + "is_system": True + }, + { + "name": "mesh_file_explorer", + "description": "List, read, and manipulate files within the decentralized mesh synchronization system.", + "system_prompt": "You are a file management assistant. You can browse and synchronize files across different agent nodes.", + "skill_type": "local", + "is_enabled": True, + "features": ["chat", "workflow"], + "config": { + "internal_module": "app.core.grpc.core.mirror", + "actions": ["list", "read", "write", "delete"], + "parameters": { + "type": "object", + "properties": { + "action": {"type": "string", "enum": ["list", "read", "write", "delete"], "description": "File system action."}, + "path": {"type": "string", "description": "Relative path to the file/directory."}, + "node_id": {"type": "string", "description": "The target node ID."}, + "content": {"type": "string", "description": "Optional content for write action."} + }, + "required": ["action", "path", "node_id"] + } + }, + "is_system": True + } +] diff --git a/ai-hub/app/db/migrate.py b/ai-hub/app/db/migrate.py index 1f6e174..562b0ea 100644 --- a/ai-hub/app/db/migrate.py +++ b/ai-hub/app/db/migrate.py @@ -128,6 +128,44 @@ except Exception as e: logger.error(f"Failed to create 'node_group_access': {e}") + # Create skill_group_access table if it doesn't exist + if not inspector.has_table("skill_group_access"): + logger.info("Creating table 'skill_group_access'...") + try: + conn.execute(text(""" + CREATE TABLE IF NOT EXISTS skill_group_access ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + skill_id INTEGER NOT NULL REFERENCES skills(id), + group_id TEXT NOT NULL REFERENCES groups(id), + granted_by TEXT NOT NULL REFERENCES users(id), + granted_at DATETIME DEFAULT CURRENT_TIMESTAMP + ) + """)) + conn.commit() + logger.info("Table 'skill_group_access' created.") + except Exception as e: + logger.error(f"Failed to create 'skill_group_access': {e}") + + # --- Skill table migrations --- + if inspector.has_table("skills"): + skill_columns = [c["name"] for c in inspector.get_columns("skills")] + skill_required_columns = [ + ("system_prompt", "TEXT"), + ("is_enabled", "INTEGER DEFAULT 1"), + ("features", "TEXT DEFAULT '[\"chat\"]'"), + ("is_system", "INTEGER DEFAULT 0"), + ("skill_type", "TEXT DEFAULT 'local'"), + ] + for col_name, col_type in skill_required_columns: + if col_name not in skill_columns: + logger.info(f"Adding column '{col_name}' to 'skills' table...") + try: + conn.execute(text(f"ALTER TABLE skills ADD COLUMN {col_name} {col_type}")) + conn.commit() + logger.info(f"Successfully added '{col_name}' to 'skills'.") + except Exception as e: + logger.error(f"Failed to add column '{col_name}' to 'skills': {e}") + logger.info("Database migrations complete.") diff --git a/ai-hub/app/db/models.py b/ai-hub/app/db/models.py index 140ff74..7008875 100644 --- a/ai-hub/app/db/models.py +++ b/ai-hub/app/db/models.py @@ -270,18 +270,37 @@ # Stores tool definition, parameters, or endpoint config config = Column(JSON, default={}, nullable=True) + # Extended properties + system_prompt = Column(String, nullable=True) + is_enabled = Column(Boolean, default=True) + features = Column(JSON, default=["chat"], nullable=True) # e.g. ["chat", "voice"] + owner_id = Column(String, ForeignKey('users.id'), nullable=False) - group_id = Column(String, ForeignKey('groups.id'), nullable=True) is_system = Column(Boolean, default=False) created_at = Column(DateTime, default=datetime.utcnow) - + owner = relationship("User") - group = relationship("Group") def __repr__(self): return f"" +class SkillGroupAccess(Base): + """ + Many-to-many relationship between skills and groups. + """ + __tablename__ = 'skill_group_access' + + id = Column(Integer, primary_key=True, index=True) + skill_id = Column(Integer, ForeignKey('skills.id'), nullable=False) + group_id = Column(String, ForeignKey('groups.id'), nullable=False) + + granted_by = Column(String, ForeignKey('users.id'), nullable=False) + granted_at = Column(DateTime, default=datetime.utcnow) + + skill = relationship("Skill") + group = relationship("Group") + class MCPServer(Base): """ SQLAlchemy model for Model Context Protocol (MCP) server configurations. diff --git a/ui/client-app/src/pages/SettingsPage.js b/ui/client-app/src/pages/SettingsPage.js index 0ddeba0..e186d31 100644 --- a/ui/client-app/src/pages/SettingsPage.js +++ b/ui/client-app/src/pages/SettingsPage.js @@ -3,7 +3,8 @@ getUserConfig, updateUserConfig, exportUserConfig, importUserConfig, verifyProvider, getProviderModels, getAllProviders, getVoices, getAdminUsers, updateUserRole, getAdminGroups, createAdminGroup, - updateAdminGroup, deleteAdminGroup, updateUserGroup, getAdminNodes + updateAdminGroup, deleteAdminGroup, updateUserGroup, getAdminNodes, + getSkills } from '../services/apiService'; const SettingsPage = () => { @@ -33,6 +34,8 @@ const [addForm, setAddForm] = useState({ type: '', suffix: '', model: '', cloneFrom: '' }); const [allNodes, setAllNodes] = useState([]); const [nodesLoading, setNodesLoading] = useState(false); + const [allSkills, setAllSkills] = useState([]); + const [skillsLoading, setSkillsLoading] = useState(false); const fileInputRef = useRef(null); const handleViewVoices = async (providerId, apiKey = null) => { @@ -74,8 +77,21 @@ loadUsers(); loadGroups(); loadNodes(); + loadSkills(); }, []); + const loadSkills = async () => { + try { + setSkillsLoading(true); + const skills = await getSkills(); + setAllSkills(skills); + } catch (e) { + console.error("Failed to load skills", e); + } finally { + setSkillsLoading(false); + } + }; + const loadNodes = async () => { try { setNodesLoading(true); @@ -1032,7 +1048,7 @@