添加 Copilot 聊天流式响应功能及相关配置
This commit is contained in:
@@ -49,3 +49,8 @@ KEYCLOAK_PUBLIC_KEY="-----BEGIN PUBLIC KEY-----\n...\n-----END PUBLIC KEY-----"
|
|||||||
KEYCLOAK_ALGORITHM=RS256
|
KEYCLOAK_ALGORITHM=RS256
|
||||||
KEYCLOAK_AUDIENCE="account"
|
KEYCLOAK_AUDIENCE="account"
|
||||||
|
|
||||||
|
# ============================================
|
||||||
|
# Copilot Python Sidecar
|
||||||
|
# ============================================
|
||||||
|
COPILOT_SIDECAR_URL="http://127.0.0.1:8787"
|
||||||
|
COPILOT_STREAM_TIMEOUT_SECONDS=120
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -18,6 +18,7 @@ from app.api.v1.endpoints import (
|
|||||||
user_management, # 新增:用户管理
|
user_management, # 新增:用户管理
|
||||||
audit, # 新增:审计日志
|
audit, # 新增:审计日志
|
||||||
meta,
|
meta,
|
||||||
|
copilot_chat,
|
||||||
)
|
)
|
||||||
from app.api.v1.endpoints.network import (
|
from app.api.v1.endpoints.network import (
|
||||||
general,
|
general,
|
||||||
@@ -110,3 +111,6 @@ api_router.include_router(project_data.router, tags=["Project Data"])
|
|||||||
|
|
||||||
# Extension
|
# Extension
|
||||||
api_router.include_router(extension.router, tags=["Extension"])
|
api_router.include_router(extension.router, tags=["Extension"])
|
||||||
|
|
||||||
|
# Copilot Chat
|
||||||
|
api_router.include_router(copilot_chat.router, prefix="/copilot", tags=["Copilot"])
|
||||||
|
|||||||
@@ -62,6 +62,10 @@ class Settings(BaseSettings):
|
|||||||
KEYCLOAK_ALGORITHM: str = "RS256"
|
KEYCLOAK_ALGORITHM: str = "RS256"
|
||||||
KEYCLOAK_AUDIENCE: str = ""
|
KEYCLOAK_AUDIENCE: str = ""
|
||||||
|
|
||||||
|
# Copilot Sidecar
|
||||||
|
COPILOT_SIDECAR_URL: str = "http://127.0.0.1:8787"
|
||||||
|
COPILOT_STREAM_TIMEOUT_SECONDS: int = 120
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
def SQLALCHEMY_DATABASE_URI(self) -> str:
|
||||||
db_password = quote_plus(self.DB_PASSWORD)
|
db_password = quote_plus(self.DB_PASSWORD)
|
||||||
|
|||||||
@@ -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",
|
||||||
|
},
|
||||||
|
)
|
||||||
@@ -168,3 +168,4 @@ zmq==0.0.0
|
|||||||
pymoo==0.6.1.6
|
pymoo==0.6.1.6
|
||||||
scikit-learn==1.6.1
|
scikit-learn==1.6.1
|
||||||
scipy==1.15.2
|
scipy==1.15.2
|
||||||
|
github-copilot-sdk==0.2.0
|
||||||
Reference in New Issue
Block a user