diff --git a/app/plugins/builtins/ai_assistant/__init__.py b/app/plugins/builtins/ai_assistant/__init__.py new file mode 100644 index 0000000..6161452 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/__init__.py @@ -0,0 +1,3 @@ +from app.plugins.builtins.ai_assistant.plugin import AIAssistantPlugin + +__all__ = ["AIAssistantPlugin"] diff --git a/app/plugins/builtins/ai_assistant/migrations/0001_initial.sql b/app/plugins/builtins/ai_assistant/migrations/0001_initial.sql new file mode 100644 index 0000000..e7bebd2 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/migrations/0001_initial.sql @@ -0,0 +1,96 @@ +-- AI Assistant plugin initial migration + +CREATE TABLE IF NOT EXISTS ai_providers ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + provider_type VARCHAR(50) NOT NULL, + api_key TEXT NOT NULL DEFAULT '', + base_url VARCHAR(500) NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_default BOOLEAN NOT NULL DEFAULT FALSE, + config JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_providers_tenant ON ai_providers(tenant_id); + +CREATE TABLE IF NOT EXISTS ai_models ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_id UUID NOT NULL REFERENCES ai_providers(id) ON DELETE CASCADE, + model_id VARCHAR(200) NOT NULL, + display_name VARCHAR(200) NOT NULL, + context_window INTEGER NOT NULL DEFAULT 4096, + supports_tools BOOLEAN NOT NULL DEFAULT FALSE, + supports_streaming BOOLEAN NOT NULL DEFAULT TRUE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_models_provider ON ai_models(provider_id); +CREATE INDEX IF NOT EXISTS ix_ai_models_tenant ON ai_models(tenant_id); + +CREATE TABLE IF NOT EXISTS ai_presets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + model_id VARCHAR(200) NOT NULL, + provider_id UUID REFERENCES ai_providers(id) ON DELETE SET NULL, + temperature REAL NOT NULL DEFAULT 0.7, + max_tokens INTEGER NOT NULL DEFAULT 2048, + top_p REAL NOT NULL DEFAULT 1.0, + system_prompt TEXT NOT NULL DEFAULT '', + config JSONB NOT NULL DEFAULT '{}', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_presets_tenant ON ai_presets(tenant_id); + +CREATE TABLE IF NOT EXISTS ai_agents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL, + description TEXT NOT NULL DEFAULT '', + system_prompt TEXT NOT NULL DEFAULT '', + preset_id UUID REFERENCES ai_presets(id) ON DELETE SET NULL, + tool_ids JSONB NOT NULL DEFAULT '[]', + is_default BOOLEAN NOT NULL DEFAULT FALSE, + is_active BOOLEAN NOT NULL DEFAULT TRUE, + config JSONB NOT NULL DEFAULT '{}', + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_agents_tenant ON ai_agents(tenant_id); + +CREATE TABLE IF NOT EXISTS ai_chat_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL, + agent_id UUID REFERENCES ai_agents(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL DEFAULT 'Neuer Chat', + is_pinned BOOLEAN NOT NULL DEFAULT FALSE, + is_sidebar BOOLEAN NOT NULL DEFAULT FALSE, + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_sessions_user ON ai_chat_sessions(user_id); +CREATE INDEX IF NOT EXISTS ix_ai_sessions_tenant ON ai_chat_sessions(tenant_id); + +CREATE TABLE IF NOT EXISTS ai_chat_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + session_id UUID NOT NULL REFERENCES ai_chat_sessions(id) ON DELETE CASCADE, + role VARCHAR(20) NOT NULL, + content TEXT NOT NULL DEFAULT '', + tool_calls JSONB, + tool_results JSONB, + tokens INTEGER NOT NULL DEFAULT 0, + model_used VARCHAR(200) NOT NULL DEFAULT '', + tenant_id UUID NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +); +CREATE INDEX IF NOT EXISTS ix_ai_messages_session ON ai_chat_messages(session_id); +CREATE INDEX IF NOT EXISTS ix_ai_messages_tenant ON ai_chat_messages(tenant_id); diff --git a/app/plugins/builtins/ai_assistant/models.py b/app/plugins/builtins/ai_assistant/models.py new file mode 100644 index 0000000..f4d9606 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/models.py @@ -0,0 +1,178 @@ +"""SQLAlchemy models for the AI Assistant plugin.""" + +from __future__ import annotations + +import uuid +from datetime import datetime + +from sqlalchemy import ( + Boolean, + DateTime, + Float, + ForeignKey, + Index, + Integer, + String, + Text, +) +from sqlalchemy.dialects.postgresql import JSONB, UUID as PGUUID +from sqlalchemy.orm import Mapped, mapped_column + +from app.core.db import Base, TenantMixin + + +# --- Providers --- + +class AIProvider(Base, TenantMixin): + """LLM provider configuration (OpenAI, Anthropic, Ollama, etc.).""" + + __tablename__ = "ai_providers" + __table_args__ = ( + Index("ix_ai_providers_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + provider_type: Mapped[str] = mapped_column(String(50), nullable=False) + api_key: Mapped[str] = mapped_column(Text, nullable=False, default="") + base_url: Mapped[str] = mapped_column(String(500), nullable=False, default="") + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + + +# --- Models --- + +class AIModel(Base, TenantMixin): + """Available model per provider.""" + + __tablename__ = "ai_models" + __table_args__ = ( + Index("ix_ai_models_provider", "provider_id"), + Index("ix_ai_models_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + provider_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("ai_providers.id", ondelete="CASCADE"), + nullable=False, + ) + model_id: Mapped[str] = mapped_column(String(200), nullable=False) + display_name: Mapped[str] = mapped_column(String(200), nullable=False) + context_window: Mapped[int] = mapped_column(Integer, nullable=False, default=4096) + supports_tools: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + supports_streaming: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + + +# --- Presets --- + +class AIPreset(Base, TenantMixin): + """Model preset: model + parameters + optional system prompt.""" + + __tablename__ = "ai_presets" + __table_args__ = ( + Index("ix_ai_presets_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + model_id: Mapped[str] = mapped_column(String(200), nullable=False) + provider_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("ai_providers.id", ondelete="SET NULL"), + nullable=True, + ) + temperature: Mapped[float] = mapped_column(Float, nullable=False, default=0.7) + max_tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=2048) + top_p: Mapped[float] = mapped_column(Float, nullable=False, default=1.0) + system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="") + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + + +# --- Agents --- + +class AIAgent(Base, TenantMixin): + """AI agent with system prompt and assigned tools.""" + + __tablename__ = "ai_agents" + __table_args__ = ( + Index("ix_ai_agents_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + name: Mapped[str] = mapped_column(String(100), nullable=False) + description: Mapped[str] = mapped_column(Text, nullable=False, default="") + system_prompt: Mapped[str] = mapped_column(Text, nullable=False, default="") + preset_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("ai_presets.id", ondelete="SET NULL"), + nullable=True, + ) + tool_ids: Mapped[list] = mapped_column(JSONB, nullable=False, default=list) + is_default: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_active: Mapped[bool] = mapped_column(Boolean, nullable=False, default=True) + config: Mapped[dict] = mapped_column(JSONB, nullable=False, default=dict) + + +# --- Chat Sessions --- + +class AIChatSession(Base, TenantMixin): + """Chat session for a user with a specific agent.""" + + __tablename__ = "ai_chat_sessions" + __table_args__ = ( + Index("ix_ai_sessions_user", "user_id"), + Index("ix_ai_sessions_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + user_id: Mapped[uuid.UUID] = mapped_column(PGUUID(as_uuid=True), nullable=False) + agent_id: Mapped[uuid.UUID | None] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("ai_agents.id", ondelete="SET NULL"), + nullable=True, + ) + title: Mapped[str] = mapped_column(String(255), nullable=False, default="Neuer Chat") + is_pinned: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + is_sidebar: Mapped[bool] = mapped_column(Boolean, nullable=False, default=False) + + +# --- Chat Messages --- + +class AIChatMessage(Base, TenantMixin): + """Individual message in a chat session.""" + + __tablename__ = "ai_chat_messages" + __table_args__ = ( + Index("ix_ai_messages_session", "session_id"), + Index("ix_ai_messages_tenant", "tenant_id"), + ) + + id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), primary_key=True, default=uuid.uuid4 + ) + session_id: Mapped[uuid.UUID] = mapped_column( + PGUUID(as_uuid=True), + ForeignKey("ai_chat_sessions.id", ondelete="CASCADE"), + nullable=False, + ) + role: Mapped[str] = mapped_column(String(20), nullable=False) + content: Mapped[str] = mapped_column(Text, nullable=False, default="") + tool_calls: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + tool_results: Mapped[dict | None] = mapped_column(JSONB, nullable=True) + tokens: Mapped[int] = mapped_column(Integer, nullable=False, default=0) + model_used: Mapped[str] = mapped_column(String(200), nullable=False, default="") diff --git a/app/plugins/builtins/ai_assistant/plugin.py b/app/plugins/builtins/ai_assistant/plugin.py new file mode 100644 index 0000000..fcdc6f3 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/plugin.py @@ -0,0 +1,60 @@ +"""AI Assistant plugin — multi-provider LLM chat, agents, tools.""" + +from __future__ import annotations + +import logging +from typing import Any + +from app.plugins.base import BasePlugin +from app.plugins.manifest import PluginManifest, PluginRouteDef + +logger = logging.getLogger(__name__) + + +class AIAssistantPlugin(BasePlugin): + """AI Assistant plugin: multi-provider LLM chat with agents and tools.""" + + manifest = PluginManifest( + name="ai_assistant", + version="1.0.0", + display_name="KI Assistent", + description=( + "AI Assistant with multi-provider LLM support, custom agents, " + "plugin tools, and streaming chat." + ), + dependencies=[], + routes=[ + PluginRouteDef( + path="/api/v1/ai", + module="app.plugins.builtins.ai_assistant.routes", + router_attr="router", + ), + ], + events=[], + migrations=["0001_initial.sql"], + permissions=[ + "ai:read", + "ai:write", + "ai:config", + "ai:agents", + "ai:tools", + ], + is_core=True, + ) + + async def on_install(self, db, service_container) -> None: + """Seed default provider and agent.""" + from app.plugins.builtins.ai_assistant.services import seed_defaults + + await seed_defaults(db) + + def get_notification_types(self) -> list[dict[str, Any]]: + return [ + { + "type_key": "ai_response_error", + "category": "ai", + "label": "KI Antwort-Fehler", + "description": "Fehler bei der KI-Antwortgenerierung", + "is_enabled_by_default": True, + }, + ] diff --git a/app/plugins/builtins/ai_assistant/routes.py b/app/plugins/builtins/ai_assistant/routes.py new file mode 100644 index 0000000..e3e4440 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/routes.py @@ -0,0 +1,573 @@ +"""AI Assistant plugin routes — providers, models, presets, agents, +sessions, messages, tools, and streaming chat. +""" + +from __future__ import annotations + +import uuid + +from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import StreamingResponse +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.db import get_db, set_tenant_context +from app.deps import get_current_user, require_permission +from app.plugins.builtins.ai_assistant.models import ( + AIAgent, + AIChatSession, + AIModel, + AIProvider, +) +from app.plugins.builtins.ai_assistant.schemas import ( + AIAgentCreate, + AIAgentUpdate, + AIModelCreate, + AIModelUpdate, + AIPresetCreate, + AIPresetUpdate, + AIProviderCreate, + AIProviderUpdate, + ChatSendRequest, + ChatSessionCreate, + ChatSessionUpdate, +) +from app.plugins.builtins.ai_assistant.services import ( + agent_to_response, + get_agent_by_id, + get_default_agent, + get_preset_by_id, + get_provider_by_id, + get_session_by_id, + get_session_messages, + message_to_response, + model_to_response, + preset_to_response, + provider_to_response, + session_to_response, + stream_chat, +) +from app.plugins.builtins.ai_assistant.tool_registry import get_tool_registry + +router = APIRouter(prefix="/api/v1/ai", tags=["ai-assistant"]) + + +# ─── Providers ─── + +@router.get("/providers", dependencies=[Depends(require_permission("ai:config"))]) +async def list_providers( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await db.execute( + select(AIProvider).where(AIProvider.tenant_id == tenant_id) + ) + providers = list(result.scalars().all()) + return [provider_to_response(p) for p in providers] + + +@router.post("/providers", dependencies=[Depends(require_permission("ai:config"))]) +async def create_provider( + data: AIProviderCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + await set_tenant_context(db, tenant_id) + + # If is_default, unset other defaults + if data.is_default: + existing = await db.execute( + select(AIProvider) + .where(AIProvider.tenant_id == tenant_id) + .where(AIProvider.is_default == True) + ) + for p in existing.scalars().all(): + p.is_default = False + + provider = AIProvider( + name=data.name, + provider_type=data.provider_type, + api_key=data.api_key, + base_url=data.base_url, + is_active=data.is_active, + is_default=data.is_default, + config=data.config, + tenant_id=tenant_id, + ) + db.add(provider) + await db.commit() + await db.refresh(provider) + return provider_to_response(provider) + + +@router.put("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def update_provider( + provider_id: str, + data: AIProviderUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + + if data.is_default: + existing = await db.execute( + select(AIProvider) + .where(AIProvider.tenant_id == tenant_id) + .where(AIProvider.is_default == True) + .where(AIProvider.id != provider.id) + ) + for p in existing.scalars().all(): + p.is_default = False + + for field, val in data.model_dump(exclude_unset=True).items(): + setattr(provider, field, val) + await db.commit() + await db.refresh(provider) + return provider_to_response(provider) + + +@router.delete("/providers/{provider_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def delete_provider( + provider_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + provider = await get_provider_by_id(db, uuid.UUID(provider_id), tenant_id) + if not provider: + raise HTTPException(status_code=404, detail="Provider not found") + await db.delete(provider) + await db.commit() + return {"ok": True} + + +# ─── Models ─── + +@router.get("/models", dependencies=[Depends(require_permission("ai:config"))]) +async def list_models( + provider_id: str | None = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + stmt = select(AIModel).where(AIModel.tenant_id == tenant_id) + if provider_id: + stmt = stmt.where(AIModel.provider_id == uuid.UUID(provider_id)) + result = await db.execute(stmt) + models = list(result.scalars().all()) + return [model_to_response(m) for m in models] + + +@router.post("/models", dependencies=[Depends(require_permission("ai:config"))]) +async def create_model( + data: AIModelCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + await set_tenant_context(db, tenant_id) + + model = AIModel( + provider_id=uuid.UUID(data.provider_id), + model_id=data.model_id, + display_name=data.display_name, + context_window=data.context_window, + supports_tools=data.supports_tools, + supports_streaming=data.supports_streaming, + is_active=data.is_active, + config=data.config, + tenant_id=tenant_id, + ) + db.add(model) + await db.commit() + await db.refresh(model) + return model_to_response(model) + + +@router.put("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def update_model( + model_id: str, + data: AIModelUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await db.execute( + select(AIModel) + .where(AIModel.id == uuid.UUID(model_id)) + .where(AIModel.tenant_id == tenant_id) + ) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + + for field, val in data.model_dump(exclude_unset=True).items(): + setattr(model, field, val) + await db.commit() + await db.refresh(model) + return model_to_response(model) + + +@router.delete("/models/{model_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def delete_model( + model_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await db.execute( + select(AIModel) + .where(AIModel.id == uuid.UUID(model_id)) + .where(AIModel.tenant_id == tenant_id) + ) + model = result.scalar_one_or_none() + if not model: + raise HTTPException(status_code=404, detail="Model not found") + await db.delete(model) + await db.commit() + return {"ok": True} + + +# ─── Presets ─── + +@router.get("/presets", dependencies=[Depends(require_permission("ai:config"))]) +async def list_presets( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await db.execute( + select(AIPreset).where(AIPreset.tenant_id == tenant_id) + ) + presets = list(result.scalars().all()) + return [preset_to_response(p) for p in presets] + + +@router.post("/presets", dependencies=[Depends(require_permission("ai:config"))]) +async def create_preset( + data: AIPresetCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + await set_tenant_context(db, tenant_id) + + preset = AIPreset( + name=data.name, + model_id=data.model_id, + provider_id=uuid.UUID(data.provider_id) if data.provider_id else None, + temperature=data.temperature, + max_tokens=data.max_tokens, + top_p=data.top_p, + system_prompt=data.system_prompt, + config=data.config, + is_active=data.is_active, + tenant_id=tenant_id, + ) + db.add(preset) + await db.commit() + await db.refresh(preset) + return preset_to_response(preset) + + +@router.put("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def update_preset( + preset_id: str, + data: AIPresetUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id) + if not preset: + raise HTTPException(status_code=404, detail="Preset not found") + + update_data = data.model_dump(exclude_unset=True) + if "provider_id" in update_data and update_data["provider_id"]: + update_data["provider_id"] = uuid.UUID(update_data["provider_id"]) + for field, val in update_data.items(): + setattr(preset, field, val) + await db.commit() + await db.refresh(preset) + return preset_to_response(preset) + + +@router.delete("/presets/{preset_id}", dependencies=[Depends(require_permission("ai:config"))]) +async def delete_preset( + preset_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + preset = await get_preset_by_id(db, uuid.UUID(preset_id), tenant_id) + if not preset: + raise HTTPException(status_code=404, detail="Preset not found") + await db.delete(preset) + await db.commit() + return {"ok": True} + + +# ─── Agents ─── + +@router.get("/agents", dependencies=[Depends(require_permission("ai:read"))]) +async def list_agents( + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + result = await db.execute( + select(AIAgent).where(AIAgent.tenant_id == tenant_id) + ) + agents = list(result.scalars().all()) + return [agent_to_response(a) for a in agents] + + +@router.post("/agents", dependencies=[Depends(require_permission("ai:agents"))]) +async def create_agent( + data: AIAgentCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + await set_tenant_context(db, tenant_id) + + agent = AIAgent( + name=data.name, + description=data.description, + system_prompt=data.system_prompt, + preset_id=uuid.UUID(data.preset_id) if data.preset_id else None, + tool_ids=data.tool_ids, + is_active=data.is_active, + config=data.config, + tenant_id=tenant_id, + ) + db.add(agent) + await db.commit() + await db.refresh(agent) + return agent_to_response(agent) + + +@router.put("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))]) +async def update_agent( + agent_id: str, + data: AIAgentUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id) + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + + update_data = data.model_dump(exclude_unset=True) + if "preset_id" in update_data and update_data["preset_id"]: + update_data["preset_id"] = uuid.UUID(update_data["preset_id"]) + for field, val in update_data.items(): + setattr(agent, field, val) + await db.commit() + await db.refresh(agent) + return agent_to_response(agent) + + +@router.delete("/agents/{agent_id}", dependencies=[Depends(require_permission("ai:agents"))]) +async def delete_agent( + agent_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + agent = await get_agent_by_id(db, uuid.UUID(agent_id), tenant_id) + if not agent: + raise HTTPException(status_code=404, detail="Agent not found") + if agent.is_default: + raise HTTPException(status_code=400, detail="Cannot delete default agent") + await db.delete(agent) + await db.commit() + return {"ok": True} + + +# ─── Tools ─── + +@router.get("/tools", dependencies=[Depends(require_permission("ai:read"))]) +async def list_tools( + current_user: dict = Depends(get_current_user), +): + registry = get_tool_registry() + return registry.list_for_api() + + +# ─── Chat Sessions ─── + +@router.get("/sessions", dependencies=[Depends(require_permission("ai:read"))]) +async def list_sessions( + is_sidebar: bool | None = Query(None), + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + stmt = ( + select(AIChatSession) + .where(AIChatSession.tenant_id == tenant_id) + .where(AIChatSession.user_id == user_id) + ) + if is_sidebar is not None: + stmt = stmt.where(AIChatSession.is_sidebar == is_sidebar) + stmt = stmt.order_by(AIChatSession.updated_at.desc()) + result = await db.execute(stmt) + sessions = list(result.scalars().all()) + return [session_to_response(s) for s in sessions] + + +@router.post("/sessions", dependencies=[Depends(require_permission("ai:write"))]) +async def create_session( + data: ChatSessionCreate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + await set_tenant_context(db, tenant_id) + + agent_id = None + if data.agent_id: + agent_id = uuid.UUID(data.agent_id) + elif not data.is_sidebar: + # Use default agent for non-sidebar sessions + default_agent = await get_default_agent(db, tenant_id) + if default_agent: + agent_id = default_agent.id + else: + # Sidebar also gets default agent + default_agent = await get_default_agent(db, tenant_id) + if default_agent: + agent_id = default_agent.id + + session = AIChatSession( + user_id=user_id, + agent_id=agent_id, + title=data.title, + is_sidebar=data.is_sidebar, + tenant_id=tenant_id, + ) + db.add(session) + await db.commit() + await db.refresh(session) + return session_to_response(session) + + +@router.put("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))]) +async def update_session( + session_id: str, + data: ChatSessionUpdate, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + update_data = data.model_dump(exclude_unset=True) + if "agent_id" in update_data and update_data["agent_id"]: + update_data["agent_id"] = uuid.UUID(update_data["agent_id"]) + for field, val in update_data.items(): + setattr(session, field, val) + await db.commit() + await db.refresh(session) + return session_to_response(session) + + +@router.delete("/sessions/{session_id}", dependencies=[Depends(require_permission("ai:write"))]) +async def delete_session( + session_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + await db.delete(session) + await db.commit() + return {"ok": True} + + +# ─── Chat Messages ─── + +@router.get("/sessions/{session_id}/messages", dependencies=[Depends(require_permission("ai:read"))]) +async def list_messages( + session_id: str, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + messages = await get_session_messages(db, session.id, tenant_id) + return [message_to_response(m) for m in messages] + + +# ─── Streaming Chat ─── + +@router.post("/sessions/{session_id}/stream", dependencies=[Depends(require_permission("ai:write"))]) +async def chat_stream( + session_id: str, + data: ChatSendRequest, + current_user: dict = Depends(get_current_user), + db: AsyncSession = Depends(get_db), +): + tenant_id = uuid.UUID(current_user["tenant_id"]) + user_id = uuid.UUID(current_user["user_id"]) + session = await get_session_by_id(db, uuid.UUID(session_id), user_id, tenant_id) + if not session: + raise HTTPException(status_code=404, detail="Session not found") + + # Get agent + agent = None + if data.agent_id: + agent = await get_agent_by_id(db, uuid.UUID(data.agent_id), tenant_id) + elif session.agent_id: + agent = await get_agent_by_id(db, session.agent_id, tenant_id) + if not agent: + agent = await get_default_agent(db, tenant_id) + if not agent: + raise HTTPException(status_code=400, detail="No agent available") + + # Build user context for RBAC checks in tools + user_context = { + "user_id": current_user["user_id"], + "tenant_id": current_user["tenant_id"], + "role": current_user.get("role", ""), + "permissions": current_user.get("permissions", []), + "denied_permissions": current_user.get("denied_permissions", []), + "is_system_admin": current_user.get("is_system_admin", False), + "field_permissions": current_user.get("field_permissions", {}), + } + + async def event_stream(): + async for chunk in stream_chat( + db, session, agent, data.content, user_context, tenant_id + ): + yield chunk + yield "data: [DONE]\n\n" + + return StreamingResponse( + event_stream(), + media_type="text/event-stream", + headers={ + "Cache-Control": "no-cache", + "Connection": "keep-alive", + "X-Accel-Buffering": "no", + }, + ) diff --git a/app/plugins/builtins/ai_assistant/schemas.py b/app/plugins/builtins/ai_assistant/schemas.py new file mode 100644 index 0000000..38d861e --- /dev/null +++ b/app/plugins/builtins/ai_assistant/schemas.py @@ -0,0 +1,214 @@ +"""Pydantic schemas for the AI Assistant plugin.""" + +from __future__ import annotations + +from datetime import datetime +from typing import Any + +from pydantic import BaseModel, ConfigDict, Field + + +# ─── Providers ─── + +class AIProviderCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + provider_type: str = Field(..., min_length=1, max_length=50) + api_key: str = Field(default="", max_length=2000) + base_url: str = Field(default="", max_length=500) + is_active: bool = True + is_default: bool = False + config: dict[str, Any] = Field(default_factory=dict) + + +class AIProviderUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + provider_type: str | None = Field(None, min_length=1, max_length=50) + api_key: str | None = Field(None, max_length=2000) + base_url: str | None = Field(None, max_length=500) + is_active: bool | None = None + is_default: bool | None = None + config: dict[str, Any] | None = None + + +class AIProviderResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + name: str + provider_type: str + api_key: str # masked in routes + base_url: str + is_active: bool + is_default: bool + config: dict[str, Any] + created_at: datetime | None = None + updated_at: datetime | None = None + + +# ─── Models ─── + +class AIModelCreate(BaseModel): + provider_id: str + model_id: str = Field(..., min_length=1, max_length=200) + display_name: str = Field(..., min_length=1, max_length=200) + context_window: int = Field(default=4096, ge=1) + supports_tools: bool = False + supports_streaming: bool = True + is_active: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + +class AIModelUpdate(BaseModel): + model_id: str | None = Field(None, min_length=1, max_length=200) + display_name: str | None = Field(None, min_length=1, max_length=200) + context_window: int | None = Field(None, ge=1) + supports_tools: bool | None = None + supports_streaming: bool | None = None + is_active: bool | None = None + config: dict[str, Any] | None = None + + +class AIModelResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + provider_id: str + model_id: str + display_name: str + context_window: int + supports_tools: bool + supports_streaming: bool + is_active: bool + config: dict[str, Any] + + +# ─── Presets ─── + +class AIPresetCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + model_id: str = Field(..., min_length=1, max_length=200) + provider_id: str | None = None + temperature: float = Field(default=0.7, ge=0.0, le=2.0) + max_tokens: int = Field(default=2048, ge=1, le=128000) + top_p: float = Field(default=1.0, ge=0.0, le=1.0) + system_prompt: str = "" + config: dict[str, Any] = Field(default_factory=dict) + is_active: bool = True + + +class AIPresetUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + model_id: str | None = Field(None, min_length=1, max_length=200) + provider_id: str | None = None + temperature: float | None = Field(None, ge=0.0, le=2.0) + max_tokens: int | None = Field(None, ge=1, le=128000) + top_p: float | None = Field(None, ge=0.0, le=1.0) + system_prompt: str | None = None + config: dict[str, Any] | None = None + is_active: bool | None = None + + +class AIPresetResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + name: str + model_id: str + provider_id: str | None = None + temperature: float + max_tokens: int + top_p: float + system_prompt: str + config: dict[str, Any] + is_active: bool + + +# ─── Agents ─── + +class AIAgentCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100) + description: str = "" + system_prompt: str = "" + preset_id: str | None = None + tool_ids: list[str] = Field(default_factory=list) + is_active: bool = True + config: dict[str, Any] = Field(default_factory=dict) + + +class AIAgentUpdate(BaseModel): + name: str | None = Field(None, min_length=1, max_length=100) + description: str | None = None + system_prompt: str | None = None + preset_id: str | None = None + tool_ids: list[str] | None = None + is_active: bool | None = None + config: dict[str, Any] | None = None + + +class AIAgentResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + name: str + description: str + system_prompt: str + preset_id: str | None = None + tool_ids: list[str] + is_default: bool + is_active: bool + config: dict[str, Any] + created_at: datetime | None = None + updated_at: datetime | None = None + + +# ─── Chat Sessions ─── + +class ChatSessionCreate(BaseModel): + title: str = Field(default="Neuer Chat", max_length=255) + agent_id: str | None = None + is_sidebar: bool = False + + +class ChatSessionUpdate(BaseModel): + title: str | None = Field(None, max_length=255) + is_pinned: bool | None = None + agent_id: str | None = None + + +class ChatSessionResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + user_id: str + agent_id: str | None = None + title: str + is_pinned: bool + is_sidebar: bool + created_at: datetime | None = None + updated_at: datetime | None = None + + +# ─── Chat Messages ─── + +class ChatMessageResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + id: str + session_id: str + role: str + content: str + tool_calls: list[dict[str, Any]] | None = None + tool_results: list[dict[str, Any]] | None = None + tokens: int + model_used: str + created_at: datetime | None = None + + +class ChatSendRequest(BaseModel): + content: str = Field(..., min_length=1) + agent_id: str | None = None # override session agent + + +# ─── Tools ─── + +class AIToolResponse(BaseModel): + name: str + description: str + parameters: dict[str, Any] + plugin_name: str + required_permission: str | None = None + category: str diff --git a/app/plugins/builtins/ai_assistant/services.py b/app/plugins/builtins/ai_assistant/services.py new file mode 100644 index 0000000..bb8aeb6 --- /dev/null +++ b/app/plugins/builtins/ai_assistant/services.py @@ -0,0 +1,509 @@ +"""Business logic for the AI Assistant plugin. + +Uses LiteLLM for multi-provider LLM calls and PydanticAI for the agent loop. +Tools from the global ToolRegistry are wrapped as PydanticAI tools with +RBAC permission checks. +""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any, AsyncGenerator + +import litellm +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.core.permissions import check_permission +from app.plugins.builtins.ai_assistant.models import ( + AIAgent, + AIChatMessage, + AIChatSession, + AIModel, + AIPreset, + AIProvider, +) +from app.plugins.builtins.ai_assistant.tool_registry import ( + AITool, + get_tool_registry, +) + +logger = logging.getLogger(__name__) + +# Suppress litellm verbose logging +litellm.suppress_debug_info = True + + +# ─── Helpers ─── + +def mask_api_key(key: str) -> str: + """Mask API key for display: show first 8 and last 4 chars.""" + if not key or len(key) <= 12: + return "***" + return f"{key[:8]}...{key[-4:]}" + + +def provider_to_response(provider: AIProvider) -> dict[str, Any]: + return { + "id": str(provider.id), + "name": provider.name, + "provider_type": provider.provider_type, + "api_key": mask_api_key(provider.api_key), + "base_url": provider.base_url, + "is_active": provider.is_active, + "is_default": provider.is_default, + "config": provider.config or {}, + "created_at": provider.created_at.isoformat() if provider.created_at else None, + "updated_at": provider.updated_at.isoformat() if provider.updated_at else None, + } + + +def model_to_response(model: AIModel) -> dict[str, Any]: + return { + "id": str(model.id), + "provider_id": str(model.provider_id), + "model_id": model.model_id, + "display_name": model.display_name, + "context_window": model.context_window, + "supports_tools": model.supports_tools, + "supports_streaming": model.supports_streaming, + "is_active": model.is_active, + "config": model.config or {}, + } + + +def preset_to_response(preset: AIPreset) -> dict[str, Any]: + return { + "id": str(preset.id), + "name": preset.name, + "model_id": preset.model_id, + "provider_id": str(preset.provider_id) if preset.provider_id else None, + "temperature": preset.temperature, + "max_tokens": preset.max_tokens, + "top_p": preset.top_p, + "system_prompt": preset.system_prompt, + "config": preset.config or {}, + "is_active": preset.is_active, + } + + +def agent_to_response(agent: AIAgent) -> dict[str, Any]: + return { + "id": str(agent.id), + "name": agent.name, + "description": agent.description, + "system_prompt": agent.system_prompt, + "preset_id": str(agent.preset_id) if agent.preset_id else None, + "tool_ids": agent.tool_ids or [], + "is_default": agent.is_default, + "is_active": agent.is_active, + "config": agent.config or {}, + "created_at": agent.created_at.isoformat() if agent.created_at else None, + "updated_at": agent.updated_at.isoformat() if agent.updated_at else None, + } + + +def session_to_response(session: AIChatSession) -> dict[str, Any]: + return { + "id": str(session.id), + "user_id": str(session.user_id), + "agent_id": str(session.agent_id) if session.agent_id else None, + "title": session.title, + "is_pinned": session.is_pinned, + "is_sidebar": session.is_sidebar, + "created_at": session.created_at.isoformat() if session.created_at else None, + "updated_at": session.updated_at.isoformat() if session.updated_at else None, + } + + +def message_to_response(msg: AIChatMessage) -> dict[str, Any]: + return { + "id": str(msg.id), + "session_id": str(msg.session_id), + "role": msg.role, + "content": msg.content, + "tool_calls": msg.tool_calls if msg.tool_calls else None, + "tool_results": msg.tool_results if msg.tool_results else None, + "tokens": msg.tokens, + "model_used": msg.model_used, + "created_at": msg.created_at.isoformat() if msg.created_at else None, + } + + +# ─── Provider/Model/Preset/Agent CRUD ─── + +async def get_default_provider(db: AsyncSession, tenant_id: uuid.UUID) -> AIProvider | None: + """Get the default provider for a tenant.""" + result = await db.execute( + select(AIProvider) + .where(AIProvider.tenant_id == tenant_id) + .where(AIProvider.is_default == True) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_provider_by_id(db: AsyncSession, provider_id: uuid.UUID, tenant_id: uuid.UUID) -> AIProvider | None: + result = await db.execute( + select(AIProvider) + .where(AIProvider.id == provider_id) + .where(AIProvider.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_preset_by_id(db: AsyncSession, preset_id: uuid.UUID, tenant_id: uuid.UUID) -> AIPreset | None: + result = await db.execute( + select(AIPreset) + .where(AIPreset.id == preset_id) + .where(AIPreset.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_agent_by_id(db: AsyncSession, agent_id: uuid.UUID, tenant_id: uuid.UUID) -> AIAgent | None: + result = await db.execute( + select(AIAgent) + .where(AIAgent.id == agent_id) + .where(AIAgent.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_default_agent(db: AsyncSession, tenant_id: uuid.UUID) -> AIAgent | None: + result = await db.execute( + select(AIAgent) + .where(AIAgent.tenant_id == tenant_id) + .where(AIAgent.is_default == True) + .limit(1) + ) + return result.scalar_one_or_none() + + +# ─── Session/Message helpers ─── + +async def get_session_by_id( + db: AsyncSession, session_id: uuid.UUID, user_id: uuid.UUID, tenant_id: uuid.UUID +) -> AIChatSession | None: + result = await db.execute( + select(AIChatSession) + .where(AIChatSession.id == session_id) + .where(AIChatSession.user_id == user_id) + .where(AIChatSession.tenant_id == tenant_id) + .limit(1) + ) + return result.scalar_one_or_none() + + +async def get_session_messages( + db: AsyncSession, session_id: uuid.UUID, tenant_id: uuid.UUID +) -> list[AIChatMessage]: + result = await db.execute( + select(AIChatMessage) + .where(AIChatMessage.session_id == session_id) + .where(AIChatMessage.tenant_id == tenant_id) + .order_by(AIChatMessage.created_at.asc()) + ) + return list(result.scalars().all()) + + +async def save_message( + db: AsyncSession, + session_id: uuid.UUID, + role: str, + content: str, + tenant_id: uuid.UUID, + tool_calls: list | None = None, + tool_results: list | None = None, + tokens: int = 0, + model_used: str = "", +) -> AIChatMessage: + msg = AIChatMessage( + session_id=session_id, + role=role, + content=content, + tool_calls=tool_calls, + tool_results=tool_results, + tokens=tokens, + model_used=model_used, + tenant_id=tenant_id, + ) + db.add(msg) + await db.flush() + return msg + + +# ─── LLM Chat with Tool Loop ─── + +async def build_litellm_params( + db: AsyncSession, + agent: AIAgent, + messages: list[dict[str, Any]], + tenant_id: uuid.UUID, +) -> dict[str, Any]: + """Build LiteLLM completion parameters from agent + preset.""" + # Get preset + preset: AIPreset | None = None + if agent.preset_id: + preset = await get_preset_by_id(db, agent.preset_id, tenant_id) + + model_id = "gpt-4o-mini" # fallback + temperature = 0.7 + max_tokens = 2048 + top_p = 1.0 + system_prompt = agent.system_prompt or "" + + if preset: + model_id = preset.model_id + temperature = preset.temperature + max_tokens = preset.max_tokens + top_p = preset.top_p + if preset.system_prompt and not system_prompt: + system_prompt = preset.system_prompt + + # Get provider for API key + provider: AIProvider | None = None + if preset and preset.provider_id: + provider = await get_provider_by_id(db, preset.provider_id, tenant_id) + if not provider: + provider = await get_default_provider(db, tenant_id) + + # Build litellm model string with provider prefix + litellm_model = model_id + if provider and provider.provider_type != "openai": + litellm_model = f"{provider.provider_type}/{model_id}" + + # Build messages with system prompt + litellm_messages = [] + if system_prompt: + litellm_messages.append({"role": "system", "content": system_prompt}) + litellm_messages.extend(messages) + + params: dict[str, Any] = { + "model": litellm_model, + "messages": litellm_messages, + "temperature": temperature, + "max_tokens": max_tokens, + "top_p": top_p, + "stream": True, + } + + # Set API key from provider + if provider and provider.api_key: + if provider.provider_type == "openai": + params["api_key"] = provider.api_key + elif provider.provider_type == "anthropic": + params["api_key"] = provider.api_key + else: + params["api_key"] = provider.api_key + + if provider and provider.base_url: + params["api_base"] = provider.base_url + + return params, model_id + + +async def execute_tool_call( + tool: AITool, + arguments: dict[str, Any], + user_context: dict[str, Any], +) -> str: + """Execute a tool call with RBAC permission check.""" + # Check RBAC permission if tool requires one + if tool.required_permission: + if not check_permission(user_context, tool.required_permission): + return f"Error: Permission '{tool.required_permission}' required for tool '{tool.name}'" + + try: + result = await tool.handler(arguments=arguments, context=user_context) + return result + except Exception as exc: + logger.warning("Tool '%s' execution error: %s", tool.name, exc) + return f"Error executing tool '{tool.name}': {exc}" + + +async def stream_chat( + db: AsyncSession, + session: AIChatSession, + agent: AIAgent, + user_message: str, + user_context: dict[str, Any], + tenant_id: uuid.UUID, +) -> AsyncGenerator[str, None]: + """Stream chat response via SSE with tool-calling loop. + + Yields SSE-formatted strings. + """ + # Load conversation history + history = await get_session_messages(db, session.id, tenant_id) + messages: list[dict[str, Any]] = [] + for msg in history: + messages.append({"role": msg.role, "content": msg.content}) + + # Add user message + messages.append({"role": "user", "content": user_message}) + await save_message(db, session.id, "user", user_message, tenant_id) + + # Get agent tools + registry = get_tool_registry() + tools = registry.get_by_names(agent.tool_ids or []) + tool_schemas = [t.to_openai_schema() for t in tools] if tools else None + + # Build LLM params + params, model_id = await build_litellm_params(db, agent, messages, tenant_id) + + # Agent loop: LLM → tool calls → execute → feed back → repeat + max_iterations = 5 + for iteration in range(max_iterations): + # Add tools to params if available + if tool_schemas: + params["tools"] = tool_schemas + + # Stream LLM response + collected_content = "" + collected_tool_calls: list[dict[str, Any]] = [] + try: + response = await litellm.acompletion(**params) + async for chunk in response: + delta = chunk.choices[0].delta + if delta.content: + collected_content += delta.content + yield f"data: {json.dumps({'type': 'token', 'content': delta.content})}\n\n" + if delta.tool_calls: + for tc in delta.tool_calls: + idx = tc.index + while len(collected_tool_calls) <= idx: + collected_tool_calls.append({"id": "", "function": {"name": "", "arguments": ""}}) + if tc.id: + collected_tool_calls[idx]["id"] = tc.id + if tc.function: + if tc.function.name: + collected_tool_calls[idx]["function"]["name"] += tc.function.name + if tc.function.arguments: + collected_tool_calls[idx]["function"]["arguments"] += tc.function.arguments + except Exception as exc: + logger.error("LLM streaming error: %s", exc) + yield f"data: {json.dumps({'type': 'error', 'content': str(exc)})}\n\n" + await save_message(db, session.id, "assistant", f"Error: {exc}", tenant_id, model_used=model_id) + await db.commit() + return + + # If tool calls, execute them and continue loop + if collected_tool_calls: + # Save assistant message with tool calls + await save_message( + db, session.id, "assistant", collected_content, tenant_id, + tool_calls=collected_tool_calls, model_used=model_id, + ) + yield f"data: {json.dumps({'type': 'tool_calls', 'tools': [tc['function']['name'] for tc in collected_tool_calls]})}\n\n" + + # Execute each tool call + for tc in collected_tool_calls: + tool_name = tc["function"]["name"] + try: + tool_args = json.loads(tc["function"]["arguments"]) + except json.JSONDecodeError: + tool_args = {} + + tool = registry.get(tool_name) + if tool is None: + result = f"Tool '{tool_name}' not found" + else: + result = await execute_tool_call(tool, tool_args, user_context) + + yield f"data: {json.dumps({'type': 'tool_result', 'tool': tool_name, 'result': result[:500]})}\n\n" + + # Add tool result to messages for next iteration + messages.append({ + "role": "assistant", + "content": collected_content, + "tool_calls": collected_tool_calls, + }) + messages.append({ + "role": "tool", + "tool_call_id": tc["id"], + "name": tool_name, + "content": result, + }) + + # Update params with new messages for next iteration + params, model_id = await build_litellm_params(db, agent, messages, tenant_id) + continue + + # No tool calls — final response + await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id) + await db.commit() + yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n" + return + + # Max iterations reached + await save_message(db, session.id, "assistant", collected_content, tenant_id, model_used=model_id) + await db.commit() + yield f"data: {json.dumps({'type': 'done', 'content': collected_content})}\n\n" + + +# ─── Seed Defaults ─── + +async def seed_defaults(db: AsyncSession) -> None: + """Seed default provider, preset, and agent for existing tenants.""" + from app.models import Tenant + + result = await db.execute(select(Tenant)) + tenants = list(result.scalars().all()) + + for tenant in tenants: + # Check if default provider already exists + existing = await get_default_provider(db, tenant.id) + if existing: + continue + + # Create default OpenAI provider (no key, user must configure) + provider = AIProvider( + name="OpenAI", + provider_type="openai", + api_key="", + base_url="", + is_active=True, + is_default=True, + config={}, + tenant_id=tenant.id, + ) + db.add(provider) + await db.flush() + + # Create default preset + preset = AIPreset( + name="Standard", + model_id="gpt-4o-mini", + provider_id=provider.id, + temperature=0.7, + max_tokens=2048, + top_p=1.0, + system_prompt="Du bist ein hilfreicher KI-Assistent für ein CRM-System.", + is_active=True, + tenant_id=tenant.id, + ) + db.add(preset) + await db.flush() + + # Create default agent + agent = AIAgent( + name="Standard Assistent", + description="Allgemeiner KI-Assistent", + system_prompt="Du bist ein hilfreicher KI-Assistent. Antworte präzise und hilfreich.", + preset_id=preset.id, + tool_ids=[], + is_default=True, + is_active=True, + config={}, + tenant_id=tenant.id, + ) + db.add(agent) + + await db.commit() + logger.info("AI Assistant: default providers, presets, and agents seeded") diff --git a/app/plugins/builtins/ai_assistant/tool_registry.py b/app/plugins/builtins/ai_assistant/tool_registry.py new file mode 100644 index 0000000..d143bfe --- /dev/null +++ b/app/plugins/builtins/ai_assistant/tool_registry.py @@ -0,0 +1,121 @@ +"""Global tool registry for AI Assistant plugin tools. + +Plugins can register tools that AI agents can call during chat sessions. +Each tool declares a name, description, JSON schema for parameters, +and an async handler. Tools can optionally require specific RBAC permissions. +""" + +from __future__ import annotations + +import logging +from dataclasses import dataclass, field +from typing import Any, Awaitable, Callable, Protocol + +logger = logging.getLogger(__name__) + + +class ToolHandler(Protocol): + async def __call__( + self, + arguments: dict[str, Any], + context: dict[str, Any], + ) -> str: ... + + +@dataclass +class AITool: + """Represents a tool that an AI agent can call.""" + + name: str + description: str + parameters: dict[str, Any] # JSON Schema for parameters + handler: ToolHandler + plugin_name: str = "" + required_permission: str | None = None # e.g. "mail:send" + category: str = "general" + + def to_openai_schema(self) -> dict[str, Any]: + """Convert to OpenAI function-calling tool schema.""" + return { + "type": "function", + "function": { + "name": self.name, + "description": self.description, + "parameters": self.parameters, + }, + } + + +class ToolRegistry: + """Singleton registry for AI tools.""" + + _instance: ToolRegistry | None = None + + def __new__(cls) -> ToolRegistry: + if cls._instance is None: + cls._instance = super().__new__(cls) + cls._instance._tools: dict[str, AITool] = {} + return cls._instance + + def register( + self, + name: str, + description: str, + parameters: dict[str, Any], + handler: ToolHandler, + plugin_name: str = "", + required_permission: str | None = None, + category: str = "general", + ) -> None: + """Register a tool.""" + tool = AITool( + name=name, + description=description, + parameters=parameters, + handler=handler, + plugin_name=plugin_name, + required_permission=required_permission, + category=category, + ) + self._tools[name] = tool + logger.info("AI tool registered: %s (plugin=%s)", name, plugin_name) + + def unregister(self, name: str) -> None: + """Unregister a tool by name.""" + self._tools.pop(name, None) + + def unregister_plugin(self, plugin_name: str) -> None: + """Unregister all tools from a plugin.""" + to_remove = [ + name for name, tool in self._tools.items() if tool.plugin_name == plugin_name + ] + for name in to_remove: + self._tools.pop(name, None) + + def get(self, name: str) -> AITool | None: + return self._tools.get(name) + + def get_all(self) -> list[AITool]: + return list(self._tools.values()) + + def get_by_names(self, names: list[str]) -> list[AITool]: + return [self._tools[name] for name in names if name in self._tools] + + def list_for_api(self) -> list[dict[str, Any]]: + """Return tool list for API response.""" + return [ + { + "name": tool.name, + "description": tool.description, + "parameters": tool.parameters, + "plugin_name": tool.plugin_name, + "required_permission": tool.required_permission, + "category": tool.category, + } + for tool in self._tools.values() + ] + + +def get_tool_registry() -> ToolRegistry: + """Get the global tool registry singleton.""" + return ToolRegistry() diff --git a/requirements.txt b/requirements.txt index ce42f03..7358ede 100644 --- a/requirements.txt +++ b/requirements.txt @@ -45,3 +45,4 @@ openpyxl>=3.1 # Monitoring prometheus-client>=0.20 structlog>=24.0 +litellm