From 21dd393aee3bdafe4426981a715bc80aaa424e1d Mon Sep 17 00:00:00 2001 From: Jiang Date: Mon, 23 Mar 2026 18:03:00 +0800 Subject: [PATCH] =?UTF-8?q?=E6=B7=BB=E5=8A=A0=20Copilot=20=E8=81=8A?= =?UTF-8?q?=E5=A4=A9=E6=B5=81=E5=BC=8F=E5=93=8D=E5=BA=94=E5=8A=9F=E8=83=BD?= =?UTF-8?q?=E5=8F=8A=E7=9B=B8=E5=85=B3=E9=85=8D=E7=BD=AE?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .env.example | 5 + app/api/v1/endpoints/copilot_chat.py | 121 ++++++++++++++++++ app/api/v1/router.py | 4 + app/core/config.py | 4 + copilot-sidecar-python/server.py | 180 +++++++++++++++++++++++++++ requirements.txt | 3 +- 6 files changed, 316 insertions(+), 1 deletion(-) create mode 100644 app/api/v1/endpoints/copilot_chat.py create mode 100644 copilot-sidecar-python/server.py diff --git a/.env.example b/.env.example index 9133314..90d3d1b 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/app/api/v1/endpoints/copilot_chat.py b/app/api/v1/endpoints/copilot_chat.py new file mode 100644 index 0000000..860bd05 --- /dev/null +++ b/app/api/v1/endpoints/copilot_chat.py @@ -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", + }, + ) diff --git a/app/api/v1/router.py b/app/api/v1/router.py index 53a6125..c5c58ae 100644 --- a/app/api/v1/router.py +++ b/app/api/v1/router.py @@ -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"]) diff --git a/app/core/config.py b/app/core/config.py index 7404bd4..81b7496 100644 --- a/app/core/config.py +++ b/app/core/config.py @@ -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) diff --git a/copilot-sidecar-python/server.py b/copilot-sidecar-python/server.py new file mode 100644 index 0000000..edf326e --- /dev/null +++ b/copilot-sidecar-python/server.py @@ -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", + }, + ) diff --git a/requirements.txt b/requirements.txt index 259c4e2..e69742c 100644 --- a/requirements.txt +++ b/requirements.txt @@ -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 \ No newline at end of file +scipy==1.15.2 +github-copilot-sdk==0.2.0 \ No newline at end of file