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:
@@ -17,6 +17,7 @@ from __future__ import annotations
|
||||
|
||||
import json
|
||||
import logging
|
||||
from collections.abc import AsyncIterator
|
||||
from dataclasses import dataclass, field
|
||||
from typing import Any
|
||||
|
||||
@@ -232,3 +233,108 @@ async def _post_json(url: str, headers: dict[str, str] | None, payload: dict[str
|
||||
resp = await client.post(url, headers=headers or {}, json=payload)
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
|
||||
# ── Streaming (SSE token stream) ────────────────────────────────────────
|
||||
|
||||
|
||||
async def stream_completion(
|
||||
messages: list[dict[str, Any]],
|
||||
*,
|
||||
provider: str | None = None,
|
||||
model: str | None = None,
|
||||
temperature: float = 0.3,
|
||||
max_tokens: int = 4096,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Yield content deltas from a chat completion as they arrive.
|
||||
|
||||
Only text content is streamed (no tool calling): this backs the plain
|
||||
``/api/ai/bookslm/chat`` endpoint. The tool-calling ``/agent`` endpoint
|
||||
keeps using :func:`chat_completion` because tool calls need the complete
|
||||
response before they can be executed.
|
||||
"""
|
||||
cfg = _get_provider_config(provider) # type: ignore[arg-type]
|
||||
resolved_model = model or cfg["model"]
|
||||
|
||||
if cfg["name"] == "gemini":
|
||||
stream = _gemini_stream(messages, resolved_model, temperature, max_tokens)
|
||||
else:
|
||||
stream = _openai_stream(messages, cfg, resolved_model, temperature, max_tokens)
|
||||
|
||||
async for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
|
||||
async def _openai_stream(
|
||||
messages: list[dict[str, Any]],
|
||||
cfg: dict[str, Any],
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream an OpenAI-compatible ``/chat/completions`` response."""
|
||||
headers = _build_headers(cfg)
|
||||
url = f"{cfg['base_url']}/chat/completions"
|
||||
payload: dict[str, Any] = {
|
||||
"model": model,
|
||||
"messages": messages,
|
||||
"temperature": temperature,
|
||||
"max_tokens": max_tokens,
|
||||
"stream": True,
|
||||
}
|
||||
|
||||
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, headers=headers, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if raw == "[DONE]":
|
||||
break
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
choices = chunk.get("choices") or []
|
||||
if not choices:
|
||||
continue
|
||||
delta = choices[0].get("delta") or {}
|
||||
content = delta.get("content")
|
||||
if content:
|
||||
yield content
|
||||
|
||||
|
||||
async def _gemini_stream(
|
||||
messages: list[dict[str, Any]],
|
||||
model: str,
|
||||
temperature: float,
|
||||
max_tokens: int,
|
||||
) -> AsyncIterator[str]:
|
||||
"""Stream Gemini's ``streamGenerateContent`` response (SSE)."""
|
||||
cfg = PROVIDERS["gemini"]
|
||||
system, contents = _gemini_contents(messages)
|
||||
payload: dict[str, Any] = {
|
||||
"contents": contents,
|
||||
"generationConfig": {"temperature": temperature, "maxOutputTokens": max_tokens},
|
||||
}
|
||||
if system:
|
||||
payload["system_instruction"] = {"parts": [{"text": system}]}
|
||||
|
||||
url = f"{cfg['base_url']}/models/{model}:streamGenerateContent?alt=sse&key={cfg['api_key']}"
|
||||
async with httpx.AsyncClient(timeout=120.0) as client, client.stream("POST", url, json=payload) as resp:
|
||||
resp.raise_for_status()
|
||||
async for line in resp.aiter_lines():
|
||||
if not line or not line.startswith("data:"):
|
||||
continue
|
||||
raw = line[5:].strip()
|
||||
if not raw:
|
||||
continue
|
||||
try:
|
||||
chunk = json.loads(raw)
|
||||
except json.JSONDecodeError:
|
||||
continue
|
||||
for candidate in chunk.get("candidates") or []:
|
||||
for part in candidate.get("content", {}).get("parts", []):
|
||||
text = part.get("text")
|
||||
if text:
|
||||
yield text
|
||||
|
||||
Reference in New Issue
Block a user