앱이 기대하는 base-backend 계약(envelope·JWT·/chat/stream SSE)을 그대로 구현. Django 는 로그인·세션 미러(SQLite/PG)·OpenCode 이벤트 번역만 맡고, 답변은 전용 OpenCode 인스턴스(opencode/ workspace, codeassist 에이전트)가 만듦. - accounts: 이메일 로그인, access 60분 / refresh 14일, entra/config 는 501 - chat: 세션 목록·검색·메시지·취소 + POST /chat/stream 어댑터(part.delta→token, idle→usage/done, 클라 끊겨도 턴 감시 태스크가 DB 마무리, 첫 이벤트 60초 타임아웃) - OpenCode 1.18 멀티 프로젝트라 모든 요청에 ?directory= 부착 - docs-lib 에 OpenCode SDK 1.18.6 타입 원본, pytest 32 Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
104 lines
3.9 KiB
Python
104 lines
3.9 KiB
Python
"""
|
|
세션 API — 프론트 `features/snap/api/snap.api.ts` 계약. 전부 본인 세션만.
|
|
|
|
GET /api/v1/chat/sessions?page&limit → SnapSession[] + meta
|
|
POST /api/v1/chat/sessions → SnapSession (OpenCode 세션 생성 + 미러)
|
|
GET /api/v1/chat/sessions/search?query&page&limit → SnapMessage[] + meta (본문 icontains)
|
|
GET /api/v1/chat/sessions/{id}/messages → SnapSessionDetail
|
|
POST /api/v1/chat/sessions/{id}/cancel → null (OpenCode abort + isGenerating 해제)
|
|
스트림(POST /chat/stream)은 stream.py — async 라 DRF 밖.
|
|
"""
|
|
|
|
import logging
|
|
|
|
import httpx
|
|
from django.shortcuts import get_object_or_404
|
|
from rest_framework.response import Response
|
|
from rest_framework.views import APIView
|
|
|
|
from common.envelope import CodedError, paged
|
|
from common.opencode_service import opencode_service
|
|
|
|
from .models import ChatMessage, ChatSession
|
|
|
|
log = logging.getLogger(__name__)
|
|
|
|
MAX_LIMIT = 100
|
|
|
|
|
|
def _page_args(request) -> tuple[int, int]:
|
|
try:
|
|
page = max(1, int(request.query_params.get("page", 1)))
|
|
limit = min(MAX_LIMIT, max(1, int(request.query_params.get("limit", 20))))
|
|
except ValueError:
|
|
raise CodedError(400, "VALIDATION_ERROR", "page/limit 은 정수여야 해")
|
|
return page, limit
|
|
|
|
|
|
def _slice(qs, page: int, limit: int):
|
|
total = qs.count()
|
|
items = list(qs[(page - 1) * limit : page * limit])
|
|
return items, total
|
|
|
|
|
|
def _own_session(request, session_id: str) -> ChatSession:
|
|
return get_object_or_404(ChatSession, id=session_id, user=request.user)
|
|
|
|
|
|
def _upstream(e: Exception) -> CodedError:
|
|
if isinstance(e, httpx.ConnectError):
|
|
return CodedError(503, "UPSTREAM_UNAVAILABLE", "OpenCode 서버에 연결할 수 없어")
|
|
if isinstance(e, httpx.HTTPStatusError):
|
|
return CodedError(502, "UPSTREAM_ERROR", f"OpenCode 오류: {e.response.text[:300]}")
|
|
log.exception("OpenCode 호출 실패")
|
|
return CodedError(502, "UPSTREAM_ERROR", f"OpenCode 중계 오류: {e}")
|
|
|
|
|
|
class SessionListView(APIView):
|
|
def get(self, request):
|
|
page, limit = _page_args(request)
|
|
items, total = _slice(ChatSession.objects.filter(user=request.user), page, limit)
|
|
return Response(paged([s.as_dto() for s in items], page, limit, total))
|
|
|
|
def post(self, request):
|
|
try:
|
|
created = opencode_service.create_session()
|
|
except Exception as e: # noqa: BLE001
|
|
raise _upstream(e)
|
|
session = ChatSession.objects.create(id=created["id"], user=request.user)
|
|
return Response(session.as_dto(), status=201)
|
|
|
|
|
|
class SearchView(APIView):
|
|
def get(self, request):
|
|
query = (request.query_params.get("query") or "").strip()
|
|
if not query:
|
|
raise CodedError(400, "VALIDATION_ERROR", "query 가 비었어")
|
|
page, limit = _page_args(request)
|
|
qs = (
|
|
ChatMessage.objects.filter(session__user=request.user, content__icontains=query)
|
|
.select_related("session")
|
|
.order_by("-created_at", "-id")
|
|
)
|
|
items, total = _slice(qs, page, limit)
|
|
return Response(paged([m.as_dto() for m in items], page, limit, total))
|
|
|
|
|
|
class SessionMessagesView(APIView):
|
|
def get(self, request, session_id: str):
|
|
session = _own_session(request, session_id)
|
|
return Response({**session.as_dto(), "messages": [m.as_dto() for m in session.messages.all()]})
|
|
|
|
|
|
class CancelView(APIView):
|
|
def post(self, request, session_id: str):
|
|
session = _own_session(request, session_id)
|
|
try:
|
|
opencode_service.abort_session(session.id)
|
|
except Exception: # noqa: BLE001 — best-effort. 이미 끝났으면 실패해도 됨
|
|
log.warning("abort 실패 (이미 끝났을 수 있음): %s", session.id)
|
|
if session.is_generating:
|
|
session.is_generating = False
|
|
session.save(update_fields=["is_generating", "updated_at"])
|
|
return Response(None)
|