feat: v6.6.0 — Agent phase 5 : API publique agent (/api/v2/agents, run synchrone JSON) + marketplace skills (export/import portable + galerie de 6 presets, palette / du panneau) + webhooks agent.run.started/failed · 764 tests verts
This commit is contained in:
+3
-1
@@ -39,6 +39,7 @@ from app.routers import (
|
||||
workspace,
|
||||
)
|
||||
from app.routers.api_v2 import router as api_v2_router
|
||||
from app.routers.api_v2_agent import router as api_v2_agent_router
|
||||
from app.routers.automations import router as automations_router
|
||||
from app.routers.collaboration import router as collaboration_router
|
||||
from app.routers.emoji import router as emoji_router
|
||||
@@ -122,7 +123,7 @@ async def lifespan(_app: FastAPI):
|
||||
|
||||
app = FastAPI(
|
||||
title="FlowDeck",
|
||||
version="6.5.1",
|
||||
version="6.6.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
@@ -169,6 +170,7 @@ app.include_router(permissions_router)
|
||||
app.include_router(web_clipper_api_router)
|
||||
app.include_router(web_clipper_router)
|
||||
app.include_router(api_v2_router)
|
||||
app.include_router(api_v2_agent_router)
|
||||
|
||||
app.mount("/static", StaticFiles(directory="static"), name="static")
|
||||
|
||||
|
||||
@@ -14,6 +14,7 @@ from fastapi.responses import StreamingResponse
|
||||
from app.auth.session import get_current_user
|
||||
from app.config import settings
|
||||
from app.db import get_conn
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.llm_client import PROVIDER_MODELS, PROVIDERS, LLMClient
|
||||
from app.services.llm_config import (
|
||||
@@ -524,6 +525,81 @@ async def apply_skill(request: Request, skill_id: int):
|
||||
return {"conversation_id": cur.lastrowid, "skill": skill["name"], "status": "ready"}
|
||||
|
||||
|
||||
# ── Skill marketplace (v6.6.0, Agent phase 5) ──
|
||||
# Galerie de presets + export/import portable — même implémentation que
|
||||
# l'API publique (/api/v2/skills/*), via app.services.skill_gallery.
|
||||
|
||||
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery(request: Request):
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/agent/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill(request: Request, slug: str):
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(status_code=404, detail=f"Skill inconnue dans la galerie: {slug}")
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill(request: Request):
|
||||
"""Importe un skill portable (JSON exporté depuis une autre instance)."""
|
||||
user_id = await _current_user_id(request)
|
||||
ws = await _workspace_id(request)
|
||||
body = await request.json() if request.headers.get("content-type") else {}
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user_id,
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
return {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row}
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill(request: Request, skill_id: int):
|
||||
"""Document JSON portable — à rejouer sur /api/agent/skills/import."""
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill(request: Request, skill_id: int):
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(status_code=404, detail="Skill introuvable")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Mentions (commande @ / +) & feedback (boutons 👍 / 👎) ──
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,657 @@
|
||||
"""FlowDeck — Public API v2 : Agent & Skill marketplace (v6.6.0, phase 5).
|
||||
|
||||
Thin Bearer+scopes wrappers over the existing agent logic (AgentEngine,
|
||||
`agent_skills`, the gallery service) so third-party integrations can drive
|
||||
FlowDeck Agent without a browser session:
|
||||
|
||||
* ``/api/v2/agents`` — agents CRUD, conversations, synchronous runs (JSON,
|
||||
the SSE stream stays an internal/UI concern), audit journal & rollback.
|
||||
* ``/api/v2/skills`` — the skill marketplace: CRUD, portable export/import and
|
||||
the built-in gallery of installable presets.
|
||||
|
||||
Rules honoured (see docs/API_GUIDE_V6.md): one code path (the engine and the
|
||||
gallery service are reused, never re-implemented), JSON only, no secrets or
|
||||
internal columns, rate limit + audit + idempotency on every mutation.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import time
|
||||
|
||||
from fastapi import APIRouter, Header, HTTPException, Request
|
||||
from fastapi.responses import JSONResponse
|
||||
|
||||
from app.db import get_conn
|
||||
from app.routers.agent import _default_agent
|
||||
from app.services import skill_gallery
|
||||
from app.services.agent_engine import AgentEngine, undo_action
|
||||
from app.services.api_v2_helpers import (
|
||||
audit_log,
|
||||
check_idempotency,
|
||||
check_v2_rate_limit,
|
||||
get_bearer_user,
|
||||
has_scope,
|
||||
paginate_headers,
|
||||
parse_pagination,
|
||||
row_to_dict,
|
||||
store_idempotency,
|
||||
)
|
||||
from app.services.llm_client import LLMClient
|
||||
from app.services.llm_config import get_user_llm_key
|
||||
|
||||
router = APIRouter(prefix="/api/v2", tags=["api-v2-agent"])
|
||||
|
||||
|
||||
# ── Shared guards ──────────────────────────────────────────────────────────
|
||||
|
||||
def _guard(request: Request, authorization: str | None, *, write: bool = False) -> dict:
|
||||
"""Bearer auth + per-token rate limit (+ write scope when required)."""
|
||||
user = get_bearer_user(request, authorization)
|
||||
ip = request.client.host if request.client else "unknown"
|
||||
if not check_v2_rate_limit(user.get("_token_hash"), ip):
|
||||
raise HTTPException(429, "Rate limit exceeded: 300 req/min per token")
|
||||
if write and not has_scope(user.get("_token_scopes"), "write"):
|
||||
raise HTTPException(403, "Insufficient scope. Required: write")
|
||||
return user
|
||||
|
||||
|
||||
async def _json_body(request: Request) -> dict:
|
||||
try:
|
||||
body = await request.json()
|
||||
except Exception: # noqa: BLE001
|
||||
return {}
|
||||
return body if isinstance(body, dict) else {}
|
||||
|
||||
|
||||
def _workspace_of(request: Request, body: dict | None = None) -> int | None:
|
||||
"""Workspace resolution mirrors the internal agent router: explicit param
|
||||
wins, then the token's own workspace, else NULL (shared/global scope)."""
|
||||
body = body or {}
|
||||
raw = body.get("workspace_id") or request.query_params.get("workspace_id")
|
||||
if raw is None:
|
||||
return None
|
||||
try:
|
||||
return int(raw)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
|
||||
|
||||
def _owned_conversation(conn, conversation_id: int, user_id: int):
|
||||
"""Conversation visible to this token's user (ownership is enforced here,
|
||||
unlike the session router where the browser is already authenticated)."""
|
||||
return conn.execute(
|
||||
"SELECT * FROM agent_conversations WHERE id=? AND user_id=?",
|
||||
(conversation_id, user_id),
|
||||
).fetchone()
|
||||
|
||||
|
||||
def _engine_for(user_id: int, workspace_id: int | None, provider: str | None) -> AgentEngine:
|
||||
engine = AgentEngine(user_id, workspace_id=workspace_id)
|
||||
if provider:
|
||||
user_key = get_user_llm_key(user_id, provider)
|
||||
if user_key and user_key.get("api_key"):
|
||||
engine.llm = LLMClient(
|
||||
provider=provider,
|
||||
api_key=user_key["api_key"],
|
||||
api_base=user_key.get("api_base") or None,
|
||||
)
|
||||
else:
|
||||
engine.llm = LLMClient(provider=provider)
|
||||
return engine
|
||||
|
||||
|
||||
# ── Agents ─────────────────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/agents")
|
||||
async def list_agents_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
_default_agent(conn, user["id"])
|
||||
clause = "WHERE workspace_id IS ? OR workspace_id=?"
|
||||
total = conn.execute(f"SELECT COUNT(*) FROM agents {clause}", (ws, ws)).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
f"SELECT * FROM agents {clause} ORDER BY agent_type, name LIMIT ? OFFSET ?",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"agents": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents")
|
||||
async def create_agent_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
name = (body.get("name") or "").strip() or "Custom Agent"
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
try:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agents (workspace_id, name, icon, agent_type, description,
|
||||
system_instructions, model, scope_json, trigger_json, approval_mode, created_by)
|
||||
VALUES (?,?,?,?,?,?,?,?,?,?,?)""",
|
||||
(ws, name, body.get("icon", "🤖"), body.get("agent_type", "custom"),
|
||||
body.get("description", ""), body.get("system_instructions", ""),
|
||||
body.get("model", "gpt-4o"),
|
||||
json.dumps(body.get("scope", {})), json.dumps(body.get("trigger", {})),
|
||||
body.get("approval_mode", "auto"), user["id"]),
|
||||
)
|
||||
conn.commit()
|
||||
agent_id = cur.lastrowid
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(409, f"Cannot create agent: {exc}") from exc
|
||||
audit_log(user, "agent.create", "agent", agent_id, name, request)
|
||||
data = {"id": agent_id, "name": name, "status": "created", "agent": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/{agent_id}")
|
||||
async def get_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.put("/agents/{agent_id}")
|
||||
async def update_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not existing:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
sets, params = [], []
|
||||
for col in ("name", "icon", "description", "system_instructions", "model",
|
||||
"approval_mode", "is_active"):
|
||||
if col in body:
|
||||
sets.append(f"{col}=?")
|
||||
params.append(body[col])
|
||||
if "scope" in body:
|
||||
sets.append("scope_json=?")
|
||||
params.append(json.dumps(body["scope"]))
|
||||
if "trigger" in body:
|
||||
sets.append("trigger_json=?")
|
||||
params.append(json.dumps(body["trigger"]))
|
||||
if sets:
|
||||
params.append(agent_id)
|
||||
conn.execute(f"UPDATE agents SET {', '.join(sets)} WHERE id=?", params)
|
||||
conn.commit()
|
||||
audit_log(user, "agent.update", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "updated"}
|
||||
|
||||
|
||||
@router.delete("/agents/{agent_id}")
|
||||
async def delete_agent_v2(agent_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone():
|
||||
raise HTTPException(404, "Agent not found")
|
||||
conn.execute("DELETE FROM agents WHERE id=?", (agent_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.delete", "agent", agent_id, "", request)
|
||||
return {"id": agent_id, "status": "deleted"}
|
||||
|
||||
|
||||
# ── Conversations (static paths declared before /agents/{agent_id}) ────────
|
||||
|
||||
@router.get("/agents/conversations")
|
||||
async def list_conversations_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_conversations WHERE user_id=?", (user["id"],)
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_conversations WHERE user_id=?
|
||||
ORDER BY updated_at DESC LIMIT ? OFFSET ?""",
|
||||
(user["id"], limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"conversations": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/agents/conversations")
|
||||
async def create_conversation_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
agent_id = body.get("agent_id")
|
||||
with get_conn() as conn:
|
||||
if agent_id is not None:
|
||||
agent = conn.execute("SELECT id FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
agent_id = agent["id"]
|
||||
else:
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json, provider, model)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(agent_id, user["id"], body.get("title") or "New conversation",
|
||||
json.dumps({"workspace_id": ws}),
|
||||
body.get("provider") or "", body.get("model") or ""),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_conversations WHERE id=?", (conv_id,)).fetchone()
|
||||
audit_log(user, "agent.conversation.create", "agent_conversation", conv_id, "", request)
|
||||
data = {"id": conv_id, "status": "created", "conversation": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201)
|
||||
return JSONResponse(content=data, status_code=201)
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}")
|
||||
async def get_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
messages = conn.execute(
|
||||
"SELECT * FROM agent_messages WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"conversation": row_to_dict(conv), "messages": [row_to_dict(m) for m in messages]}
|
||||
|
||||
|
||||
@router.delete("/agents/conversations/{conversation_id}")
|
||||
async def delete_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
conn.execute("DELETE FROM agent_conversations WHERE id=?", (conversation_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "agent.conversation.delete", "agent_conversation", conversation_id, "", request)
|
||||
return {"id": conversation_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.get("/agents/conversations/{conversation_id}/actions")
|
||||
async def list_actions_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
if not _owned_conversation(conn, conversation_id, user["id"]):
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
rows = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
return {"actions": [row_to_dict(r) for r in rows]}
|
||||
|
||||
|
||||
@router.post("/agents/actions/{action_id}/undo")
|
||||
async def undo_action_v2(action_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute(
|
||||
"""SELECT a.id FROM agent_actions a
|
||||
JOIN agent_conversations c ON c.id = a.conversation_id
|
||||
WHERE a.id=? AND c.user_id=?""",
|
||||
(action_id, user["id"]),
|
||||
).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Action not found")
|
||||
try:
|
||||
undo_action(action_id)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(500, f"Rollback failed: {exc}") from exc
|
||||
audit_log(user, "agent.action.undo", "agent_action", action_id, "", request)
|
||||
return {"id": action_id, "status": "reverted"}
|
||||
|
||||
|
||||
# ── Runs (JSON — the SSE stream stays internal) ────────────────────────────
|
||||
|
||||
def _collect_run_events(events: list[dict]) -> dict:
|
||||
"""Aggregate an engine event stream into a JSON run result.
|
||||
|
||||
Engine events are flat (``{"type": "final", "content": ...}``), the same
|
||||
shape the SSE panel consumes.
|
||||
"""
|
||||
final = None
|
||||
reasoning = []
|
||||
actions = []
|
||||
error = None
|
||||
for ev in events:
|
||||
etype = ev.get("type")
|
||||
if etype == "final":
|
||||
final = ev.get("content") or final
|
||||
elif etype == "reasoning":
|
||||
reasoning.append(ev.get("content") or "")
|
||||
elif etype == "action":
|
||||
actions.append({k: v for k, v in ev.items() if k != "type"})
|
||||
elif etype == "error":
|
||||
error = ev.get("message") or "run failed"
|
||||
return {
|
||||
"status": "failed" if error else "completed",
|
||||
"final": final,
|
||||
"error": error,
|
||||
"reasoning": reasoning,
|
||||
"actions": actions,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/agents/conversations/{conversation_id}/run")
|
||||
async def run_conversation_v2(conversation_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Synchronous agent run: buffers the engine stream and returns JSON.
|
||||
|
||||
Third parties get one HTTP round-trip instead of an SSE subscription; the
|
||||
same AgentEngine, permissions, journal and webhooks are used as the UI.
|
||||
"""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
objective = (body.get("message") or body.get("objective") or "").strip()
|
||||
if not objective:
|
||||
raise HTTPException(400, "message is required")
|
||||
|
||||
with get_conn() as conn:
|
||||
conv = _owned_conversation(conn, conversation_id, user["id"])
|
||||
if not conv:
|
||||
raise HTTPException(404, "Conversation not found")
|
||||
eff_provider = body.get("provider") or conv["provider"] or None
|
||||
eff_model = body.get("model") or conv["model"] or None
|
||||
if body.get("provider") is not None or body.get("model") is not None:
|
||||
conn.execute(
|
||||
"UPDATE agent_conversations SET provider=?, model=?, updated_at=CURRENT_TIMESTAMP WHERE id=?",
|
||||
(body.get("provider", conv["provider"] or ""),
|
||||
body.get("model", conv["model"] or ""), conversation_id),
|
||||
)
|
||||
conn.commit()
|
||||
conv_context = {}
|
||||
try:
|
||||
conv_context = json.loads(conv["context_json"] or "{}") or {}
|
||||
except (TypeError, ValueError):
|
||||
conv_context = {}
|
||||
|
||||
ws = _workspace_of(request, body)
|
||||
if ws is None:
|
||||
ws = conv_context.get("workspace_id")
|
||||
|
||||
engine = _engine_for(user["id"], ws, eff_provider)
|
||||
started = time.time()
|
||||
events = [
|
||||
ev async for ev in engine.run(
|
||||
conversation_id, objective,
|
||||
model=eff_model,
|
||||
mentions=body.get("mentions"),
|
||||
files=body.get("files"),
|
||||
skill_id=body.get("skill_id"),
|
||||
skill_ids=body.get("skill_ids"),
|
||||
extra_context=body.get("context"),
|
||||
)
|
||||
]
|
||||
result = _collect_run_events(events)
|
||||
with get_conn() as conn:
|
||||
actions = conn.execute(
|
||||
"SELECT * FROM agent_actions WHERE conversation_id=? ORDER BY created_at, id",
|
||||
(conversation_id,),
|
||||
).fetchall()
|
||||
payload = {
|
||||
"conversation_id": conversation_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": [row_to_dict(a) for a in actions],
|
||||
"events": events,
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.run", "agent_conversation", conversation_id, objective[:200], request)
|
||||
status_code = 200 if result["status"] == "completed" else 500
|
||||
data = payload
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, status_code)
|
||||
return JSONResponse(content=data, status_code=status_code)
|
||||
|
||||
|
||||
@router.post("/agents/{agent_id}/trigger")
|
||||
async def trigger_agent_v2(agent_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Fire a custom agent from an external integration (JSON, synchronous)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
agent = conn.execute("SELECT * FROM agents WHERE id=?", (agent_id,)).fetchone()
|
||||
if not agent:
|
||||
raise HTTPException(404, "Agent not found")
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], f"Run: {agent['name']}", json.dumps({"workspace_id": ws})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
|
||||
objective = (agent["system_instructions"] or "").strip() or f"Exécute l'agent « {agent['name']} »."
|
||||
if body.get("message"):
|
||||
objective = f"{objective}\n\n{body['message']}"
|
||||
engine = _engine_for(user["id"], ws, agent["model"] or None)
|
||||
started = time.time()
|
||||
events = [ev async for ev in engine.run(conv_id, objective, model=agent["model"])]
|
||||
result = _collect_run_events(events)
|
||||
payload = {
|
||||
"conversation_id": conv_id,
|
||||
"agent_id": agent_id,
|
||||
"status": result["status"],
|
||||
"final": result["final"],
|
||||
"error": result["error"],
|
||||
"reasoning": result["reasoning"],
|
||||
"actions": result["actions"],
|
||||
"duration_ms": int((time.time() - started) * 1000),
|
||||
}
|
||||
audit_log(user, "agent.trigger", "agent", agent_id, objective[:200], request)
|
||||
return JSONResponse(content=payload, status_code=200 if result["status"] == "completed" else 500)
|
||||
|
||||
|
||||
# ── Skill marketplace ──────────────────────────────────────────────────────
|
||||
|
||||
@router.get("/skills")
|
||||
async def list_skills_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
limit, offset = parse_pagination(request)
|
||||
ws = _workspace_of(request)
|
||||
with get_conn() as conn:
|
||||
total = conn.execute(
|
||||
"SELECT COUNT(*) FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?",
|
||||
(ws, ws),
|
||||
).fetchone()[0]
|
||||
rows = conn.execute(
|
||||
"""SELECT * FROM agent_skills WHERE workspace_id IS ? OR workspace_id=?
|
||||
ORDER BY name LIMIT ? OFFSET ?""",
|
||||
(ws, ws, limit, offset),
|
||||
).fetchall()
|
||||
return JSONResponse(
|
||||
content={"skills": [row_to_dict(r) for r in rows], "total": total,
|
||||
"limit": limit, "offset": offset},
|
||||
headers=paginate_headers(total),
|
||||
)
|
||||
|
||||
|
||||
@router.post("/skills")
|
||||
async def create_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(
|
||||
{k: body[k] for k in ("name", "description", "prompt_template", "allowed_tools")
|
||||
if k in body} | {"format": skill_gallery.EXPORT_FORMAT}
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.create", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "created" if created else "updated", "skill": row_to_dict(row) if row else {}}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
# Gallery & import are static segments: declared before /skills/{skill_id} so
|
||||
# FastAPI never tries to coerce "gallery" into an int path parameter.
|
||||
@router.get("/skills/gallery")
|
||||
async def skills_gallery_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
presets = skill_gallery.list_gallery()
|
||||
return {"gallery": presets, "total": len(presets),
|
||||
"install": "POST /api/v2/skills/gallery/{slug}/install"}
|
||||
|
||||
|
||||
@router.post("/skills/gallery/{slug}/install")
|
||||
async def install_gallery_skill_v2(slug: str, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
preset = skill_gallery.get_gallery(slug)
|
||||
if not preset:
|
||||
raise HTTPException(404, f"Unknown gallery skill: {slug}")
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
skill_gallery.parse_payload(preset),
|
||||
workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite", True)),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.gallery.install", "skill", row.get("id"), slug, request)
|
||||
data = {"slug": slug, "id": row.get("id"), "name": row.get("name"),
|
||||
"status": "installed" if created else "updated", "skill": row_to_dict(row)}
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.post("/skills/import")
|
||||
async def import_skill_v2(request: Request, authorization: str | None = Header(default=None)):
|
||||
"""Import a portable skill document (from another FlowDeck instance)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
idem = check_idempotency(request, user["id"])
|
||||
if idem:
|
||||
return JSONResponse(content=idem["data"], status_code=idem["status"])
|
||||
body = await _json_body(request)
|
||||
payload = body.get("payload") if isinstance(body.get("payload"), dict) else body
|
||||
try:
|
||||
fields = skill_gallery.parse_payload(payload)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(400, str(exc)) from exc
|
||||
ws = _workspace_of(request, body)
|
||||
try:
|
||||
row, created = skill_gallery.upsert_skill(
|
||||
fields, workspace_id=ws, created_by=user["id"],
|
||||
overwrite=bool(body.get("overwrite")),
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(409, str(exc)) from exc
|
||||
audit_log(user, "skill.import", "skill", row.get("id"), fields["name"], request)
|
||||
data = {"id": row.get("id"), "name": fields["name"],
|
||||
"status": "imported" if created else "updated", "skill": row_to_dict(row)}
|
||||
key = (request.headers.get("Idempotency-Key") or "").strip()
|
||||
if key:
|
||||
store_idempotency(key, user["id"], data, 201 if created else 200)
|
||||
return JSONResponse(content=data, status_code=201 if created else 200)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}")
|
||||
async def get_skill_v2(skill_id: int, request: Request, authorization: str | None = Header(default=None)):
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return row_to_dict(row)
|
||||
|
||||
|
||||
@router.get("/skills/{skill_id}/export")
|
||||
async def export_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Portable JSON document — POST it to /api/v2/skills/import elsewhere."""
|
||||
_guard(request, authorization)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
return skill_gallery.export_skill(row)
|
||||
|
||||
|
||||
@router.delete("/skills/{skill_id}")
|
||||
async def delete_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
user = _guard(request, authorization, write=True)
|
||||
with get_conn() as conn:
|
||||
row = conn.execute("SELECT name FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not row:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
conn.execute("DELETE FROM agent_skills WHERE id=?", (skill_id,))
|
||||
conn.commit()
|
||||
audit_log(user, "skill.delete", "skill", skill_id, row["name"] or "", request)
|
||||
return {"id": skill_id, "status": "deleted"}
|
||||
|
||||
|
||||
@router.post("/skills/{skill_id}/apply")
|
||||
async def apply_skill_v2(skill_id: int, request: Request,
|
||||
authorization: str | None = Header(default=None)):
|
||||
"""Open a conversation pre-loaded with the skill (ready to run)."""
|
||||
user = _guard(request, authorization, write=True)
|
||||
body = await _json_body(request)
|
||||
ws = _workspace_of(request, body)
|
||||
with get_conn() as conn:
|
||||
skill = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
if not skill:
|
||||
raise HTTPException(404, "Skill not found")
|
||||
agent_id = _default_agent(conn, user["id"])["id"]
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_conversations (agent_id, user_id, title, context_json)
|
||||
VALUES (?,?,?,?)""",
|
||||
(agent_id, user["id"], skill["name"],
|
||||
json.dumps({"workspace_id": ws if ws is not None else skill["workspace_id"],
|
||||
"skill_id": skill_id})),
|
||||
)
|
||||
conv_id = cur.lastrowid
|
||||
conn.commit()
|
||||
audit_log(user, "skill.apply", "skill", skill_id, skill["name"], request)
|
||||
return JSONResponse(content={"conversation_id": conv_id, "skill": skill["name"],
|
||||
"status": "ready"}, status_code=201)
|
||||
@@ -27,6 +27,20 @@ logger = logging.getLogger(__name__)
|
||||
|
||||
MAX_ITERATIONS = 12
|
||||
|
||||
|
||||
async def _fire_agent_webhook(event: str, payload: dict) -> None:
|
||||
"""Dispatch an outbound agent lifecycle event (never raises).
|
||||
|
||||
Lifecycle: ``agent.run.started`` → ``agent.run.finished`` | ``agent.run.failed``.
|
||||
All three are in the webhook_outbound catalogue, so integrations can subscribe
|
||||
to `agent.*` and drive FlowDeck Agent from outside (Agent phase 5).
|
||||
"""
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh(event, payload)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("%s webhook dispatch failed", event)
|
||||
|
||||
# Compact in-app guide so the LLM can answer « comment faire… ? » questions even
|
||||
# when no document is attached to the conversation (generic help / onboarding).
|
||||
APP_GUIDE = """## Guide de l'utilisateur FlowDeck (sert à répondre aux questions « comment … ? »)
|
||||
@@ -151,6 +165,11 @@ class AgentEngine:
|
||||
|
||||
self._persist_message(conversation_id, "user", objective)
|
||||
self._update_conversation(conversation_id, status="running")
|
||||
await _fire_agent_webhook("agent.run.started", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": model or "",
|
||||
})
|
||||
|
||||
# Update the history title right away (before the run finishes) and
|
||||
# refine it once we have the final answer (_autotitle below).
|
||||
@@ -260,19 +279,20 @@ class AgentEngine:
|
||||
model=used_model, tokens=self._tokens)
|
||||
await self._autotitle(conversation_id, objective, final_text)
|
||||
# v6.4.0: emit agent.run.finished (outbound webhooks only).
|
||||
try:
|
||||
from app.services.webhook_outbound import fire_event as _fire_wh
|
||||
await _fire_wh("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
except Exception: # noqa: BLE001
|
||||
logger.debug("agent.run.finished webhook dispatch failed")
|
||||
await _fire_agent_webhook("agent.run.finished", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"model": used_model,
|
||||
"tokens": self._tokens,
|
||||
})
|
||||
|
||||
except Exception as exc: # noqa: BLE001
|
||||
logger.exception("AgentEngine run failed")
|
||||
await _fire_agent_webhook("agent.run.failed", {
|
||||
"conversation_id": conversation_id,
|
||||
"objective": objective[:500],
|
||||
"error": str(exc)[:500],
|
||||
})
|
||||
yield self._event("error", {"message": f"Erreur interne: {exc}"})
|
||||
finally:
|
||||
self._update_conversation(conversation_id, status="idle")
|
||||
|
||||
@@ -0,0 +1,231 @@
|
||||
"""FlowDeck — Skill marketplace (v6.6.0, Agent phase 5 « Plateforme »).
|
||||
|
||||
Single source of truth for shareable agent skills:
|
||||
|
||||
* **Gallery** — built-in presets shipped with FlowDeck, installable into any
|
||||
workspace with one call (the self-hosted equivalent of a skill marketplace).
|
||||
* **Portable payloads** — a skill exports to a versioned JSON document that any
|
||||
other FlowDeck instance can re-import unchanged.
|
||||
|
||||
Both the internal router (``/api/agent/skills/*``, session cookie) and the
|
||||
public API v2 (``/api/v2/skills/*``, Bearer + scopes) call into this module so
|
||||
there is exactly one implementation of export/import/install.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
from typing import Any
|
||||
|
||||
from app.db import get_conn
|
||||
|
||||
EXPORT_FORMAT = "flowdeck-skill"
|
||||
EXPORT_VERSION = 1
|
||||
|
||||
# ── Gallery presets ────────────────────────────────────────────────────────
|
||||
# `allowed_tools` only references real ToolRegistry names (v4.10 → v5 tool set),
|
||||
# so an installed preset can never expose a tool that does not exist.
|
||||
|
||||
GALLERY: dict[str, dict] = {
|
||||
"rapport-hebdo": {
|
||||
"name": "Rapport hebdo",
|
||||
"icon": "📊",
|
||||
"description": "Agrège les pages modifiées de la semaine et rédige un rapport structuré.",
|
||||
"prompt_template": (
|
||||
"Rédige le rapport hebdomadaire de l'équipe.\n"
|
||||
"1. Repère les pages et documents modifiés cette semaine (search_workspace).\n"
|
||||
"2. Lis les plus significatifs (read_document) et en extrais avancées, blocages, décisions.\n"
|
||||
"3. Crée un document « Rapport hebdo — <date> » (create_document) structuré ainsi : "
|
||||
"Résumé · Faits marquants · Blocages · Plan de la semaine prochaine.\n"
|
||||
"Chaque affirmation doit s'appuyer sur un document lu, jamais sur une supposition."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"compte-rendu-reunion": {
|
||||
"name": "Compte rendu de réunion",
|
||||
"icon": "📝",
|
||||
"description": "Transforme une note brute de réunion en compte rendu avec décisions et tâches.",
|
||||
"prompt_template": (
|
||||
"À partir de la note de réunion fournie (ou demandée) :\n"
|
||||
"1. Crée un document « CR — <titre> » (create_document).\n"
|
||||
"2. Structure : Contexte · Décisions prises · Actions (avec responsable et échéance) · Points ouverts.\n"
|
||||
"3. Ne garde que ce qui est dans la note ; liste explicitement les points manquants."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
"base-crm": {
|
||||
"name": "Base CRM",
|
||||
"icon": "🗂️",
|
||||
"description": "Crée une collection CRM (contacts, statut, dernière interaction) avec une vue board.",
|
||||
"prompt_template": (
|
||||
"Crée une base CRM complète :\n"
|
||||
"1. Collection « CRM » (create_collection) avec propriétés : Société (texte), Contact (texte), "
|
||||
"Statut (sélection : Prospect/Négociation/Gagné/Perdu), Montant (nombre), Dernière interaction (date).\n"
|
||||
"2. Vue board groupée sur Statut (create_view).\n"
|
||||
"3. Trois lignes d'exemple réalistes (create_page).\n"
|
||||
"Termine par le lien/identifiant de la collection créée."
|
||||
),
|
||||
"allowed_tools": ["create_collection", "add_property", "create_view", "create_page"],
|
||||
},
|
||||
"okr": {
|
||||
"name": "Objectifs OKR",
|
||||
"icon": "🎯",
|
||||
"description": "Génère une base d'OKR avec objectifs, résultats clés et progression.",
|
||||
"prompt_template": (
|
||||
"Crée une base « OKR » :\n"
|
||||
"1. Collection avec propriétés : Objectif (texte), Responsable (texte), Période (sélection : T1..T4), "
|
||||
"Progression (nombre 0-100).\n"
|
||||
"2. Collection « Résultats clés » liée à l'objectif (add_relation), avec Critère de succès et Progression.\n"
|
||||
"3. Un jeu d'exemple : 3 objectifs, 2 résultats clés chacun (create_page, create_sub_item).\n"
|
||||
"4. Vue board par période (create_view)."
|
||||
),
|
||||
"allowed_tools": [
|
||||
"create_collection", "add_property", "add_relation",
|
||||
"create_view", "create_page", "create_sub_item",
|
||||
],
|
||||
},
|
||||
"analyse-repo": {
|
||||
"name": "Analyse repo Gitea",
|
||||
"icon": "🛠️",
|
||||
"description": "Analyse les issues d'un dépôt Gitea et produit un dashboard de suivi.",
|
||||
"prompt_template": (
|
||||
"Analyse le dépôt Gitea du workspace :\n"
|
||||
"1. Lis les issues ouvertes (read_gitea_issues) et synchronise l'état (sync_gitea).\n"
|
||||
"2. Regroupe par label/priorité : bloquantes, en cours, à trier.\n"
|
||||
"3. Crée un document « Suivi repo — <dépôt> » (create_document) avec un tableau des issues "
|
||||
"et 3 recommandations de priorisation."
|
||||
),
|
||||
"allowed_tools": ["read_gitea_issues", "sync_gitea", "search_workspace", "create_document", "write_blocks"],
|
||||
},
|
||||
"resume-document": {
|
||||
"name": "Résumé de document",
|
||||
"icon": "📄",
|
||||
"description": "Résume un document long en une page : points clés, chiffres, décisions.",
|
||||
"prompt_template": (
|
||||
"Résume le document fourni (ou demandé) :\n"
|
||||
"1. Lis-le intégralement (read_document).\n"
|
||||
"2. Crée un document « Résumé — <titre> » (create_document) : 5 points clés, chiffres marquants, "
|
||||
"décisions/engagements, questions restées ouvertes.\n"
|
||||
"3. Maximum une page, phrases courtes, aucune reformulation qui change le sens."
|
||||
),
|
||||
"allowed_tools": ["search_workspace", "read_document", "create_document", "write_blocks"],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
# ── Gallery access ─────────────────────────────────────────────────────────
|
||||
|
||||
def list_gallery() -> list[dict]:
|
||||
"""Return every preset with its slug, ready for API responses."""
|
||||
return [{"slug": slug, **preset} for slug, preset in GALLERY.items()]
|
||||
|
||||
|
||||
def get_gallery(slug: str) -> dict | None:
|
||||
preset = GALLERY.get(str(slug or "").strip().lower())
|
||||
return {"slug": slug, **preset} if preset else None
|
||||
|
||||
|
||||
# ── Portable payload (export / import) ─────────────────────────────────────
|
||||
|
||||
def export_skill(row: Any) -> dict:
|
||||
"""Build the portable JSON document for a stored skill row.
|
||||
|
||||
Only shareable fields are included: ids, workspace and author stay local so
|
||||
an import never leaks (nor depends on) the source instance's internals.
|
||||
"""
|
||||
skill = row if isinstance(row, dict) else dict(row)
|
||||
tools = skill.get("allowed_tools_json") or "[]"
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError):
|
||||
tools = []
|
||||
return {
|
||||
"format": EXPORT_FORMAT,
|
||||
"version": EXPORT_VERSION,
|
||||
"skill": {
|
||||
"name": skill.get("name") or "",
|
||||
"description": skill.get("description") or "",
|
||||
"prompt_template": skill.get("prompt_template") or "",
|
||||
"allowed_tools": list(tools) if isinstance(tools, list) else [],
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def parse_payload(payload: Any) -> dict:
|
||||
"""Validate an import payload → normalized skill fields.
|
||||
|
||||
Accepts a full exported document (``{format, version, skill}``) or a bare
|
||||
skill object (``{name, prompt_template, ...}``) for hand-written imports.
|
||||
Raises ``ValueError`` with a human-readable message on invalid input.
|
||||
"""
|
||||
if not isinstance(payload, dict):
|
||||
raise ValueError("Payload JSON attendu (objet)")
|
||||
skill = payload.get("skill") if isinstance(payload.get("skill"), dict) else payload
|
||||
if payload.get("format") and payload.get("format") != EXPORT_FORMAT:
|
||||
raise ValueError(f"Format inconnu: {payload.get('format')} (attendu {EXPORT_FORMAT})")
|
||||
version = payload.get("version")
|
||||
if version is not None and (not isinstance(version, int) or version > EXPORT_VERSION):
|
||||
raise ValueError(f"Version non supportée: {version} (max {EXPORT_VERSION})")
|
||||
|
||||
name = str(skill.get("name") or "").strip()
|
||||
prompt = str(skill.get("prompt_template") or "").strip()
|
||||
if not name:
|
||||
raise ValueError("name est requis")
|
||||
if not prompt:
|
||||
raise ValueError("prompt_template est requis")
|
||||
tools = skill.get("allowed_tools") or []
|
||||
if isinstance(tools, str):
|
||||
try:
|
||||
tools = json.loads(tools)
|
||||
except (TypeError, ValueError) as exc:
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils") from exc
|
||||
if not isinstance(tools, list) or not all(isinstance(t, str) for t in tools):
|
||||
raise ValueError("allowed_tools doit être une liste de noms d'outils")
|
||||
|
||||
return {
|
||||
"name": name[:120],
|
||||
"description": str(skill.get("description") or "")[:500],
|
||||
"prompt_template": prompt,
|
||||
"allowed_tools": tools,
|
||||
}
|
||||
|
||||
|
||||
def upsert_skill(
|
||||
fields: dict,
|
||||
*,
|
||||
workspace_id: int | None,
|
||||
created_by: int | None,
|
||||
overwrite: bool = False,
|
||||
) -> tuple[dict, bool]:
|
||||
"""Insert (or replace) a skill in the current workspace.
|
||||
|
||||
Returns ``(row, created)``. Raises ``ValueError`` when the name already
|
||||
exists and ``overwrite`` is False — callers translate that to HTTP 409.
|
||||
"""
|
||||
with get_conn() as conn:
|
||||
existing = conn.execute(
|
||||
"SELECT * FROM agent_skills WHERE workspace_id IS ? AND name=?",
|
||||
(workspace_id, fields["name"]),
|
||||
).fetchone()
|
||||
if existing and not overwrite:
|
||||
raise ValueError(f"Skill déjà présente dans ce workspace: {fields['name']}")
|
||||
tools_json = json.dumps(fields["allowed_tools"])
|
||||
if existing:
|
||||
conn.execute(
|
||||
"UPDATE agent_skills SET description=?, prompt_template=?, allowed_tools_json=? WHERE id=?",
|
||||
(fields["description"], fields["prompt_template"], tools_json, existing["id"]),
|
||||
)
|
||||
skill_id = existing["id"]
|
||||
else:
|
||||
cur = conn.execute(
|
||||
"""INSERT INTO agent_skills
|
||||
(workspace_id, name, description, prompt_template, allowed_tools_json, created_by)
|
||||
VALUES (?,?,?,?,?,?)""",
|
||||
(workspace_id, fields["name"], fields["description"],
|
||||
fields["prompt_template"], tools_json, created_by),
|
||||
)
|
||||
skill_id = cur.lastrowid
|
||||
conn.commit()
|
||||
row = conn.execute("SELECT * FROM agent_skills WHERE id=?", (skill_id,)).fetchone()
|
||||
data = dict(row) if row else {"id": skill_id}
|
||||
return data, existing is None
|
||||
@@ -500,7 +500,7 @@
|
||||
allProviders: [], providers: [], providerModels: [], llmProvider: 'offline',
|
||||
llmModel: '', llmKeys: [], defaultProvider: 'offline',
|
||||
activeContext: null, ctxPinnedKey: null,
|
||||
contextChips: [], skills: [],
|
||||
contextChips: [], skills: [], gallery: [],
|
||||
mentionOpen: false, mentionItems: [], mentionQuery: '', mentionLoad: false,
|
||||
mentionFocus: -1, _mentionFrom: null, _mentionTimer: null, _atNode: null,
|
||||
cmdFocus: -1, slashDismiss: false,
|
||||
@@ -811,6 +811,42 @@
|
||||
fetch('/api/agent/skills').then(function(r){return r.json()}).then(function(d){
|
||||
self.skills = d.skills || [];
|
||||
}).catch(function(){ self.skills = []; });
|
||||
// Galerie de skills (marketplace, v6.6.0) — presets installables en 1 clic.
|
||||
fetch('/api/agent/skills/gallery').then(function(r){return r.json()}).then(function(d){
|
||||
var installed = {};
|
||||
(self.skills || []).forEach(function(s){ installed[String(s.name).toLowerCase()] = true; });
|
||||
self.gallery = (d.gallery || []).filter(function(g){
|
||||
return !installed[String(g.name).toLowerCase()];
|
||||
});
|
||||
}).catch(function(){ self.gallery = []; });
|
||||
},
|
||||
// Installe un preset de galerie puis l'épingle au contexte (phase 5).
|
||||
installGallerySkill(slug, icon, name){
|
||||
var self = this;
|
||||
fetch('/api/agent/skills/gallery/' + encodeURIComponent(slug) + '/install', {
|
||||
method: 'POST', headers: {'Content-Type': 'application/json'}, body: '{}'
|
||||
}).then(function(r){
|
||||
return r.json().then(function(d){ return {ok: r.ok, d: d}; });
|
||||
}).then(function(res){
|
||||
if(!res.ok){
|
||||
self.toast((res.d && res.d.detail) || 'Installation impossible.', true);
|
||||
return;
|
||||
}
|
||||
self.toast('Skill « ' + (res.d.name || name || slug) + ' » installée.');
|
||||
self.fetchSkills();
|
||||
self._pinDbSkill(res.d.id, res.d.name || name || slug, res.d.icon || icon || '📦');
|
||||
}).catch(function(){ self.toast('Installation impossible (réseau).', true); });
|
||||
},
|
||||
// Épingle un skill enregistré (id DB) dans les chips de contexte.
|
||||
_pinDbSkill(id, name, icon){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === id; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); return; }
|
||||
var pin = {key:'skill-'+id, kind:'skill', icon: icon || '✨',
|
||||
label: String(name || '').replace(/^\//, ''), token:'skill:'+id, skillId:id};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
},
|
||||
loadConversations(){
|
||||
var self = this;
|
||||
@@ -1254,7 +1290,11 @@
|
||||
items.forEach(function(o){ o._i = seq++; out.push(o); });
|
||||
}
|
||||
var skillsPresent = false, admPresent = false;
|
||||
var built = [], dbs = [], adm = [];
|
||||
var built = [], dbs = [], adm = [], gal = [];
|
||||
var installedNames = {};
|
||||
for(var j=0;j<(this.skills||[]).length;j++){
|
||||
installedNames[String(this.skills[j].name).toLowerCase()] = true;
|
||||
}
|
||||
for(var i=0;i<FD_SKILLS.length;i++){
|
||||
if(qmatch(FD_SKILLS[i].cmd)){
|
||||
var s = FD_SKILLS[i];
|
||||
@@ -1267,6 +1307,15 @@
|
||||
dbs.push({key:'db-'+sk.id, cmd:'/'+sk.name, desc:(sk.description || 'Skill enregistré'), icon:'✨', _type:'db', skillId:sk.id});
|
||||
}
|
||||
}
|
||||
// Galerie (marketplace) — presets pas encore installés.
|
||||
for(i=0;i<(this.gallery||[]).length;i++){
|
||||
var g = this.gallery[i];
|
||||
if(installedNames[String(g.name).toLowerCase()]) continue;
|
||||
if(qmatch(g.slug)){
|
||||
gal.push({key:'gal-'+g.slug, cmd:'/'+g.slug, desc:(g.description || 'Skill galerie'),
|
||||
icon:(g.icon || '📦'), _type:'gallery', slug:g.slug, skillName:g.name});
|
||||
}
|
||||
}
|
||||
for(i=0;i<FD_ADMIN_CMDS.length;i++){
|
||||
if(qmatch(FD_ADMIN_CMDS[i].cmd)) adm.push({key:'adm-'+FD_ADMIN_CMDS[i].cmd, cmd:FD_ADMIN_CMDS[i].cmd, desc:FD_ADMIN_CMDS[i].desc});
|
||||
}
|
||||
@@ -1276,7 +1325,11 @@
|
||||
out.push({_sep:'Skills', key:'sep-skills'});
|
||||
pushItems(built.concat(dbs));
|
||||
}
|
||||
if(skillsPresent && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(gal.length){
|
||||
out.push({_sep:'Galerie', key:'sep-gal'});
|
||||
pushItems(gal);
|
||||
}
|
||||
if((skillsPresent || gal.length) && admPresent) out.push({_sep:'Commandes', key:'sep-adm'});
|
||||
if(admPresent) pushItems(adm);
|
||||
this._slashSeq = seq;
|
||||
return out;
|
||||
@@ -1302,17 +1355,14 @@
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'gallery'){
|
||||
this.installGallerySkill(c.slug, c.icon, c.skillName);
|
||||
this.cmdFocus = -1; this.slashDismiss = false;
|
||||
this._focusComposer();
|
||||
return;
|
||||
}
|
||||
if(c._type === 'db'){
|
||||
var exists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.skillId === c.skillId; });
|
||||
if(exists){ this.toast('Ce skill est déjà épinglé au contexte.'); }
|
||||
else {
|
||||
var pin = {key:'skill-'+c.skillId, kind:'skill', icon:c.icon||'✨',
|
||||
label:String(c.cmd).replace(/^\//, ''), token:'skill:'+c.skillId, skillId:c.skillId};
|
||||
this.contextChips.push(pin);
|
||||
this.clearComposer();
|
||||
this._insertToken({token:pin.token, label:pin.label, icon:pin.icon, kind:'skill'});
|
||||
this.toast('Skill épinglé — décrivez votre demande, il sera appliqué à l\u2019envoi.');
|
||||
}
|
||||
this._pinDbSkill(c.skillId, c.cmd, c.icon);
|
||||
} else {
|
||||
var slug = String(c.cmd).replace(/^\/+/, '').toLowerCase().replace(/\s+/g, '-');
|
||||
var builtinExists = this.contextChips.some(function(ch){ return ch.kind === 'skill' && ch.token === 'builtin:' + slug; });
|
||||
|
||||
Reference in New Issue
Block a user