feat: agent platform initial commit
- FastAPI + HTMX UI: dashboard, agents CRUD, chat, audit, tools - SQLite schema: agents, conversations, messages, audit, sessions - Code-agent sync (agents/*.py -> DB on startup) - MCP client with health check - Token auth (Bearer + session) - LiteLLM integration via llm.py - Docker Compose: agent-platform + mcp-tools services - Smoke tests pass: /health 200, /api/agents 200, / 401
This commit is contained in:
@@ -0,0 +1,20 @@
|
||||
FROM python:3.12-slim
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
gcc \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY pyproject.toml .
|
||||
RUN pip install --no-cache-dir --upgrade pip && \
|
||||
pip install --no-cache-dir .
|
||||
|
||||
COPY . .
|
||||
|
||||
RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app
|
||||
USER appuser
|
||||
|
||||
EXPOSE 8000
|
||||
|
||||
CMD ["uvicorn", "api:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||
@@ -0,0 +1,183 @@
|
||||
"""Agent-Runner: führt einen Agent-Turn aus.
|
||||
|
||||
Agents leben in der DB (Tabelle `agents`).
|
||||
Beim Start werden Code-Agents aus agents/*.py in DB gesynct (source='code').
|
||||
"""
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
from typing import Dict, Any, List
|
||||
import aiosqlite
|
||||
|
||||
from db import get_agent, add_message, get_messages, touch_conversation
|
||||
from llm import chat
|
||||
from mcp_client import get_mcp_client
|
||||
|
||||
|
||||
logger = logging.getLogger("agent")
|
||||
|
||||
|
||||
async def sync_code_agents(db: aiosqlite.Connection):
|
||||
"""Lädt Code-Agents aus agents/*.py und synct sie in die DB."""
|
||||
from agents import discover_agents
|
||||
for instance in discover_agents().values():
|
||||
cfg = instance.config
|
||||
existing = await get_agent(db, cfg.id)
|
||||
if existing:
|
||||
# Update nur Code-Felder, behalte User-Overrides (enabled, custom)
|
||||
merged = {
|
||||
"id": cfg.id,
|
||||
"name": cfg.name,
|
||||
"description": cfg.description,
|
||||
"system_prompt": cfg.system_prompt,
|
||||
"allowed_tools": cfg.allowed_tools,
|
||||
"model": cfg.model,
|
||||
"temperature": cfg.temperature,
|
||||
"max_tokens": cfg.max_tokens,
|
||||
"enabled": existing.get("enabled", cfg.enabled),
|
||||
}
|
||||
await db.execute(
|
||||
"""UPDATE agents SET
|
||||
name=?, description=?, system_prompt=?, allowed_tools=?,
|
||||
model=?, temperature=?, max_tokens=?, updated_at=CURRENT_TIMESTAMP
|
||||
WHERE id=?""",
|
||||
(
|
||||
merged["name"], merged["description"], merged["system_prompt"],
|
||||
json.dumps(merged["allowed_tools"]),
|
||||
merged["model"], merged["temperature"], merged["max_tokens"],
|
||||
cfg.id,
|
||||
)
|
||||
)
|
||||
else:
|
||||
await db.execute(
|
||||
"""INSERT INTO agents (id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'code')""",
|
||||
(
|
||||
cfg.id, cfg.name, cfg.description, cfg.system_prompt,
|
||||
json.dumps(cfg.allowed_tools),
|
||||
cfg.model, cfg.temperature, cfg.max_tokens,
|
||||
int(cfg.enabled),
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def run_agent_turn(
|
||||
db: aiosqlite.Connection,
|
||||
agent_id: str,
|
||||
user_id: str,
|
||||
user_message: str,
|
||||
conversation_id: str,
|
||||
) -> Dict[str, Any]:
|
||||
"""Führt einen Agent-Turn aus: User-Message → LLM → optional Tool-Calls → Antwort."""
|
||||
agent = await get_agent(db, agent_id)
|
||||
if not agent:
|
||||
raise ValueError(f"Agent '{agent_id}' nicht gefunden")
|
||||
if not agent["enabled"]:
|
||||
raise ValueError(f"Agent '{agent_id}' ist deaktiviert")
|
||||
|
||||
# User-Message speichern
|
||||
await add_message(db, conversation_id, "user", user_message)
|
||||
|
||||
# History laden (letzte 20 Messages)
|
||||
history = await get_messages(db, conversation_id, limit=20)
|
||||
messages: List[dict] = [{"role": "system", "content": agent["system_prompt"]}]
|
||||
for row in history[:-1]: # ohne die gerade gespeicherte user-Message
|
||||
role, content, tool_calls_json, _, _ = row
|
||||
if role == "tool":
|
||||
continue
|
||||
msg = {"role": role, "content": content or ""}
|
||||
if tool_calls_json:
|
||||
try:
|
||||
msg["tool_calls"] = json.loads(tool_calls_json)
|
||||
except Exception:
|
||||
pass
|
||||
messages.append(msg)
|
||||
|
||||
# MCP-Tools laden (gefiltert nach allowed_tools)
|
||||
mcp_client = get_mcp_client()
|
||||
tools = []
|
||||
try:
|
||||
all_tools = await mcp_client.get_tools_for_llm()
|
||||
if agent["allowed_tools"]:
|
||||
tools = [t for t in all_tools if t.get("function", {}).get("name") in agent["allowed_tools"]]
|
||||
else:
|
||||
tools = all_tools
|
||||
except Exception as e:
|
||||
logger.warning(f"MCP-Tools konnten nicht geladen werden: {e}")
|
||||
|
||||
# LLM-Loop
|
||||
MAX_ITER = 10
|
||||
iterations = 0
|
||||
total_input = 0
|
||||
total_output = 0
|
||||
final_content = ""
|
||||
|
||||
while iterations < MAX_ITER:
|
||||
iterations += 1
|
||||
kwargs = {}
|
||||
if agent["model"]:
|
||||
kwargs["model"] = agent["model"]
|
||||
|
||||
start = time.time()
|
||||
response = await chat(
|
||||
messages=messages,
|
||||
tools=tools or None,
|
||||
temperature=agent["temperature"],
|
||||
max_tokens=agent["max_tokens"],
|
||||
**kwargs,
|
||||
)
|
||||
duration_ms = int((time.time() - start) * 1000)
|
||||
total_input += response.input_tokens
|
||||
total_output += response.output_tokens
|
||||
|
||||
assistant_msg = {"role": "assistant", "content": response.content}
|
||||
if response.tool_calls:
|
||||
assistant_msg["tool_calls"] = [
|
||||
{
|
||||
"id": tc["id"],
|
||||
"type": "function",
|
||||
"function": {"name": tc["name"], "arguments": tc["arguments"]},
|
||||
}
|
||||
for tc in response.tool_calls
|
||||
]
|
||||
messages.append(assistant_msg)
|
||||
|
||||
if not response.tool_calls:
|
||||
final_content = response.content
|
||||
break
|
||||
|
||||
for tc in response.tool_calls:
|
||||
if agent["allowed_tools"] and tc["name"] not in agent["allowed_tools"]:
|
||||
messages.append({
|
||||
"role": "tool",
|
||||
"tool_call_id": tc["id"],
|
||||
"content": f"Tool '{tc['name']}' ist nicht erlaubt",
|
||||
})
|
||||
continue
|
||||
try:
|
||||
args = json.loads(tc["arguments"]) if isinstance(tc["arguments"], str) else tc["arguments"]
|
||||
except Exception:
|
||||
args = {}
|
||||
try:
|
||||
result = await mcp_client.call_tool(tc["name"], args)
|
||||
content_str = json.dumps(result, default=str)[:8000]
|
||||
except Exception as e:
|
||||
content_str = f"Tool-Fehler: {e}"
|
||||
messages.append({"role": "tool", "tool_call_id": tc["id"], "content": content_str})
|
||||
|
||||
# Assistant-Message speichern
|
||||
await add_message(
|
||||
db, conversation_id, "assistant", final_content,
|
||||
tool_calls=response.tool_calls if (response and response.tool_calls) else None,
|
||||
token_count=total_input + total_output,
|
||||
)
|
||||
await touch_conversation(db, conversation_id)
|
||||
|
||||
return {
|
||||
"content": final_content,
|
||||
"iterations": iterations,
|
||||
"input_tokens": total_input,
|
||||
"output_tokens": total_output,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Agent-Loader: scannt agents/ Ordner und lädt alle BaseAgent-Subklassen."""
|
||||
import importlib
|
||||
import inspect
|
||||
import logging
|
||||
from pathlib import Path
|
||||
from typing import Dict, List
|
||||
|
||||
from agents.base import BaseAgent, AgentConfig
|
||||
|
||||
|
||||
logger = logging.getLogger("agents.loader")
|
||||
|
||||
|
||||
def discover_agents() -> Dict[str, BaseAgent]:
|
||||
"""Scannt das agents/ Verzeichnis und gibt alle Agent-Instanzen zurück."""
|
||||
agents: Dict[str, BaseAgent] = {}
|
||||
agents_dir = Path(__file__).parent
|
||||
|
||||
for py_file in agents_dir.glob("*.py"):
|
||||
if py_file.name.startswith("_") or py_file.name in ("base.py", "__init__.py"):
|
||||
continue
|
||||
module_name = f"agents.{py_file.stem}"
|
||||
try:
|
||||
module = importlib.import_module(module_name)
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte {module_name} nicht laden: {e}")
|
||||
continue
|
||||
|
||||
for name, obj in inspect.getmembers(module, inspect.isclass):
|
||||
if obj is BaseAgent:
|
||||
continue
|
||||
if issubclass(obj, BaseAgent) and obj.__module__ == module_name:
|
||||
try:
|
||||
instance = obj()
|
||||
if hasattr(instance, "config"):
|
||||
agents[instance.config.id] = instance
|
||||
logger.info(f"Agent geladen: {instance.config.id} ({instance.config.name})")
|
||||
except Exception as e:
|
||||
logger.warning(f"Konnte Agent {name} aus {module_name} nicht instanziieren: {e}")
|
||||
|
||||
return agents
|
||||
|
||||
|
||||
def get_agent(agent_id: str) -> BaseAgent:
|
||||
"""Holt einen Agent by ID."""
|
||||
agents = discover_agents()
|
||||
if agent_id not in agents:
|
||||
raise KeyError(f"Agent '{agent_id}' nicht gefunden. Verfügbar: {list(agents.keys())}")
|
||||
return agents[agent_id]
|
||||
|
||||
|
||||
def list_agents() -> List[AgentConfig]:
|
||||
"""Gibt Configs aller Agents zurück."""
|
||||
return [a.config for a in discover_agents().values()]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Pydantic-Schema für hardcoded Agents.
|
||||
|
||||
Beispiel:
|
||||
# agents/recherche.py
|
||||
from agents.base import BaseAgent, AgentConfig
|
||||
|
||||
class RechercheAgent(BaseAgent):
|
||||
config = AgentConfig(
|
||||
id="recherche",
|
||||
name="Recherche-Assistent",
|
||||
description="Recherchiert Themen und fasst zusammen.",
|
||||
system_prompt="Du bist ein Recherche-Assistent...",
|
||||
allowed_tools=["http_get"],
|
||||
)
|
||||
"""
|
||||
from abc import ABC
|
||||
from typing import List, Optional
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class AgentConfig(BaseModel):
|
||||
"""Type-safe Definition eines hardcoded Agents."""
|
||||
id: str = Field(..., pattern=r'^[a-z0-9_-]+$', min_length=1, max_length=64)
|
||||
name: str = Field(..., min_length=1)
|
||||
description: str = ""
|
||||
system_prompt: str = Field(..., min_length=1)
|
||||
allowed_tools: List[str] = []
|
||||
enabled: bool = True
|
||||
|
||||
model: Optional[str] = None
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
||||
|
||||
|
||||
class BaseAgent(ABC):
|
||||
"""Marker-Base für Code-Agents. Setze `config` als Class-Variable."""
|
||||
config: AgentConfig
|
||||
@@ -0,0 +1,26 @@
|
||||
"""Beispiel-Agent: zeigt wie ein Agent definiert wird."""
|
||||
from agents.base import BaseAgent, AgentConfig
|
||||
|
||||
|
||||
class ExampleAgent(BaseAgent):
|
||||
"""Generischer Beispiel-Agent mit Tool-Zugriff."""
|
||||
|
||||
config = AgentConfig(
|
||||
id="example",
|
||||
name="Beispiel-Agent",
|
||||
description="Demonstriert einen hardcoded Agent mit Tool-Zugriff.",
|
||||
system_prompt="""Du bist ein hilfreicher KI-Assistent.
|
||||
|
||||
Du hast Zugriff auf folgende Tools:
|
||||
- echo: Gibt einen Text zurück
|
||||
- get_time: Aktuelle Zeit
|
||||
- calculate: Mathematische Berechnungen
|
||||
- http_get: HTTP-Requests
|
||||
- list_env: Umgebungsvariablen
|
||||
- json_format: JSON formatieren
|
||||
|
||||
Antworte immer auf Deutsch, präzise und freundlich.
|
||||
Wenn du etwas nicht weißt, sage es ehrlich.""",
|
||||
allowed_tools=["echo", "get_time", "calculate", "http_get", "list_env", "json_format"],
|
||||
enabled=True,
|
||||
)
|
||||
@@ -0,0 +1,352 @@
|
||||
"""FastAPI: HTTP-API + UI für die Agent Platform.
|
||||
|
||||
Agents leben in der DB. CRUD über UI und API.
|
||||
"""
|
||||
import json
|
||||
import logging
|
||||
from typing import Optional, Dict, Any, List
|
||||
from contextlib import asynccontextmanager
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import FastAPI, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from pydantic import BaseModel, Field
|
||||
import aiosqlite
|
||||
|
||||
from config import settings
|
||||
from db import (
|
||||
init_db,
|
||||
list_agents, get_agent, upsert_agent, delete_agent, set_agent_enabled,
|
||||
create_conversation, list_conversations, get_messages,
|
||||
list_audit, log_audit,
|
||||
)
|
||||
from auth import get_current_user, require_admin
|
||||
from mcp_client import get_mcp_client, close_mcp_client
|
||||
from agent import sync_code_agents, run_agent_turn
|
||||
|
||||
|
||||
logging.basicConfig(
|
||||
level=getattr(logging, settings.LOG_LEVEL.upper(), logging.INFO),
|
||||
format="%(asctime)s [%(levelname)s] %(name)s: %(message)s",
|
||||
)
|
||||
logger = logging.getLogger("api")
|
||||
|
||||
|
||||
# === Pydantic Models ===
|
||||
|
||||
class AgentCreate(BaseModel):
|
||||
id: str = Field(..., pattern=r'^[a-z0-9_-]+$', min_length=1, max_length=64)
|
||||
name: str = Field(..., min_length=1)
|
||||
description: str = ""
|
||||
system_prompt: str = Field(..., min_length=1)
|
||||
allowed_tools: List[str] = []
|
||||
model: Optional[str] = None
|
||||
temperature: float = Field(default=0.7, ge=0.0, le=2.0)
|
||||
max_tokens: int = Field(default=2000, ge=100, le=32000)
|
||||
enabled: bool = True
|
||||
|
||||
|
||||
class AgentUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
system_prompt: Optional[str] = None
|
||||
allowed_tools: Optional[List[str]] = None
|
||||
model: Optional[str] = None
|
||||
temperature: Optional[float] = Field(default=None, ge=0.0, le=2.0)
|
||||
max_tokens: Optional[int] = Field(default=None, ge=100, le=32000)
|
||||
enabled: Optional[bool] = None
|
||||
|
||||
|
||||
class ChatMessage(BaseModel):
|
||||
message: str = Field(..., min_length=1)
|
||||
|
||||
|
||||
# === App ===
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
Path(settings.DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
db = await aiosqlite.connect(settings.DB_PATH)
|
||||
try:
|
||||
await init_db()
|
||||
await sync_code_agents(db)
|
||||
finally:
|
||||
await db.close()
|
||||
_count_db = await aiosqlite.connect(settings.DB_PATH)
|
||||
try:
|
||||
agents = await list_agents(_count_db)
|
||||
finally:
|
||||
await _count_db.close()
|
||||
logger.info(f"DB initialisiert. {len(agents)} Agent(s) verfügbar: {[a['id'] for a in agents]}")
|
||||
yield
|
||||
await close_mcp_client()
|
||||
|
||||
|
||||
app = FastAPI(title="Agent Platform", version="0.3.0", lifespan=lifespan)
|
||||
|
||||
|
||||
# === Static + Templates ===
|
||||
|
||||
BASE_DIR = Path(__file__).parent
|
||||
TEMPLATES_DIR = BASE_DIR / "templates"
|
||||
STATIC_DIR = BASE_DIR / "static"
|
||||
|
||||
if STATIC_DIR.exists():
|
||||
app.mount("/static", StaticFiles(directory=str(STATIC_DIR)), name="static")
|
||||
|
||||
templates = Jinja2Templates(directory=str(TEMPLATES_DIR)) if TEMPLATES_DIR.exists() else None
|
||||
|
||||
|
||||
# === DB Dependency ===
|
||||
|
||||
async def get_db():
|
||||
db = await aiosqlite.connect(settings.DB_PATH)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# === Health ===
|
||||
|
||||
@app.get("/health")
|
||||
async def health():
|
||||
db = await aiosqlite.connect(settings.DB_PATH)
|
||||
try:
|
||||
agents = await list_agents(db)
|
||||
finally:
|
||||
await db.close()
|
||||
mcp_ok = False
|
||||
try:
|
||||
mcp_ok = await get_mcp_client().health()
|
||||
except BaseException:
|
||||
pass
|
||||
return {
|
||||
"status": "ok",
|
||||
"agents": len(agents),
|
||||
"agent_ids": [a["id"] for a in agents],
|
||||
"mcp_server": "reachable" if mcp_ok else "unreachable",
|
||||
"llm_model": settings.LLM_MODEL,
|
||||
}
|
||||
|
||||
|
||||
# === Agents API ===
|
||||
|
||||
@app.get("/api/agents")
|
||||
async def api_list_agents(db: aiosqlite.Connection = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
return await list_agents(db)
|
||||
|
||||
|
||||
@app.post("/api/agents", status_code=201)
|
||||
async def api_create_agent(
|
||||
agent: AgentCreate,
|
||||
user_id: str = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
existing = await get_agent(db, agent.id)
|
||||
if existing:
|
||||
raise HTTPException(409, f"Agent '{agent.id}' existiert bereits")
|
||||
await upsert_agent(db, agent.model_dump(), source="db")
|
||||
await log_audit(db, agent_id=agent.id, user_id=user_id, action="agent_created", target=agent.id, args=agent.model_dump())
|
||||
return await get_agent(db, agent.id)
|
||||
|
||||
|
||||
@app.get("/api/agents/{agent_id}")
|
||||
async def api_get_agent(agent_id: str, db: aiosqlite.Connection = Depends(get_db), _: str = Depends(get_current_user)):
|
||||
agent = await get_agent(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404, f"Agent '{agent_id}' nicht gefunden")
|
||||
return agent
|
||||
|
||||
|
||||
@app.put("/api/agents/{agent_id}")
|
||||
async def api_update_agent(
|
||||
agent_id: str,
|
||||
update: AgentUpdate,
|
||||
user_id: str = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
existing = await get_agent(db, agent_id)
|
||||
if not existing:
|
||||
raise HTTPException(404)
|
||||
merged = {**existing}
|
||||
for k, v in update.model_dump(exclude_unset=True).items():
|
||||
merged[k] = v
|
||||
await upsert_agent(db, merged, source=existing.get("source", "db"))
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_updated", target=agent_id, args=update.model_dump(exclude_unset=True))
|
||||
return await get_agent(db, agent_id)
|
||||
|
||||
|
||||
@app.delete("/api/agents/{agent_id}", status_code=204)
|
||||
async def api_delete_agent(
|
||||
agent_id: str,
|
||||
user_id: str = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
agent = await get_agent(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404)
|
||||
if agent.get("source") == "code":
|
||||
# Code-Agents: nur deaktivieren, damit sie beim nächsten Sync wieder da sind
|
||||
await set_agent_enabled(db, agent_id, False)
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_disabled_via_delete", target=agent_id)
|
||||
return {"status": "disabled", "note": "Code-Agents werden nicht gelöscht, nur deaktiviert"}
|
||||
await delete_agent(db, agent_id)
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_deleted", target=agent_id)
|
||||
return {"status": "deleted"}
|
||||
|
||||
|
||||
@app.post("/api/agents/{agent_id}/enable")
|
||||
async def api_enable_agent(agent_id: str, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
||||
if not await get_agent(db, agent_id):
|
||||
raise HTTPException(404)
|
||||
await set_agent_enabled(db, agent_id, True)
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_enabled", target=agent_id)
|
||||
return {"status": "enabled"}
|
||||
|
||||
|
||||
@app.post("/api/agents/{agent_id}/disable")
|
||||
async def api_disable_agent(agent_id: str, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
||||
if not await get_agent(db, agent_id):
|
||||
raise HTTPException(404)
|
||||
await set_agent_enabled(db, agent_id, False)
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="agent_disabled", target=agent_id)
|
||||
return {"status": "disabled"}
|
||||
|
||||
|
||||
# === Chat API ===
|
||||
|
||||
@app.post("/api/chat/{agent_id}")
|
||||
async def api_new_chat(
|
||||
agent_id: str,
|
||||
msg: ChatMessage,
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
agent = await get_agent(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404)
|
||||
if not agent["enabled"]:
|
||||
raise HTTPException(403, f"Agent '{agent_id}' ist deaktiviert")
|
||||
conv_id = await create_conversation(db, agent_id, user_id=user_id, title=msg.message[:50])
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="chat_started", target=agent_id)
|
||||
try:
|
||||
result = await run_agent_turn(db, agent_id, user_id, msg.message, conv_id)
|
||||
except Exception as e:
|
||||
logger.exception("Agent run failed")
|
||||
await log_audit(db, agent_id=agent_id, user_id=user_id, action="chat_failed", target=agent_id, result={"error": str(e)})
|
||||
raise HTTPException(500, f"Agent-Fehler: {e}")
|
||||
return {"conversation_id": conv_id, **result}
|
||||
|
||||
|
||||
@app.post("/api/chat/{agent_id}/{conv_id}")
|
||||
async def api_continue_chat(
|
||||
agent_id: str,
|
||||
conv_id: str,
|
||||
msg: ChatMessage,
|
||||
user_id: str = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
return await run_agent_turn(db, agent_id, user_id, msg.message, conv_id)
|
||||
|
||||
|
||||
@app.get("/api/chat/{conv_id}/messages")
|
||||
async def api_get_messages(conv_id: str, _: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
||||
rows = await get_messages(db, conv_id)
|
||||
return [{"role": r[0], "content": r[1], "tool_calls": r[2]} for r in rows]
|
||||
|
||||
|
||||
@app.get("/api/conversations")
|
||||
async def api_list_conversations(
|
||||
agent_id: Optional[str] = None,
|
||||
_: str = Depends(get_current_user),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
rows = await list_conversations(db, agent_id=agent_id)
|
||||
return [{"id": r[0], "agent_id": r[1], "title": r[2], "created_at": r[3]} for r in rows]
|
||||
|
||||
|
||||
# === Tools API ===
|
||||
|
||||
@app.get("/api/tools")
|
||||
async def api_list_tools(_: str = Depends(get_current_user)):
|
||||
try:
|
||||
tools = await get_mcp_client().list_tools()
|
||||
return {"mcp_reachable": True, "tools": tools}
|
||||
except Exception as e:
|
||||
return {"mcp_reachable": False, "error": str(e), "tools": []}
|
||||
|
||||
|
||||
# === Audit API ===
|
||||
|
||||
@app.get("/api/audit")
|
||||
async def api_audit(
|
||||
agent_id: Optional[str] = None,
|
||||
limit: int = 100,
|
||||
_: str = Depends(require_admin),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
):
|
||||
rows = await list_audit(db, agent_id=agent_id, limit=limit)
|
||||
return [{"timestamp": r[0], "agent_id": r[1], "action": r[2], "target": r[3], "duration_ms": r[4]} for r in rows]
|
||||
|
||||
|
||||
# === UI Routes ===
|
||||
|
||||
def _ctx(request: Request, user_id: str, **extra) -> dict:
|
||||
return {"request": request, "user_id": user_id, **extra}
|
||||
|
||||
|
||||
@app.get("/", response_class=HTMLResponse)
|
||||
async def ui_dashboard(request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
||||
agents = await list_agents(db)
|
||||
return templates.TemplateResponse("dashboard.html", _ctx(request, user_id, agents=agents))
|
||||
|
||||
|
||||
@app.get("/agents", response_class=HTMLResponse)
|
||||
async def ui_agents(request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
||||
agents = await list_agents(db)
|
||||
return templates.TemplateResponse("agents.html", _ctx(request, user_id, agents=agents))
|
||||
|
||||
|
||||
@app.get("/agents/new", response_class=HTMLResponse)
|
||||
async def ui_agent_new(request: Request, user_id: str = Depends(require_admin)):
|
||||
return templates.TemplateResponse("agent_form.html", _ctx(request, user_id, agent=None))
|
||||
|
||||
|
||||
@app.get("/agents/{agent_id}", response_class=HTMLResponse)
|
||||
async def ui_agent_detail(agent_id: str, request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
||||
agent = await get_agent(db, agent_id)
|
||||
if not agent:
|
||||
raise HTTPException(404)
|
||||
# verfügbare Tools vom MCP-Server
|
||||
available_tools = []
|
||||
try:
|
||||
available_tools = await get_mcp_client().list_tools()
|
||||
except Exception:
|
||||
pass
|
||||
return templates.TemplateResponse("agent_form.html", _ctx(request, user_id, agent=agent, available_tools=available_tools))
|
||||
|
||||
|
||||
@app.get("/chat/{conv_id}", response_class=HTMLResponse)
|
||||
async def ui_chat(conv_id: str, request: Request, user_id: str = Depends(get_current_user), db: aiosqlite.Connection = Depends(get_db)):
|
||||
rows = await get_messages(db, conv_id)
|
||||
messages = [{"role": r[0], "content": r[1]} for r in rows]
|
||||
return templates.TemplateResponse("chat.html", _ctx(request, user_id, conv_id=conv_id, messages=messages))
|
||||
|
||||
|
||||
@app.get("/audit", response_class=HTMLResponse)
|
||||
async def ui_audit(request: Request, user_id: str = Depends(require_admin), db: aiosqlite.Connection = Depends(get_db)):
|
||||
rows = await list_audit(db, limit=200)
|
||||
entries = [{"timestamp": r[0], "agent_id": r[1], "action": r[2], "target": r[3], "duration_ms": r[4]} for r in rows]
|
||||
return templates.TemplateResponse("audit.html", _ctx(request, user_id, entries=entries))
|
||||
|
||||
|
||||
@app.get("/tools", response_class=HTMLResponse)
|
||||
async def ui_tools(request: Request, user_id: str = Depends(get_current_user)):
|
||||
try:
|
||||
tools = await get_mcp_client().list_tools()
|
||||
return templates.TemplateResponse("tools.html", _ctx(request, user_id, tools=tools, reachable=True))
|
||||
except Exception as e:
|
||||
return templates.TemplateResponse("tools.html", _ctx(request, user_id, tools=[], reachable=False, error=str(e)))
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Audit-Helper: strukturiertes Logging aller Aktionen."""
|
||||
import logging
|
||||
import json
|
||||
from datetime import datetime
|
||||
from typing import Optional, Dict, Any
|
||||
import aiosqlite
|
||||
|
||||
from config import settings
|
||||
from db import log_audit, get_db
|
||||
|
||||
|
||||
logger = logging.getLogger("audit")
|
||||
|
||||
|
||||
async def record(
|
||||
db: aiosqlite.Connection,
|
||||
agent_id: Optional[str],
|
||||
user_id: str,
|
||||
action: str,
|
||||
target: Optional[str] = None,
|
||||
args: Optional[Dict[str, Any]] = None,
|
||||
result: Optional[Dict[str, Any]] = None,
|
||||
duration_ms: Optional[int] = None,
|
||||
) -> None:
|
||||
"""Schreibt einen Audit-Eintrag in DB + Logger."""
|
||||
await log_audit(
|
||||
db=db,
|
||||
agent_id=agent_id,
|
||||
user_id=user_id,
|
||||
action=action,
|
||||
target=target,
|
||||
args=args,
|
||||
result=result,
|
||||
duration_ms=duration_ms,
|
||||
)
|
||||
|
||||
log_entry = {
|
||||
"ts": datetime.utcnow().isoformat(),
|
||||
"agent_id": agent_id,
|
||||
"user_id": user_id,
|
||||
"action": action,
|
||||
"target": target,
|
||||
"args": args,
|
||||
"result": result,
|
||||
"duration_ms": duration_ms,
|
||||
}
|
||||
logger.info(json.dumps(log_entry, default=str))
|
||||
|
||||
|
||||
async def get_recent(db: aiosqlite.Connection, limit: int = 100, agent_id: Optional[str] = None):
|
||||
"""Holt die letzten Audit-Einträge."""
|
||||
from db import list_audit
|
||||
return await list_audit(db, agent_id=agent_id, limit=limit)
|
||||
@@ -0,0 +1,36 @@
|
||||
"""Token-basierte Auth."""
|
||||
from fastapi import Header, HTTPException, Depends
|
||||
from typing import Optional
|
||||
import aiosqlite
|
||||
|
||||
from config import settings
|
||||
from db import validate_session, get_db
|
||||
|
||||
|
||||
async def get_current_user(
|
||||
authorization: Optional[str] = Header(None),
|
||||
db: aiosqlite.Connection = Depends(get_db),
|
||||
) -> str:
|
||||
"""Validiert Token und gibt User-ID zurück."""
|
||||
if not authorization:
|
||||
raise HTTPException(status_code=401, detail="Missing Authorization header")
|
||||
|
||||
token = authorization.replace("Bearer ", "").strip()
|
||||
|
||||
# Static-Auth-Token als Master-Key (für Setup/Bootstrap)
|
||||
if token == settings.AUTH_TOKEN:
|
||||
return "admin"
|
||||
|
||||
# Session-Token aus DB
|
||||
user_id = await validate_session(db, token)
|
||||
if not user_id:
|
||||
raise HTTPException(status_code=401, detail="Invalid or expired token")
|
||||
|
||||
return user_id
|
||||
|
||||
|
||||
async def require_admin(user_id: str = Depends(get_current_user)) -> str:
|
||||
"""Erfordert Admin-User."""
|
||||
if user_id != "admin":
|
||||
raise HTTPException(status_code=403, detail="Admin required")
|
||||
return user_id
|
||||
@@ -0,0 +1,33 @@
|
||||
"""Settings via Environment-Variablen."""
|
||||
import os
|
||||
|
||||
|
||||
class Settings:
|
||||
# LLM
|
||||
LLM_PROVIDER: str = os.getenv("LLM_PROVIDER", "openrouter")
|
||||
LLM_API_KEY: str = os.getenv("LLM_API_KEY", "")
|
||||
LLM_MODEL: str = os.getenv("LLM_MODEL", "anthropic/claude-3.5-sonnet")
|
||||
LLM_API_BASE: str = os.getenv("LLM_API_BASE", "")
|
||||
|
||||
# MCP Tool Server
|
||||
MCP_SERVER_URL: str = os.getenv("MCP_SERVER_URL", "http://mcp-tools:8501/mcp")
|
||||
MCP_TIMEOUT: int = int(os.getenv("MCP_TIMEOUT", "10"))
|
||||
|
||||
# Auth
|
||||
AUTH_TOKEN: str = os.getenv("AUTH_TOKEN", "change-me-in-production")
|
||||
|
||||
# Storage
|
||||
DB_PATH: str = os.getenv("DB_PATH", "/data/agent-platform.db")
|
||||
|
||||
# Server
|
||||
HOST: str = os.getenv("HOST", "0.0.0.0")
|
||||
PORT: int = int(os.getenv("PORT", "8000"))
|
||||
LOG_LEVEL: str = os.getenv("LOG_LEVEL", "info")
|
||||
|
||||
# Limits
|
||||
MAX_HISTORY_MESSAGES: int = int(os.getenv("MAX_HISTORY_MESSAGES", "10"))
|
||||
MAX_PARALLEL_AGENTS: int = int(os.getenv("MAX_PARALLEL_AGENTS", "5"))
|
||||
AGENT_IDLE_TIMEOUT_SEC: int = int(os.getenv("AGENT_IDLE_TIMEOUT_SEC", "300"))
|
||||
|
||||
|
||||
settings = Settings()
|
||||
@@ -0,0 +1,289 @@
|
||||
"""SQLite-Layer: Chats, Messages, Audit, Sessions, Agents.
|
||||
|
||||
Agents können sein:
|
||||
- source='code': aus agents/*.py, DB ist Override
|
||||
- source='db': nur in DB, via API/UI angelegt
|
||||
"""
|
||||
import aiosqlite
|
||||
import json
|
||||
import uuid
|
||||
import secrets
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
from config import settings
|
||||
|
||||
|
||||
SCHEMA = """
|
||||
CREATE TABLE IF NOT EXISTS agents (
|
||||
id TEXT PRIMARY KEY,
|
||||
name TEXT NOT NULL,
|
||||
description TEXT DEFAULT '',
|
||||
system_prompt TEXT NOT NULL,
|
||||
allowed_tools TEXT DEFAULT '[]',
|
||||
model TEXT,
|
||||
temperature REAL DEFAULT 0.7,
|
||||
max_tokens INTEGER DEFAULT 2000,
|
||||
enabled INTEGER DEFAULT 1,
|
||||
source TEXT DEFAULT 'db',
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
id TEXT PRIMARY KEY,
|
||||
agent_id TEXT NOT NULL,
|
||||
user_id TEXT,
|
||||
title TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
content TEXT,
|
||||
tool_calls TEXT,
|
||||
tool_results TEXT,
|
||||
token_count INTEGER,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS audit (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
timestamp TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
agent_id TEXT,
|
||||
user_id TEXT,
|
||||
action TEXT,
|
||||
target TEXT,
|
||||
args TEXT,
|
||||
result TEXT,
|
||||
duration_ms INTEGER
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS sessions (
|
||||
token TEXT PRIMARY KEY,
|
||||
user_id TEXT,
|
||||
created_at TEXT DEFAULT CURRENT_TIMESTAMP,
|
||||
expires_at TEXT
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS idx_messages_conv ON messages(conversation_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_agent ON audit(agent_id);
|
||||
CREATE INDEX IF NOT EXISTS idx_audit_timestamp ON audit(timestamp);
|
||||
CREATE INDEX IF NOT EXISTS idx_conversations_user ON conversations(user_id);
|
||||
"""
|
||||
|
||||
|
||||
async def init_db():
|
||||
"""Erstellt DB und Schema."""
|
||||
Path(settings.DB_PATH).parent.mkdir(parents=True, exist_ok=True)
|
||||
async with aiosqlite.connect(settings.DB_PATH) as db:
|
||||
await db.executescript(SCHEMA)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_db_connection():
|
||||
"""Gibt eine aiosqlite-Connection zurück."""
|
||||
return await aiosqlite.connect(settings.DB_PATH)
|
||||
|
||||
|
||||
async def get_db():
|
||||
"""FastAPI-Dependency: aiosqlite-Connection pro Request, mit automatischem Cleanup."""
|
||||
db = await aiosqlite.connect(settings.DB_PATH)
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
await db.close()
|
||||
|
||||
|
||||
# === Agents ===
|
||||
|
||||
def _row_to_agent(row):
|
||||
return {
|
||||
"id": row[0],
|
||||
"name": row[1],
|
||||
"description": row[2] or "",
|
||||
"system_prompt": row[3],
|
||||
"allowed_tools": json.loads(row[4]) if row[4] else [],
|
||||
"model": row[5],
|
||||
"temperature": row[6] if row[6] is not None else 0.7,
|
||||
"max_tokens": row[7] if row[7] is not None else 2000,
|
||||
"enabled": bool(row[8]),
|
||||
"source": row[9] or "db",
|
||||
"created_at": row[10],
|
||||
"updated_at": row[11],
|
||||
}
|
||||
|
||||
|
||||
async def upsert_agent(db, agent: dict, source: str = "db"):
|
||||
"""Insert oder Update. Code-Agents werden beim ersten Start hier gesynct."""
|
||||
await db.execute(
|
||||
"""INSERT INTO agents (id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
name=excluded.name,
|
||||
description=excluded.description,
|
||||
system_prompt=excluded.system_prompt,
|
||||
allowed_tools=excluded.allowed_tools,
|
||||
model=excluded.model,
|
||||
temperature=excluded.temperature,
|
||||
max_tokens=excluded.max_tokens,
|
||||
enabled=excluded.enabled,
|
||||
updated_at=CURRENT_TIMESTAMP""",
|
||||
(
|
||||
agent["id"],
|
||||
agent["name"],
|
||||
agent.get("description", ""),
|
||||
agent["system_prompt"],
|
||||
json.dumps(agent.get("allowed_tools", [])),
|
||||
agent.get("model"),
|
||||
agent.get("temperature", 0.7),
|
||||
agent.get("max_tokens", 2000),
|
||||
int(agent.get("enabled", True)),
|
||||
source,
|
||||
)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_agent(db, agent_id: str):
|
||||
async with db.execute(
|
||||
"SELECT id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, created_at, updated_at FROM agents WHERE id = ?",
|
||||
(agent_id,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
return _row_to_agent(row) if row else None
|
||||
|
||||
|
||||
async def list_agents(db, enabled_only=False):
|
||||
query = "SELECT id, name, description, system_prompt, allowed_tools, model, temperature, max_tokens, enabled, source, created_at, updated_at FROM agents"
|
||||
params = []
|
||||
if enabled_only:
|
||||
query += " WHERE enabled = 1"
|
||||
query += " ORDER BY id ASC"
|
||||
async with db.execute(query, params) as cur:
|
||||
return [_row_to_agent(row) for row in await cur.fetchall()]
|
||||
|
||||
|
||||
async def delete_agent(db, agent_id: str) -> bool:
|
||||
"""Löscht einen Agent. Code-Agents können gelöscht werden, sind dann bis zum nächsten Sync weg."""
|
||||
async with db.execute("DELETE FROM agents WHERE id = ?", (agent_id,)) as cur:
|
||||
await db.commit()
|
||||
return cur.rowcount > 0
|
||||
|
||||
|
||||
async def set_agent_enabled(db, agent_id: str, enabled: bool):
|
||||
await db.execute(
|
||||
"UPDATE agents SET enabled = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(int(enabled), agent_id)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# === Conversations ===
|
||||
|
||||
async def create_conversation(db, agent_id, user_id="default", title="Neuer Chat"):
|
||||
conv_id = str(uuid.uuid4())
|
||||
await db.execute(
|
||||
"INSERT INTO conversations (id, agent_id, user_id, title) VALUES (?, ?, ?, ?)",
|
||||
(conv_id, agent_id, user_id, title)
|
||||
)
|
||||
await db.commit()
|
||||
return conv_id
|
||||
|
||||
|
||||
async def list_conversations(db, agent_id=None, user_id="default", limit=20):
|
||||
query = "SELECT id, agent_id, title, created_at FROM conversations WHERE user_id = ?"
|
||||
params = [user_id]
|
||||
if agent_id:
|
||||
query += " AND agent_id = ?"
|
||||
params.append(agent_id)
|
||||
query += " ORDER BY updated_at DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
async with db.execute(query, params) as cur:
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
async def touch_conversation(db, conv_id):
|
||||
await db.execute(
|
||||
"UPDATE conversations SET updated_at = CURRENT_TIMESTAMP WHERE id = ?",
|
||||
(conv_id,)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
# === Messages ===
|
||||
|
||||
async def add_message(db, conversation_id, role, content, tool_calls=None, tool_results=None, token_count=0):
|
||||
await db.execute(
|
||||
"""INSERT INTO messages (conversation_id, role, content, tool_calls, tool_results, token_count)
|
||||
VALUES (?, ?, ?, ?, ?, ?)""",
|
||||
(conversation_id, role, content,
|
||||
json.dumps(tool_calls) if tool_calls else None,
|
||||
json.dumps(tool_results) if tool_results else None,
|
||||
token_count)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def get_messages(db, conversation_id, limit=None):
|
||||
query = "SELECT role, content, tool_calls, tool_results, token_count FROM messages WHERE conversation_id = ? ORDER BY id ASC"
|
||||
params = [conversation_id]
|
||||
if limit:
|
||||
query += " LIMIT ?"
|
||||
params.append(limit)
|
||||
async with db.execute(query, params) as cur:
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
# === Audit ===
|
||||
|
||||
async def log_audit(db, agent_id, user_id, action, target=None, args=None, result=None, duration_ms=None):
|
||||
await db.execute(
|
||||
"""INSERT INTO audit (agent_id, user_id, action, target, args, result, duration_ms)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?)""",
|
||||
(agent_id, user_id, action, target,
|
||||
json.dumps(args) if args else None,
|
||||
json.dumps(result) if result else None,
|
||||
duration_ms)
|
||||
)
|
||||
await db.commit()
|
||||
|
||||
|
||||
async def list_audit(db, agent_id=None, limit=100):
|
||||
query = "SELECT timestamp, agent_id, action, target, duration_ms FROM audit"
|
||||
params = []
|
||||
if agent_id:
|
||||
query += " WHERE agent_id = ?"
|
||||
params.append(agent_id)
|
||||
query += " ORDER BY id DESC LIMIT ?"
|
||||
params.append(limit)
|
||||
async with db.execute(query, params) as cur:
|
||||
return await cur.fetchall()
|
||||
|
||||
|
||||
# === Sessions ===
|
||||
|
||||
async def create_session(db, user_id="default", ttl_hours=24):
|
||||
token = secrets.token_urlsafe(32)
|
||||
expires = (datetime.utcnow() + timedelta(hours=ttl_hours)).isoformat()
|
||||
await db.execute(
|
||||
"INSERT INTO sessions (token, user_id, expires_at) VALUES (?, ?, ?)",
|
||||
(token, user_id, expires)
|
||||
)
|
||||
await db.commit()
|
||||
return token
|
||||
|
||||
|
||||
async def validate_session(db, token):
|
||||
async with db.execute(
|
||||
"SELECT user_id, expires_at FROM sessions WHERE token = ?",
|
||||
(token,)
|
||||
) as cur:
|
||||
row = await cur.fetchone()
|
||||
if not row:
|
||||
return None
|
||||
if row[1] and datetime.fromisoformat(row[1]) < datetime.utcnow():
|
||||
return None
|
||||
return row[0]
|
||||
@@ -0,0 +1,86 @@
|
||||
"""LiteLLM-Wrapper mit Token-Tracking."""
|
||||
import litellm
|
||||
from typing import List, Dict, Optional
|
||||
from config import settings
|
||||
|
||||
|
||||
if settings.LLM_API_KEY:
|
||||
litellm.api_key = settings.LLM_API_KEY
|
||||
if settings.LLM_API_BASE:
|
||||
litellm.api_base = settings.LLM_API_BASE
|
||||
|
||||
|
||||
class LLMResponse:
|
||||
def __init__(self, content: str, tool_calls: Optional[List[Dict]] = None, usage: Optional[Dict] = None):
|
||||
self.content = content
|
||||
self.tool_calls = tool_calls or []
|
||||
self.usage = usage or {}
|
||||
|
||||
@property
|
||||
def input_tokens(self) -> int:
|
||||
return self.usage.get("prompt_tokens", 0)
|
||||
|
||||
@property
|
||||
def output_tokens(self) -> int:
|
||||
return self.usage.get("completion_tokens", 0)
|
||||
|
||||
@property
|
||||
def total_tokens(self) -> int:
|
||||
return self.input_tokens + self.output_tokens
|
||||
|
||||
@property
|
||||
def finish_reason(self) -> str:
|
||||
return self.usage.get("finish_reason", "stop")
|
||||
|
||||
|
||||
async def chat(
|
||||
messages: List[Dict[str, str]],
|
||||
tools: Optional[List[Dict]] = None,
|
||||
model: Optional[str] = None,
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
) -> LLMResponse:
|
||||
"""LLM-Call via LiteLLM.
|
||||
|
||||
messages: Liste von {role, content} Dicts.
|
||||
tools: Optional, MCP-Tool-Definitionen für Function-Calling.
|
||||
"""
|
||||
model = model or settings.LLM_MODEL
|
||||
|
||||
kwargs = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
}
|
||||
|
||||
if tools:
|
||||
kwargs["tools"] = tools
|
||||
|
||||
response = await litellm.acompletion(**kwargs)
|
||||
|
||||
message = response.choices[0].message
|
||||
|
||||
tool_calls = []
|
||||
if hasattr(message, "tool_calls") and message.tool_calls:
|
||||
for tc in message.tool_calls:
|
||||
tool_calls.append({
|
||||
"id": tc.id,
|
||||
"name": tc.function.name,
|
||||
"arguments": tc.function.arguments,
|
||||
})
|
||||
|
||||
usage = {}
|
||||
if hasattr(response, "usage") and response.usage:
|
||||
usage = {
|
||||
"prompt_tokens": response.usage.prompt_tokens or 0,
|
||||
"completion_tokens": response.usage.completion_tokens or 0,
|
||||
"total_tokens": response.usage.total_tokens or 0,
|
||||
"finish_reason": response.choices[0].finish_reason or "stop",
|
||||
}
|
||||
|
||||
return LLMResponse(
|
||||
content=message.content or "",
|
||||
tool_calls=tool_calls,
|
||||
usage=usage,
|
||||
)
|
||||
@@ -0,0 +1,98 @@
|
||||
"""MCP-Client: Verbindung zum Tool-Server."""
|
||||
from typing import List, Dict, Any, Optional
|
||||
from mcp import ClientSession
|
||||
from mcp.client.streamable_http import streamablehttp_client
|
||||
|
||||
from config import settings
|
||||
|
||||
|
||||
class MCPClient:
|
||||
def __init__(self, server_url: str = None):
|
||||
self.server_url = server_url or settings.MCP_SERVER_URL
|
||||
self._session: Optional[ClientSession] = None
|
||||
self._streams = None
|
||||
self._ctx = None
|
||||
|
||||
async def connect(self):
|
||||
"""Stellt Verbindung zum MCP-Server her (lazy + reconnect-fähig)."""
|
||||
if self._session:
|
||||
return
|
||||
self._ctx = streamablehttp_client(url=self.server_url)
|
||||
read, write, _ = await self._ctx.__aenter__()
|
||||
self._session = ClientSession(read, write)
|
||||
await self._session.__aenter__()
|
||||
await self._session.initialize()
|
||||
|
||||
async def disconnect(self):
|
||||
if self._session:
|
||||
try:
|
||||
await self._session.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
if self._ctx:
|
||||
try:
|
||||
await self._ctx.__aexit__(None, None, None)
|
||||
except Exception:
|
||||
pass
|
||||
self._session = None
|
||||
self._ctx = None
|
||||
|
||||
async def list_tools(self) -> List[Dict[str, Any]]:
|
||||
await self.connect()
|
||||
result = await self._session.list_tools()
|
||||
tools = []
|
||||
for tool in result.tools:
|
||||
t = tool.model_dump() if hasattr(tool, "model_dump") else tool
|
||||
tools.append(t)
|
||||
return tools
|
||||
|
||||
async def call_tool(self, name: str, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
await self.connect()
|
||||
result = await self._session.call_tool(name, arguments)
|
||||
return result.model_dump() if hasattr(result, "model_dump") else result
|
||||
|
||||
async def get_tools_for_llm(self, allowed: Optional[List[str]] = None) -> List[Dict[str, Any]]:
|
||||
"""Konvertiert MCP-Tools in LiteLLM-Tool-Format.
|
||||
|
||||
Optional: Filter auf erlaubte Tool-Namen.
|
||||
"""
|
||||
tools = await self.list_tools()
|
||||
result = []
|
||||
for tool in tools:
|
||||
name = tool.get("name")
|
||||
if allowed and name not in allowed:
|
||||
continue
|
||||
result.append({
|
||||
"type": "function",
|
||||
"function": {
|
||||
"name": name,
|
||||
"description": tool.get("description", ""),
|
||||
"parameters": tool.get("inputSchema", {"type": "object", "properties": {}}),
|
||||
},
|
||||
})
|
||||
return result
|
||||
|
||||
async def health(self) -> bool:
|
||||
"""Prüft ob MCP-Server erreichbar ist."""
|
||||
try:
|
||||
await self.connect()
|
||||
return True
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
_client: Optional[MCPClient] = None
|
||||
|
||||
|
||||
def get_mcp_client() -> MCPClient:
|
||||
global _client
|
||||
if _client is None:
|
||||
_client = MCPClient()
|
||||
return _client
|
||||
|
||||
|
||||
async def close_mcp_client():
|
||||
global _client
|
||||
if _client:
|
||||
await _client.disconnect()
|
||||
_client = None
|
||||
@@ -0,0 +1,27 @@
|
||||
[project]
|
||||
name = "agent-platform"
|
||||
version = "0.1.0"
|
||||
description = "Business-taugliche Agent-Plattform mit LiteLLM + Pydantic AI + MCP"
|
||||
requires-python = ">=3.12"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn[standard]>=0.32",
|
||||
"litellm>=1.50",
|
||||
"pydantic-ai>=0.4",
|
||||
"jinja2>=3.1",
|
||||
"python-multipart>=0.0.12",
|
||||
"aiosqlite>=0.20",
|
||||
"httpx>=0.27",
|
||||
"mcp>=1.0",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["setuptools>=68"]
|
||||
build-backend = "setuptools.build_meta"
|
||||
|
||||
[tool.setuptools]
|
||||
py-modules = ["agent", "llm", "db", "audit", "auth", "config", "api", "mcp_client"]
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
include = ["agents*"]
|
||||
@@ -0,0 +1,249 @@
|
||||
/* Agent Platform – Stylesheet */
|
||||
|
||||
:root {
|
||||
--color-bg: #f7f8fa;
|
||||
--color-surface: #ffffff;
|
||||
--color-border: #e1e4e8;
|
||||
--color-text: #1f2328;
|
||||
--color-text-muted: #57606a;
|
||||
--color-primary: #0969da;
|
||||
--color-primary-hover: #0860c7;
|
||||
--color-success: #1a7f37;
|
||||
--color-danger: #cf222e;
|
||||
--color-warning: #bf8700;
|
||||
--radius: 6px;
|
||||
--shadow: 0 1px 3px rgba(0,0,0,0.05);
|
||||
--shadow-lg: 0 4px 12px rgba(0,0,0,0.08);
|
||||
--font: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||||
--mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
|
||||
html, body { height: 100%; }
|
||||
body {
|
||||
font-family: var(--font);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
color: var(--color-text);
|
||||
background: var(--color-bg);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 100vh;
|
||||
}
|
||||
|
||||
/* === Navbar === */
|
||||
.navbar {
|
||||
background: var(--color-surface);
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
padding: 12px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 24px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.nav-brand { font-weight: 700; font-size: 16px; color: var(--color-primary); }
|
||||
.nav-links { display: flex; gap: 16px; flex: 1; }
|
||||
.nav-link {
|
||||
color: var(--color-text-muted);
|
||||
text-decoration: none;
|
||||
padding: 6px 12px;
|
||||
border-radius: var(--radius);
|
||||
transition: background 0.15s;
|
||||
}
|
||||
.nav-link:hover { background: var(--color-bg); color: var(--color-text); }
|
||||
.nav-user { color: var(--color-text-muted); font-size: 13px; }
|
||||
|
||||
/* === Container === */
|
||||
.container {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
padding: 24px;
|
||||
width: 100%;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
/* === Page Header === */
|
||||
.page-header { margin-bottom: 24px; }
|
||||
.page-header h1 { font-size: 28px; font-weight: 600; margin-bottom: 4px; }
|
||||
.subtitle { color: var(--color-text-muted); }
|
||||
|
||||
/* === Card === */
|
||||
.card {
|
||||
background: var(--color-surface);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 20px;
|
||||
margin-bottom: 16px;
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.card h2 { font-size: 18px; font-weight: 600; margin-bottom: 12px; }
|
||||
|
||||
/* === Table === */
|
||||
.table { width: 100%; border-collapse: collapse; }
|
||||
.table th, .table td {
|
||||
padding: 10px 12px;
|
||||
text-align: left;
|
||||
border-bottom: 1px solid var(--color-border);
|
||||
}
|
||||
.table th { background: var(--color-bg); font-weight: 600; font-size: 12px; text-transform: uppercase; color: var(--color-text-muted); }
|
||||
.table tbody tr:hover { background: var(--color-bg); }
|
||||
|
||||
/* === Form === */
|
||||
.form { display: flex; flex-direction: column; gap: 12px; }
|
||||
.form-row { display: flex; gap: 12px; }
|
||||
.form-row .form-group { flex: 1; }
|
||||
.form-group { display: flex; flex-direction: column; gap: 4px; }
|
||||
.form-group label { font-size: 13px; font-weight: 500; color: var(--color-text-muted); }
|
||||
input, select, textarea {
|
||||
font-family: inherit;
|
||||
font-size: 14px;
|
||||
padding: 8px 12px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
width: 100%;
|
||||
}
|
||||
input:focus, select:focus, textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--color-primary);
|
||||
box-shadow: 0 0 0 3px rgba(9,105,218,0.15);
|
||||
}
|
||||
textarea { font-family: inherit; resize: vertical; }
|
||||
|
||||
/* === Button === */
|
||||
.btn {
|
||||
display: inline-block;
|
||||
padding: 8px 16px;
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
background: var(--color-surface);
|
||||
color: var(--color-text);
|
||||
text-decoration: none;
|
||||
cursor: pointer;
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
transition: all 0.15s;
|
||||
}
|
||||
.btn:hover { background: var(--color-bg); border-color: var(--color-text-muted); }
|
||||
.btn-primary {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
.btn-primary:hover { background: var(--color-primary-hover); }
|
||||
.btn-danger {
|
||||
background: var(--color-surface);
|
||||
color: var(--color-danger);
|
||||
border-color: var(--color-border);
|
||||
}
|
||||
.btn-danger:hover { background: var(--color-danger); color: white; border-color: var(--color-danger); }
|
||||
.btn-sm { padding: 4px 10px; font-size: 12px; }
|
||||
|
||||
/* === Badge === */
|
||||
.badge {
|
||||
display: inline-block;
|
||||
padding: 2px 8px;
|
||||
border-radius: 12px;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
background: var(--color-bg);
|
||||
color: var(--color-text-muted);
|
||||
border: 1px solid var(--color-border);
|
||||
}
|
||||
.badge-green { background: #dafbe1; color: var(--color-success); border-color: #1a7f3730; }
|
||||
.badge-red { background: #ffebe9; color: var(--color-danger); border-color: #cf222e30; }
|
||||
|
||||
/* === Empty State === */
|
||||
.empty-state {
|
||||
text-align: center;
|
||||
padding: 32px;
|
||||
color: var(--color-text-muted);
|
||||
}
|
||||
|
||||
/* === Code === */
|
||||
code {
|
||||
font-family: var(--mono);
|
||||
font-size: 12.5px;
|
||||
background: var(--color-bg);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
pre {
|
||||
background: var(--color-bg);
|
||||
padding: 12px;
|
||||
border-radius: var(--radius);
|
||||
overflow-x: auto;
|
||||
font-size: 12px;
|
||||
margin-top: 8px;
|
||||
}
|
||||
|
||||
/* === Chat === */
|
||||
.chat-container { min-height: 400px; }
|
||||
.messages { display: flex; flex-direction: column; gap: 12px; }
|
||||
.message {
|
||||
padding: 10px 14px;
|
||||
border-radius: var(--radius);
|
||||
max-width: 80%;
|
||||
}
|
||||
.message-user {
|
||||
background: var(--color-primary);
|
||||
color: white;
|
||||
align-self: flex-end;
|
||||
}
|
||||
.message-assistant {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
align-self: flex-start;
|
||||
}
|
||||
.message-tool {
|
||||
background: #fff8c5;
|
||||
color: var(--color-warning);
|
||||
border: 1px solid #d4a72c30;
|
||||
align-self: center;
|
||||
font-family: var(--mono);
|
||||
font-size: 12px;
|
||||
max-width: 90%;
|
||||
}
|
||||
.message-role {
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
margin-bottom: 4px;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.message-content { white-space: pre-wrap; word-wrap: break-word; }
|
||||
|
||||
/* === Tools Grid === */
|
||||
.tools-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
|
||||
gap: 12px;
|
||||
}
|
||||
.tool-card {
|
||||
background: var(--color-bg);
|
||||
border: 1px solid var(--color-border);
|
||||
border-radius: var(--radius);
|
||||
padding: 12px;
|
||||
}
|
||||
.tool-card h3 { margin-bottom: 6px; font-size: 14px; }
|
||||
.tool-card p { color: var(--color-text-muted); font-size: 13px; }
|
||||
|
||||
/* === Footer === */
|
||||
.footer {
|
||||
text-align: center;
|
||||
padding: 16px;
|
||||
color: var(--color-text-muted);
|
||||
border-top: 1px solid var(--color-border);
|
||||
background: var(--color-surface);
|
||||
}
|
||||
|
||||
/* === Responsive === */
|
||||
@media (max-width: 640px) {
|
||||
.navbar { flex-wrap: wrap; gap: 12px; padding: 12px; }
|
||||
.nav-links { order: 3; width: 100%; }
|
||||
.container { padding: 12px; }
|
||||
.form-row { flex-direction: column; }
|
||||
.message { max-width: 95%; }
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{{ agent.name }} – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{{ agent.name }}</h1>
|
||||
<p class="subtitle"><code>{{ agent.id }}</code> · {{ agent.description }}</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Definition (aus Code)</h2>
|
||||
<p><small>Diese Definition liegt in <code>agents/*.py</code> und ist read-only im UI. Änderungen am Code erfordern Server-Neustart.</small></p>
|
||||
<div class="form-group">
|
||||
<label>System-Prompt</label>
|
||||
<textarea readonly rows="10">{{ agent.system_prompt }}</textarea>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Erlaubte Tools</label>
|
||||
<input type="text" value="{{ agent.allowed_tools|join(', ') }}" readonly>
|
||||
</div>
|
||||
{% if agent.model %}
|
||||
<div class="form-group">
|
||||
<label>LLM-Override</label>
|
||||
<input type="text" value="{{ agent.model }}" readonly>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Runtime-Settings</h2>
|
||||
<form hx-put="/api/agents/{{ agent.id }}" hx-target="#settings-result" class="form">
|
||||
<div class="form-group">
|
||||
<label>
|
||||
<input type="checkbox" name="enabled" {% if agent.enabled %}checked{% endif %} value="true">
|
||||
Aktiv (Agent darf verwendet werden)
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label>Custom-Settings (JSON, optional)</label>
|
||||
<textarea name="custom_settings" rows="4" placeholder='{"temperature": 0.5, "max_tokens": 1000}'>{{ agent.custom_settings | tojson(indent=2) if agent.custom_settings else "{}" }}</textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Settings speichern</button>
|
||||
<a href="/agents" class="btn">Zurück</a>
|
||||
</form>
|
||||
<div id="settings-result"></div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Chat starten</h2>
|
||||
<form hx-post="/api/chat/{{ agent.id }}" hx-target="#chat-result" hx-swap="innerHTML" class="form">
|
||||
<div class="form-group">
|
||||
<label>Erste Nachricht</label>
|
||||
<textarea name="message" rows="3" required placeholder="Hallo!"></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Chat starten</button>
|
||||
</form>
|
||||
<div id="chat-result"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,95 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}{% if agent %}Agent bearbeiten{% else %}Neuer Agent{% endif %} – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>{% if agent %}{{ agent.name }}{% else %}Neuer Agent{% endif %}</h1>
|
||||
<a href="/agents" class="btn">Zurück</a>
|
||||
</div>
|
||||
|
||||
<form {% if agent %}hx-put="/api/agents/{{ agent.id }}"{% else %}hx-post="/api/agents"{% endif %} hx-target="#result" hx-swap="innerHTML" class="card form">
|
||||
|
||||
<div class="form-group">
|
||||
<label>ID (nur Kleinbuchstaben, Zahlen, _ und -)</label>
|
||||
{% if agent %}
|
||||
<input type="text" value="{{ agent.id }}" readonly>
|
||||
{% else %}
|
||||
<input type="text" name="id" required pattern="^[a-z0-9_-]+$" placeholder="mein_agent">
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Name (Anzeigename)</label>
|
||||
<input type="text" name="name" required value="{{ agent.name if agent else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Beschreibung</label>
|
||||
<input type="text" name="description" value="{{ agent.description if agent else '' }}">
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>System-Prompt</label>
|
||||
<textarea name="system_prompt" rows="6" required>{{ agent.system_prompt if agent else '' }}</textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Erlaubte Tools (Mehrfachauswahl)</label>
|
||||
<div class="checkbox-group">
|
||||
{% set selected = agent.allowed_tools if agent else [] %}
|
||||
{% if available_tools %}
|
||||
{% for tool in available_tools %}
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="allowed_tools" value="{{ tool.name }}" {% if tool.name in selected %}checked{% endif %}>
|
||||
<code>{{ tool.name }}</code>
|
||||
</label>
|
||||
{% endfor %}
|
||||
{% else %}
|
||||
<input type="text" name="allowed_tools_csv" placeholder="tool1,tool2 (MCP nicht erreichbar)" value="{{ selected|join(',') }}">
|
||||
{% endif %}
|
||||
</div>
|
||||
<small>Verfügbare Tools vom MCP-Server. Falls nicht erreichbar, manuell als CSV.</small>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex:1">
|
||||
<label>LLM-Modell (leer = Default aus Config)</label>
|
||||
<input type="text" name="model" placeholder="z.B. anthropic/claude-sonnet-4.5" value="{{ agent.model if agent and agent.model else '' }}">
|
||||
</div>
|
||||
<div class="form-group" style="width:120px">
|
||||
<label>Temperature</label>
|
||||
<input type="number" name="temperature" step="0.1" min="0" max="2" value="{{ agent.temperature if agent else 0.7 }}">
|
||||
</div>
|
||||
<div class="form-group" style="width:140px">
|
||||
<label>Max Tokens</label>
|
||||
<input type="number" name="max_tokens" min="100" max="32000" value="{{ agent.max_tokens if agent else 2000 }}">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" name="enabled" value="true" {% if not agent or agent.enabled %}checked{% endif %}>
|
||||
Aktiv (Agent darf verwendet werden)
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="btn btn-primary">{% if agent %}Speichern{% else %}Anlegen{% endif %}</button>
|
||||
<a href="/agents" class="btn">Abbrechen</a>
|
||||
</div>
|
||||
|
||||
<div id="result"></div>
|
||||
</form>
|
||||
|
||||
{% if agent %}
|
||||
<section class="card">
|
||||
<h2>Chat testen</h2>
|
||||
<form hx-post="/api/chat/{{ agent.id }}" hx-target="#chat-result" hx-swap="innerHTML" class="form">
|
||||
<textarea name="message" rows="3" required placeholder="Hallo, was kannst du?"></textarea>
|
||||
<button type="submit" class="btn">Senden</button>
|
||||
</form>
|
||||
<div id="chat-result"></div>
|
||||
</section>
|
||||
{% endif %}
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Agents – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Agents</h1>
|
||||
<a href="/agents/new" class="btn btn-primary">+ Neuer Agent</a>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<p><small><strong>Hinweis:</strong> Code-Agents (definiert in <code>agents/*.py</code>) werden beim Start in die DB synchronisiert. Du kannst sie hier anpassen oder neue Agents rein in der DB anlegen.</small></p>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>{{ agents|length }} Agent(s)</h2>
|
||||
{% if agents %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr><th>ID</th><th>Name</th><th>Quelle</th><th>Tools</th><th>Status</th><th>Aktionen</th></tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for agent in agents %}
|
||||
<tr>
|
||||
<td><code>{{ agent.id }}</code></td>
|
||||
<td>{{ agent.name }}</td>
|
||||
<td>
|
||||
{% if agent.source == "code" %}
|
||||
<span class="badge badge-blue">Code</span>
|
||||
{% else %}
|
||||
<span class="badge">DB</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><small>{{ agent.allowed_tools|length }} Tool(s)</small></td>
|
||||
<td>
|
||||
{% if agent.enabled %}
|
||||
<span class="badge badge-green">aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<a href="/agents/{{ agent.id }}" class="btn btn-sm">Edit</a>
|
||||
{% if agent.enabled %}
|
||||
<button hx-post="/api/agents/{{ agent.id }}/disable" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm">Disable</button>
|
||||
{% else %}
|
||||
<button hx-post="/api/agents/{{ agent.id }}/enable" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm">Enable</button>
|
||||
{% endif %}
|
||||
<button hx-delete="/api/agents/{{ agent.id }}" hx-confirm="Wirklich löschen?" hx-swap="none" hx-on::after-request="location.reload()" class="btn btn-sm btn-danger">Delete</button>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Keine Agents vorhanden. <a href="/agents/new">Ersten Agent anlegen</a></p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Audit-Log – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Audit-Log</h1>
|
||||
<p class="subtitle">{{ entries|length }} Einträge (neueste zuerst)</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
{% if entries %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Zeitstempel</th>
|
||||
<th>Agent</th>
|
||||
<th>Aktion</th>
|
||||
<th>Target</th>
|
||||
<th>Dauer (ms)</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for entry in entries %}
|
||||
<tr>
|
||||
<td><small>{{ entry.timestamp }}</small></td>
|
||||
<td><code>{{ entry.agent_id or '-' }}</code></td>
|
||||
<td><span class="badge">{{ entry.action }}</span></td>
|
||||
<td><code>{{ entry.target or '-' }}</code></td>
|
||||
<td>{{ entry.duration_ms or '-' }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<p class="empty-state">Noch keine Audit-Einträge.</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,30 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="de">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>{% block title %}Agent Platform{% endblock %}</title>
|
||||
<link rel="stylesheet" href="/static/style.css">
|
||||
<script src="https://unpkg.com/htmx.org@2.0.4"></script>
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar">
|
||||
<div class="nav-brand">⚡ Agent Platform</div>
|
||||
<div class="nav-links">
|
||||
<a href="/" class="nav-link">Dashboard</a>
|
||||
<a href="/agents" class="nav-link">Agents</a>
|
||||
<a href="/tools" class="nav-link">Tools</a>
|
||||
<a href="/audit" class="nav-link">Audit</a>
|
||||
</div>
|
||||
<div class="nav-user">👤 {{ user_id }}</div>
|
||||
</nav>
|
||||
|
||||
<main class="container">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<footer class="footer">
|
||||
<small>Agent Platform v0.1.0 · LiteLLM · Pydantic AI · MCP</small>
|
||||
</footer>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,36 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Chat – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Chat</h1>
|
||||
<p class="subtitle">Konversation: <code>{{ conv_id }}</code></p>
|
||||
</div>
|
||||
|
||||
<section class="card chat-container">
|
||||
<div id="messages" class="messages">
|
||||
{% for msg in messages %}
|
||||
<div class="message message-{{ msg.role }}">
|
||||
<div class="message-role">{{ msg.role }}</div>
|
||||
<div class="message-content">{{ msg.content }}</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% if not messages %}
|
||||
<p class="empty-state">Noch keine Nachrichten.</p>
|
||||
{% endif %}
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<form hx-post="" hx-target="#messages" hx-swap="beforeend" hx-on::after-request="this.reset()" class="form" id="chat-form">
|
||||
<input type="hidden" name="conv_id" value="{{ conv_id }}">
|
||||
<div class="form-row">
|
||||
<div class="form-group" style="flex: 1;">
|
||||
<textarea name="message" rows="2" required placeholder="Nachricht eingeben..." autofocus></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Senden</button>
|
||||
</div>
|
||||
</form>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,69 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Dashboard – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>Dashboard</h1>
|
||||
<p class="subtitle">Übersicht aller Agents und recent Aktivitäten</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Agents ({{ agents|length }})</h2>
|
||||
{% if agents %}
|
||||
<table class="table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>Name</th>
|
||||
<th>Beschreibung</th>
|
||||
<th>Status</th>
|
||||
<th>Aktion</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for agent in agents %}
|
||||
<tr>
|
||||
<td><code>{{ agent.id }}</code></td>
|
||||
<td>{{ agent.name }}</td>
|
||||
<td>{{ agent.description }}</td>
|
||||
<td>
|
||||
{% if agent.enabled %}
|
||||
<span class="badge badge-green">aktiv</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">inaktiv</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td><a href="/agents/{{ agent.id }}" class="btn btn-sm">Details</a></td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
{% else %}
|
||||
<div class="empty-state">
|
||||
<p>Noch keine Agents angelegt.</p>
|
||||
<a href="/agents" class="btn btn-primary">Ersten Agent erstellen</a>
|
||||
</div>
|
||||
{% endif %}
|
||||
</section>
|
||||
|
||||
<section class="card">
|
||||
<h2>Schnellstart</h2>
|
||||
<form hx-post="/api/chat" hx-target="#chat-result" class="form">
|
||||
<div class="form-group">
|
||||
<label for="agent_id">Agent wählen</label>
|
||||
<select name="agent_id" id="agent_id" required>
|
||||
{% for agent in agents %}
|
||||
<option value="{{ agent.id }}">{{ agent.name }}</option>
|
||||
{% endfor %}
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="message">Nachricht</label>
|
||||
<textarea name="message" id="message" rows="3" required placeholder="Deine Nachricht an den Agent..."></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-primary">Senden</button>
|
||||
</form>
|
||||
<div id="chat-result"></div>
|
||||
</section>
|
||||
{% endblock %}
|
||||
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}MCP Tools – Agent Platform{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="page-header">
|
||||
<h1>MCP Tool-Server</h1>
|
||||
<p class="subtitle">
|
||||
Status:
|
||||
{% if reachable %}
|
||||
<span class="badge badge-green">erreichbar</span>
|
||||
{% else %}
|
||||
<span class="badge badge-red">nicht erreichbar</span>
|
||||
{% if error %}<br><small>{{ error }}</small>{% endif %}
|
||||
{% endif %}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<section class="card">
|
||||
<h2>Verfügbare Tools ({{ tools|length }})</h2>
|
||||
{% if tools %}
|
||||
<div class="tools-grid">
|
||||
{% for tool in tools %}
|
||||
<div class="tool-card">
|
||||
<h3><code>{{ tool.name }}</code></h3>
|
||||
<p>{{ tool.description }}</p>
|
||||
{% if tool.inputSchema %}
|
||||
<details>
|
||||
<summary>Schema</summary>
|
||||
<pre><code>{{ tool.inputSchema | tojson(indent=2) }}</code></pre>
|
||||
</details>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<p class="empty-state">
|
||||
{% if reachable %}
|
||||
MCP-Server erreichbar, aber keine Tools registriert.
|
||||
{% else %}
|
||||
MCP-Server nicht erreichbar. Prüfe die Konfiguration.
|
||||
{% endif %}
|
||||
</p>
|
||||
{% endif %}
|
||||
</section>
|
||||
{% endblock %}
|
||||
Reference in New Issue
Block a user