添加 Copilot 聊天流式响应功能及相关配置

This commit is contained in:
2026-03-23 18:03:00 +08:00
parent b0acfb21ec
commit 21dd393aee
6 changed files with 316 additions and 1 deletions
+5
View File
@@ -49,3 +49,8 @@ KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
KEYCLOAK_ALGORITHM=RS256
KEYCLOAK_AUDIENCE="account"
# ============================================
# Copilot Python Sidecar
# ============================================
COPILOT_SIDECAR_URL="http://127.0.0.1:8787"
COPILOT_STREAM_TIMEOUT_SECONDS=120
+121
View File
@@ -0,0 +1,121 @@
from __future__ import annotations
import json
from typing import AsyncGenerator, Optional
import httpx
from fastapi import APIRouter, Depends, Request, status
from fastapi.responses import StreamingResponse
from pydantic import BaseModel, Field
from app.auth.dependencies import get_current_active_user
from app.core.config import settings
from app.domain.schemas.user import UserInDB
router = APIRouter()
class CopilotChatStreamRequest(BaseModel):
message: str = Field(..., min_length=1, max_length=10000)
conversation_id: Optional[str] = Field(default=None, max_length=128)
def _sse_event(event: str, data: dict) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@router.post(
"/chat/stream",
summary="Copilot 聊天流式响应",
description="向 Python Copilot sidecar 转发请求并通过 SSE 返回增量内容",
)
async def copilot_chat_stream(
payload: CopilotChatStreamRequest,
request: Request,
current_user: UserInDB = Depends(get_current_active_user),
):
timeout = httpx.Timeout(
connect=10.0,
read=float(settings.COPILOT_STREAM_TIMEOUT_SECONDS),
write=10.0,
pool=10.0,
)
sidecar_url = settings.COPILOT_SIDECAR_URL.rstrip("/")
upstream_url = f"{sidecar_url}/chat/stream"
async def event_generator() -> AsyncGenerator[str, None]:
headers: dict[str, str] = {}
auth_header = request.headers.get("authorization")
project_id = request.headers.get("x-project-id")
if auth_header:
headers["authorization"] = auth_header
if project_id:
headers["x-project-id"] = project_id
body = {
"message": payload.message,
"conversationId": payload.conversation_id,
"userId": current_user.username,
}
try:
async with httpx.AsyncClient(timeout=timeout) as client:
async with client.stream(
"POST",
upstream_url,
json=body,
headers=headers,
) as response:
if response.status_code >= 400:
detail_text = await response.aread()
detail = detail_text.decode("utf-8", errors="replace")
yield _sse_event(
"error",
{
"message": "Copilot sidecar request failed",
"status": response.status_code,
"detail": detail,
},
)
return
async for line in response.aiter_lines():
if await request.is_disconnected():
return
yield f"{line}\n"
except httpx.ReadTimeout:
yield _sse_event(
"error",
{
"message": "Copilot stream timeout",
"status": status.HTTP_504_GATEWAY_TIMEOUT,
},
)
except httpx.ConnectError as exc:
yield _sse_event(
"error",
{
"message": "Copilot sidecar unavailable",
"status": status.HTTP_503_SERVICE_UNAVAILABLE,
"detail": str(exc),
},
)
except Exception as exc:
yield _sse_event(
"error",
{
"message": "Unexpected stream proxy error",
"status": status.HTTP_500_INTERNAL_SERVER_ERROR,
"detail": str(exc),
},
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+4
View File
@@ -18,6 +18,7 @@ from app.api.v1.endpoints import (
user_management, # 新增:用户管理
audit, # 新增:审计日志
meta,
copilot_chat,
)
from app.api.v1.endpoints.network import (
general,
@@ -110,3 +111,6 @@ api_router.include_router(project_data.router, tags=["Project Data"])
# Extension
api_router.include_router(extension.router, tags=["Extension"])
# Copilot Chat
api_router.include_router(copilot_chat.router, prefix="/copilot", tags=["Copilot"])
+4
View File
@@ -62,6 +62,10 @@ class Settings(BaseSettings):
KEYCLOAK_ALGORITHM: str = "RS256"
KEYCLOAK_AUDIENCE: str = ""
# Copilot Sidecar
COPILOT_SIDECAR_URL: str = "http://127.0.0.1:8787"
COPILOT_STREAM_TIMEOUT_SECONDS: int = 120
@property
def SQLALCHEMY_DATABASE_URI(self) -> str:
db_password = quote_plus(self.DB_PASSWORD)
+180
View File
@@ -0,0 +1,180 @@
from __future__ import annotations
import asyncio
import json
import os
import time
import uuid
from dataclasses import dataclass
from typing import Any, Optional
from fastapi import FastAPI, Request
from fastapi.responses import JSONResponse, StreamingResponse
from copilot import CopilotClient, PermissionHandler
def _sse(event: str, data: dict[str, Any]) -> str:
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n"
@dataclass
class SessionHolder:
session: Any
last_used_at: float
app = FastAPI(title="TJWater Copilot Python Sidecar")
client: Optional[CopilotClient] = None
sessions: dict[str, SessionHolder] = {}
session_ttl_seconds = int(os.getenv("COPILOT_SESSION_TTL_SECONDS", "1800"))
model = os.getenv("COPILOT_MODEL", "gpt-5.1-codex")
@app.on_event("startup")
async def startup_event() -> None:
global client
client = CopilotClient()
await client.start()
@app.on_event("shutdown")
async def shutdown_event() -> None:
if client is not None:
for holder in sessions.values():
try:
await holder.session.disconnect()
except Exception:
pass
sessions.clear()
await client.stop()
async def _cleanup_sessions() -> None:
now = time.time()
expired = [
sid
for sid, holder in sessions.items()
if now - holder.last_used_at > session_ttl_seconds
]
for sid in expired:
holder = sessions.pop(sid, None)
if holder is None:
continue
try:
await holder.session.disconnect()
except Exception:
pass
async def _get_or_create_session(conversation_id: str):
await _cleanup_sessions()
if conversation_id in sessions:
sessions[conversation_id].last_used_at = time.time()
return sessions[conversation_id].session
if client is None:
raise RuntimeError("Copilot client is not initialized")
session = await client.create_session(
{
"model": model,
"streaming": True,
"on_permission_request": PermissionHandler.approve_all,
}
)
sessions[conversation_id] = SessionHolder(session=session, last_used_at=time.time())
return session
@app.get("/health")
async def health() -> dict[str, Any]:
return {"ok": True, "model": model, "sessions": len(sessions)}
@app.post("/chat/stream")
async def chat_stream(request: Request):
payload = await request.json()
message = payload.get("message")
conversation_id = payload.get("conversationId")
if not isinstance(message, str) or not message.strip():
return JSONResponse(status_code=400, content={"message": "message is required"})
conv_id = (
conversation_id.strip()
if isinstance(conversation_id, str) and conversation_id.strip()
else f"conv-{uuid.uuid4().hex[:10]}"
)
async def event_generator():
session = None
queue: asyncio.Queue[tuple[str, dict[str, Any]]] = asyncio.Queue()
done = asyncio.Event()
error_emitted = False
def on_event(event):
nonlocal error_emitted
event_type = getattr(event.type, "value", str(event.type))
data = getattr(event, "data", None)
if event_type == "assistant.message_delta":
content = getattr(data, "delta_content", "") or ""
if content:
queue.put_nowait(("token", {"conversationId": conv_id, "content": content}))
elif event_type == "assistant.message":
content = getattr(data, "content", "") or ""
if content:
queue.put_nowait(("token", {"conversationId": conv_id, "content": content}))
elif event_type == "session.idle":
queue.put_nowait(("done", {"conversationId": conv_id}))
done.set()
elif event_type == "error":
error_emitted = True
queue.put_nowait(
(
"error",
{
"conversationId": conv_id,
"message": "copilot session error",
"detail": str(data),
},
)
)
done.set()
try:
session = await _get_or_create_session(conv_id)
unsubscribe = session.on(on_event)
try:
await session.send({"prompt": message})
while not done.is_set() or not queue.empty():
if await request.is_disconnected():
return
try:
event_name, event_data = await asyncio.wait_for(queue.get(), timeout=0.2)
yield _sse(event_name, event_data)
except asyncio.TimeoutError:
continue
if not error_emitted:
yield _sse("done", {"conversationId": conv_id})
finally:
unsubscribe()
if conv_id in sessions:
sessions[conv_id].last_used_at = time.time()
except Exception as exc:
yield _sse(
"error",
{
"conversationId": conv_id,
"message": "copilot generation failed",
"detail": str(exc),
},
)
return StreamingResponse(
event_generator(),
media_type="text/event-stream",
headers={
"Cache-Control": "no-cache",
"Connection": "keep-alive",
"X-Accel-Buffering": "no",
},
)
+2 -1
View File
@@ -167,4 +167,5 @@ zipp==3.23.0
zmq==0.0.0
pymoo==0.6.1.6
scikit-learn==1.6.1
scipy==1.15.2
scipy==1.15.2
github-copilot-sdk==0.2.0