feat(ai): services partages A2 + SSE streaming B4 + confirmations UI B5 (#79)
CI / lint (push) Successful in 58s
CI / security (push) Successful in 40s
CI / test (push) Successful in 1m15s
CI / build (push) Successful in 37s
CI / e2e (push) Successful in 10m15s
Desktop Build / build-windows (push) Canceled after 0s
Desktop Build / build-linux (push) Canceled after 0s

This commit is contained in:
2026-09-11 17:06:40 -04:00
parent c55e3e0cbc
commit 4c4e415975
27 changed files with 1465 additions and 386 deletions
+53 -60
View File
@@ -3,13 +3,14 @@
import json
import logging
from pathlib import Path
from typing import Any
from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from backend.agent.loop import run_agent
from backend.ai_chat import chat_completion
from backend.ai_chat import chat_completion, stream_completion
from backend.auth.middleware import check_vault_access, require_auth
from backend.bookslm import (
build_general_system_prompt,
@@ -63,6 +64,16 @@ class BooksLMChatRequest(BaseModel):
default=None,
description="Model name to use for this request. If not set, uses the provider's default model.",
)
confirm: dict[str, Any] | None = Field(
default=None,
description="Pending tool confirmation to apply (two-step propose/apply). "
"Shape: the ``error`` object of a previous ``confirmation`` event.",
)
confirm_messages: list[dict[str, Any]] | None = Field(
default=None,
description="Conversation snapshot returned alongside a ``confirmation`` event, "
"echoed back to resume the agent run.",
)
def _normalize_mode(mode: str | None) -> str:
@@ -169,66 +180,37 @@ async def api_bookslm_chat(
"""
system_prompt = _resolve_system_prompt(req, current_user)
# Call AI provider
from backend.ai import DEFAULT_PROVIDER, PROVIDERS, _call_deepseek_openrouter, _call_gemini
# Build messages with conversation history
messages_text = ""
if req.conversation_history:
for turn in req.conversation_history:
role = turn.get("role", "user")
content = turn.get("content", "")
if role == "user":
messages_text += f"\n\nUtilisateur : {content}"
elif role == "assistant":
messages_text += f"\n\nAssistant : {content}"
# Current message
user_prompt = req.message
if messages_text:
user_prompt = f"Historique de la conversation :{messages_text}\n\nQuestion actuelle : {req.message}"
messages: list[dict[str, Any]] = [{"role": "system", "content": system_prompt}]
for turn in req.conversation_history:
role = turn.get("role")
content = turn.get("content", "")
if role in ("user", "assistant") and content:
messages.append({"role": role, "content": content})
messages.append({"role": "user", "content": req.message})
async def generate_sse():
try:
# Resolve provider: explicit override wins, else default.
# Fall back to first available if the requested one isn't configured.
cfg_name = (req.provider or DEFAULT_PROVIDER).lower()
if cfg_name not in PROVIDERS or not PROVIDERS[cfg_name].get("api_key"):
# Try next available provider
for pname, pcfg in PROVIDERS.items():
if pcfg.get("api_key") and pname != "gemini":
cfg_name = pname
break
else:
# No provider available at all
err = "Aucun fournisseur AI configuré (clés API manquantes)"
error_data = json.dumps({"error": err}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return
# Resolve provider: explicit override wins, else first available.
cfg_name = _resolve_provider_name(req.provider)
if cfg_name is None:
err = "Aucun fournisseur AI configuré (clés API manquantes)"
error_data = json.dumps({"error": err}, ensure_ascii=False)
yield f"event: error\ndata: {error_data}\n\n"
return
# Optional per-request model override
original_model = None
if req.model and cfg_name in PROVIDERS:
original_model = PROVIDERS[cfg_name].get("model")
PROVIDERS[cfg_name]["model"] = req.model
try:
if cfg_name == "gemini":
response = await _call_gemini(user_prompt, system_prompt, temperature=0.3, max_tokens=4096)
else:
response = await _call_deepseek_openrouter(
user_prompt, system_prompt,
provider=cfg_name,
temperature=0.3,
max_tokens=4096,
)
finally:
# Restore the original model so other calls aren't affected
if original_model is not None and cfg_name in PROVIDERS:
PROVIDERS[cfg_name]["model"] = original_model
# Send the full response as a single SSE event
data = json.dumps({"token": response, "provider": cfg_name, "model": req.model or PROVIDERS.get(cfg_name, {}).get("model", "")}, ensure_ascii=False)
yield f"event: message\ndata: {data}\n\n"
# Stream token deltas as they arrive from the provider.
async for token in stream_completion(
messages,
provider=cfg_name,
model=req.model,
temperature=0.3,
max_tokens=4096,
):
data = json.dumps(
{"token": token, "provider": cfg_name, "model": req.model or ""},
ensure_ascii=False,
)
yield f"event: message\ndata: {data}\n\n"
yield "event: done\ndata: {}\n\n"
except Exception as e:
logger.error(f"BooksLM chat error: {e}")
@@ -260,7 +242,9 @@ async def api_bookslm_agent(
Same context as ``/chat`` but the model may call tools (read/search the
vault) through the shared tool layer. Emits one ``tool`` event per executed
tool call, then a final ``message`` event. Mutating tools pause the run with
a ``confirmation`` event (two-step propose/apply).
a ``confirmation`` event (two-step propose/apply) carrying the pending call
and the conversation snapshot; the client resumes by echoing them back in
``confirm`` / ``confirm_messages``.
"""
system_prompt = _resolve_system_prompt(req, current_user)
@@ -295,7 +279,13 @@ async def api_bookslm_agent(
yield f"event: error\ndata: {error_data}\n\n"
return
result = await run_agent(messages, ctx=ctx, llm=_llm)
result = await run_agent(
messages,
ctx=ctx,
llm=_llm,
resume_messages=req.confirm_messages,
confirm_pending=req.confirm,
)
for rec in result.tool_calls:
payload = json.dumps(
@@ -305,7 +295,10 @@ async def api_bookslm_agent(
yield f"event: tool\ndata: {payload}\n\n"
if result.stopped == "confirmation_required":
pending = json.dumps(result.pending or {}, ensure_ascii=False)
pending = json.dumps(
{"pending": result.pending or {}, "messages": result.messages},
ensure_ascii=False,
)
yield f"event: confirmation\ndata: {pending}\n\n"
else:
data = json.dumps(