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
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:
+53
-60
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user