feat(backend): Django + OpenCode 백엔드 추가 — 5_django_backend/
앱이 기대하는 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>
This commit is contained in:
@@ -0,0 +1,15 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class AccountsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.accounts"
|
||||
verbose_name = "사용자"
|
||||
|
||||
def ready(self) -> None:
|
||||
# migrate 직후 빈 DB 면 기본 계정을 심음
|
||||
from django.db.models.signals import post_migrate
|
||||
|
||||
from .seed import seed_default_users
|
||||
|
||||
post_migrate.connect(seed_default_users, sender=self)
|
||||
@@ -0,0 +1,43 @@
|
||||
"""JWT 인증 — Authorization: Bearer 헤더 또는 ?token= 쿼리(EventSource·다운로드용).
|
||||
토큰 발급은 issue_tokens() — 프론트 TokenResponse 모양."""
|
||||
|
||||
from rest_framework_simplejwt.authentication import JWTAuthentication
|
||||
from rest_framework_simplejwt.exceptions import InvalidToken, TokenError
|
||||
from rest_framework_simplejwt.tokens import AccessToken, RefreshToken
|
||||
|
||||
|
||||
class BearerOrQueryJWTAuthentication(JWTAuthentication):
|
||||
def authenticate(self, request):
|
||||
result = super().authenticate(request)
|
||||
if result is not None:
|
||||
return result
|
||||
raw = request.query_params.get("token") if hasattr(request, "query_params") else None
|
||||
raw = raw or request.GET.get("token")
|
||||
if not raw:
|
||||
return None
|
||||
validated = self.get_validated_token(raw)
|
||||
return self.get_user(validated), validated
|
||||
|
||||
|
||||
def issue_tokens(user, refresh: RefreshToken | None = None) -> dict:
|
||||
"""TokenResponse. refresh 를 주면 그걸 유지하고 access 만 새로 찍음(회전 안 함)."""
|
||||
refresh = refresh or RefreshToken.for_user(user)
|
||||
access = refresh.access_token
|
||||
return {
|
||||
"token": str(access),
|
||||
"tokenExpirationTime": int(access["exp"]),
|
||||
"refreshToken": str(refresh),
|
||||
"refreshTokenExpirationTime": int(refresh["exp"]),
|
||||
"tokenType": "bearer",
|
||||
"user": user.as_payload(),
|
||||
}
|
||||
|
||||
|
||||
def user_from_token(raw: str):
|
||||
"""순수 Django(async) 뷰용 — access 토큰 → 사용자, 실패 시 None."""
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return BearerOrQueryJWTAuthentication().get_user(AccessToken(raw))
|
||||
except (InvalidToken, TokenError, Exception):
|
||||
return None
|
||||
@@ -0,0 +1,145 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-16 08:38
|
||||
|
||||
import django.contrib.auth.models
|
||||
import django.contrib.auth.validators
|
||||
import django.utils.timezone
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
("auth", "0012_alter_user_first_name_max_length"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="User",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
("password", models.CharField(max_length=128, verbose_name="password")),
|
||||
(
|
||||
"last_login",
|
||||
models.DateTimeField(
|
||||
blank=True, null=True, verbose_name="last login"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_superuser",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates that this user has all permissions without explicitly assigning them.",
|
||||
verbose_name="superuser status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"username",
|
||||
models.CharField(
|
||||
error_messages={
|
||||
"unique": "A user with that username already exists."
|
||||
},
|
||||
help_text="Required. 150 characters or fewer. Letters, digits and @/./+/-/_ only.",
|
||||
max_length=150,
|
||||
unique=True,
|
||||
validators=[
|
||||
django.contrib.auth.validators.UnicodeUsernameValidator()
|
||||
],
|
||||
verbose_name="username",
|
||||
),
|
||||
),
|
||||
(
|
||||
"first_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="first name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"last_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=150, verbose_name="last name"
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_staff",
|
||||
models.BooleanField(
|
||||
default=False,
|
||||
help_text="Designates whether the user can log into this admin site.",
|
||||
verbose_name="staff status",
|
||||
),
|
||||
),
|
||||
(
|
||||
"is_active",
|
||||
models.BooleanField(
|
||||
default=True,
|
||||
help_text="Designates whether this user should be treated as active. Unselect this instead of deleting accounts.",
|
||||
verbose_name="active",
|
||||
),
|
||||
),
|
||||
(
|
||||
"date_joined",
|
||||
models.DateTimeField(
|
||||
default=django.utils.timezone.now, verbose_name="date joined"
|
||||
),
|
||||
),
|
||||
(
|
||||
"email",
|
||||
models.EmailField(
|
||||
max_length=254, unique=True, verbose_name="이메일"
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_name",
|
||||
models.CharField(
|
||||
blank=True, max_length=40, verbose_name="표시 이름"
|
||||
),
|
||||
),
|
||||
(
|
||||
"employee_id",
|
||||
models.CharField(blank=True, max_length=40, verbose_name="사번"),
|
||||
),
|
||||
(
|
||||
"department",
|
||||
models.CharField(blank=True, max_length=80, verbose_name="부서"),
|
||||
),
|
||||
(
|
||||
"groups",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="The groups this user belongs to. A user will get all permissions granted to each of their groups.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.group",
|
||||
verbose_name="groups",
|
||||
),
|
||||
),
|
||||
(
|
||||
"user_permissions",
|
||||
models.ManyToManyField(
|
||||
blank=True,
|
||||
help_text="Specific permissions for this user.",
|
||||
related_name="user_set",
|
||||
related_query_name="user",
|
||||
to="auth.permission",
|
||||
verbose_name="user permissions",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "사용자",
|
||||
"verbose_name_plural": "사용자",
|
||||
},
|
||||
managers=[
|
||||
("objects", django.contrib.auth.models.UserManager()),
|
||||
],
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,41 @@
|
||||
"""사용자 — 프론트 `types/api.ts` UserPayload/UserResponse 모양으로 내보냄.
|
||||
로그인 키는 email. username 은 email 과 같은 값으로 채움(Django 필수 칸이라)."""
|
||||
|
||||
from django.contrib.auth.models import AbstractUser
|
||||
from django.db import models
|
||||
|
||||
|
||||
class User(AbstractUser):
|
||||
email = models.EmailField("이메일", unique=True)
|
||||
user_name = models.CharField("표시 이름", max_length=40, blank=True)
|
||||
employee_id = models.CharField("사번", max_length=40, blank=True)
|
||||
department = models.CharField("부서", max_length=80, blank=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "사용자"
|
||||
verbose_name_plural = "사용자"
|
||||
|
||||
@property
|
||||
def role(self) -> str:
|
||||
return "ADMIN" if self.is_superuser else "USER"
|
||||
|
||||
def as_payload(self) -> dict:
|
||||
"""UserPayload — 로그인 응답 안의 user."""
|
||||
return {
|
||||
"id": str(self.id),
|
||||
"email": self.email,
|
||||
"userName": self.user_name or None,
|
||||
"role": self.role,
|
||||
"employeeId": self.employee_id or None,
|
||||
"department": self.department or None,
|
||||
"authProvider": "local",
|
||||
}
|
||||
|
||||
def as_response(self) -> dict:
|
||||
"""UserResponse — GET /users/me."""
|
||||
return {
|
||||
**self.as_payload(),
|
||||
"isActive": self.is_active,
|
||||
"createdAt": self.date_joined.isoformat(),
|
||||
"updatedAt": (self.last_login or self.date_joined).isoformat(),
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
"""빈 DB 첫 기동 시 기본 계정 — 어느 PC 에서든 바로 로그인되게. 계정 있으면 건너뜀."""
|
||||
|
||||
import logging
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
DEFAULT_USERS = [
|
||||
# (email, password, 표시이름, 관리자)
|
||||
("admin@codeassist.local", "test1234", "관리자", True),
|
||||
("guest@codeassist.local", "test1234", "게스트", False),
|
||||
]
|
||||
|
||||
|
||||
def seed_default_users(**_kwargs) -> None:
|
||||
from .models import User
|
||||
|
||||
if User.objects.exists():
|
||||
return
|
||||
for email, pw, name, admin in DEFAULT_USERS:
|
||||
maker = User.objects.create_superuser if admin else User.objects.create_user
|
||||
maker(username=email, email=email, password=pw, user_name=name)
|
||||
log.info("기본 계정 시드: %s", ", ".join(u[0] for u in DEFAULT_USERS))
|
||||
@@ -0,0 +1,10 @@
|
||||
from rest_framework import serializers
|
||||
|
||||
|
||||
class LoginSerializer(serializers.Serializer):
|
||||
email = serializers.EmailField()
|
||||
password = serializers.CharField()
|
||||
|
||||
|
||||
class RefreshSerializer(serializers.Serializer):
|
||||
refreshToken = serializers.CharField(required=False, allow_blank=True, default="")
|
||||
@@ -0,0 +1,10 @@
|
||||
from django.urls import path
|
||||
|
||||
from .views import EntraConfigView, LoginView, LogoutView, RefreshView
|
||||
|
||||
urlpatterns = [
|
||||
path("login", LoginView.as_view()),
|
||||
path("refresh", RefreshView.as_view()),
|
||||
path("logout", LogoutView.as_view()),
|
||||
path("entra/config", EntraConfigView.as_view()),
|
||||
]
|
||||
@@ -0,0 +1,77 @@
|
||||
"""
|
||||
인증 API — 프론트 `features/auth/api/auth.api.ts` 계약.
|
||||
|
||||
POST /api/v1/auth/login {email,password} → TokenResponse
|
||||
POST /api/v1/auth/refresh {refreshToken} → TokenResponse (access 갱신)
|
||||
POST /api/v1/auth/logout → null (클라가 토큰 버림. 서버는 회전·블랙리스트 안 함)
|
||||
GET /api/v1/users/me → UserResponse
|
||||
GET /api/v1/auth/entra/config → 501 (Entra 안 씀 — 프론트가 버튼 숨김)
|
||||
"""
|
||||
|
||||
from rest_framework.permissions import AllowAny
|
||||
from rest_framework.response import Response
|
||||
from rest_framework.views import APIView
|
||||
from rest_framework_simplejwt.exceptions import TokenError
|
||||
from rest_framework_simplejwt.tokens import RefreshToken
|
||||
|
||||
from common.envelope import CodedError
|
||||
|
||||
from .authentication import issue_tokens
|
||||
from .models import User
|
||||
from .serializers import LoginSerializer, RefreshSerializer
|
||||
|
||||
|
||||
class LoginView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = [] # 옛 토큰이 붙어 있어도 로그인은 통과시킴
|
||||
|
||||
def post(self, request):
|
||||
ser = LoginSerializer(data=request.data)
|
||||
ser.is_valid(raise_exception=True)
|
||||
user = User.objects.filter(email__iexact=ser.validated_data["email"]).first()
|
||||
if user is None or not user.check_password(ser.validated_data["password"]):
|
||||
raise CodedError(401, "INVALID_CREDENTIALS", "이메일 또는 비밀번호가 올바르지 않아")
|
||||
if not user.is_active:
|
||||
raise CodedError(403, "USER_INACTIVE", "비활성 계정이야")
|
||||
return Response(issue_tokens(user))
|
||||
|
||||
|
||||
class RefreshView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, request):
|
||||
ser = RefreshSerializer(data=request.data or {})
|
||||
ser.is_valid(raise_exception=True)
|
||||
raw = ser.validated_data.get("refreshToken") or ""
|
||||
if not raw:
|
||||
raise CodedError(401, "REFRESH_TOKEN_MISSING", "refresh 토큰이 없어")
|
||||
try:
|
||||
refresh = RefreshToken(raw)
|
||||
except TokenError:
|
||||
raise CodedError(401, "REFRESH_TOKEN_INVALID", "refresh 토큰이 만료됐거나 잘못됐어")
|
||||
user = User.objects.filter(id=refresh["uid"], is_active=True).first()
|
||||
if user is None:
|
||||
raise CodedError(401, "REFRESH_TOKEN_INVALID", "사용자를 찾을 수 없어")
|
||||
return Response(issue_tokens(user, refresh))
|
||||
|
||||
|
||||
class LogoutView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def post(self, _request):
|
||||
return Response(None)
|
||||
|
||||
|
||||
class MeView(APIView):
|
||||
def get(self, request):
|
||||
return Response(request.user.as_response())
|
||||
|
||||
|
||||
class EntraConfigView(APIView):
|
||||
permission_classes = [AllowAny]
|
||||
authentication_classes = []
|
||||
|
||||
def get(self, _request):
|
||||
raise CodedError(501, "NOT_IMPLEMENTED", "Entra 로그인은 지원하지 않아")
|
||||
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class ChatConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.chat"
|
||||
verbose_name = "채팅"
|
||||
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
OpenCode `/event` 구독 버스 — 프로세스당 하나.
|
||||
|
||||
OpenCode 는 서버 전체 이벤트를 SSE 하나로 흘림. 여기서 한 번만 구독하고
|
||||
세션별 asyncio.Queue 로 나눠줌. 구독자 없으면 태스크도 안 돌림.
|
||||
큐 item: dict(이벤트) 또는 None(하트비트 — 60초 무활동/재연결).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
|
||||
from common.opencode_service import opencode_service
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def session_id_of(event: dict) -> str | None:
|
||||
props = event.get("properties") or {}
|
||||
info = props.get("info") or {}
|
||||
part = props.get("part") or {}
|
||||
if str(event.get("type", "")).startswith("session."):
|
||||
return info.get("id") or props.get("sessionID")
|
||||
return part.get("sessionID") or props.get("sessionID") or info.get("sessionID")
|
||||
|
||||
|
||||
class EventBus:
|
||||
def __init__(self, source=None):
|
||||
# source: async iterator factory — 테스트에서 가짜로 바꿈
|
||||
self._source = source or opencode_service.iter_event_jsons
|
||||
self._subs: dict[str, set[asyncio.Queue]] = {}
|
||||
self._task: asyncio.Task | None = None
|
||||
|
||||
def subscribe(self, session_id: str) -> asyncio.Queue:
|
||||
q: asyncio.Queue = asyncio.Queue()
|
||||
self._subs.setdefault(session_id, set()).add(q)
|
||||
self._ensure_running()
|
||||
return q
|
||||
|
||||
def unsubscribe(self, session_id: str, q: asyncio.Queue) -> None:
|
||||
subs = self._subs.get(session_id)
|
||||
if subs:
|
||||
subs.discard(q)
|
||||
if not subs:
|
||||
del self._subs[session_id]
|
||||
|
||||
def _ensure_running(self) -> None:
|
||||
if self._task is None or self._task.done():
|
||||
self._task = asyncio.get_running_loop().create_task(self._pump(), name="opencode-event-pump")
|
||||
|
||||
async def _pump(self) -> None:
|
||||
try:
|
||||
async for payload in self._source():
|
||||
if payload is None:
|
||||
for subs in list(self._subs.values()):
|
||||
for q in list(subs):
|
||||
q.put_nowait(None)
|
||||
continue
|
||||
try:
|
||||
event = json.loads(payload)
|
||||
except ValueError:
|
||||
continue
|
||||
sid = session_id_of(event)
|
||||
if not sid:
|
||||
continue
|
||||
for q in list(self._subs.get(sid, ())):
|
||||
q.put_nowait(event)
|
||||
except asyncio.CancelledError:
|
||||
raise
|
||||
except Exception: # noqa: BLE001
|
||||
log.exception("이벤트 펌프 죽음 — 다음 구독 때 재시작")
|
||||
finally:
|
||||
self._task = None
|
||||
|
||||
|
||||
event_bus = EventBus()
|
||||
@@ -0,0 +1,84 @@
|
||||
# Generated by Django 5.1.15 on 2026-09-16 08:40
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="ChatSession",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.CharField(max_length=64, primary_key=True, serialize=False),
|
||||
),
|
||||
("title", models.CharField(blank=True, max_length=200)),
|
||||
("title_llm", models.CharField(blank=True, max_length=200)),
|
||||
("is_generating", models.BooleanField(default=False)),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("updated_at", models.DateTimeField(auto_now=True)),
|
||||
(
|
||||
"user",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="chat_sessions",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["-updated_at"],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="ChatMessage",
|
||||
fields=[
|
||||
(
|
||||
"id",
|
||||
models.BigAutoField(
|
||||
auto_created=True,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
verbose_name="ID",
|
||||
),
|
||||
),
|
||||
(
|
||||
"role",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("user", "user"),
|
||||
("assistant", "assistant"),
|
||||
("system", "system"),
|
||||
],
|
||||
max_length=16,
|
||||
),
|
||||
),
|
||||
("content", models.TextField()),
|
||||
("created_at", models.DateTimeField(auto_now_add=True)),
|
||||
("input_tokens", models.IntegerField(blank=True, null=True)),
|
||||
("output_tokens", models.IntegerField(blank=True, null=True)),
|
||||
("cost_usd", models.FloatField(blank=True, null=True)),
|
||||
("elapsed_ms", models.IntegerField(blank=True, null=True)),
|
||||
(
|
||||
"session",
|
||||
models.ForeignKey(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="messages",
|
||||
to="chat.chatsession",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"ordering": ["created_at", "id"],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,61 @@
|
||||
"""
|
||||
세션·메시지 미러 — 프론트 `features/snap/contract/types.ts` 와 1:1.
|
||||
|
||||
대화 원본(컨텍스트)은 OpenCode 가 들고 있고, 여긴 목록·검색·usage·isGenerating 용 사본.
|
||||
OpenCode 는 페이지네이션·본문 검색·메시지별 토큰을 안 주니까 여기서 함.
|
||||
"""
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import models
|
||||
|
||||
|
||||
class ChatSession(models.Model):
|
||||
id = models.CharField(primary_key=True, max_length=64) # OpenCode session id 그대로
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name="chat_sessions")
|
||||
title = models.CharField(max_length=200, blank=True) # 사용자가 지은 제목 (지금은 안 씀)
|
||||
title_llm = models.CharField(max_length=200, blank=True) # OpenCode 가 지은 제목
|
||||
is_generating = models.BooleanField(default=False)
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
updated_at = models.DateTimeField(auto_now=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["-updated_at"]
|
||||
|
||||
def as_dto(self) -> dict:
|
||||
return {
|
||||
"id": self.id,
|
||||
"title": self.title or None,
|
||||
"titleLlm": self.title_llm or None,
|
||||
"isGenerating": self.is_generating,
|
||||
"createdAt": self.created_at.isoformat(),
|
||||
"updatedAt": self.updated_at.isoformat(),
|
||||
}
|
||||
|
||||
|
||||
class ChatMessage(models.Model):
|
||||
ROLES = [("user", "user"), ("assistant", "assistant"), ("system", "system")]
|
||||
|
||||
session = models.ForeignKey(ChatSession, on_delete=models.CASCADE, related_name="messages")
|
||||
role = models.CharField(max_length=16, choices=ROLES)
|
||||
content = models.TextField() # markdown
|
||||
created_at = models.DateTimeField(auto_now_add=True)
|
||||
# assistant 행에만 채움
|
||||
input_tokens = models.IntegerField(null=True, blank=True)
|
||||
output_tokens = models.IntegerField(null=True, blank=True)
|
||||
cost_usd = models.FloatField(null=True, blank=True)
|
||||
elapsed_ms = models.IntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ["created_at", "id"]
|
||||
|
||||
def as_dto(self) -> dict:
|
||||
return {
|
||||
"sessionId": self.session_id,
|
||||
"role": self.role,
|
||||
"content": self.content,
|
||||
"createdAt": self.created_at.isoformat(),
|
||||
"inputTokens": self.input_tokens,
|
||||
"outputTokens": self.output_tokens,
|
||||
"costUsd": self.cost_usd,
|
||||
"elapsedMs": self.elapsed_ms,
|
||||
}
|
||||
@@ -0,0 +1,381 @@
|
||||
"""
|
||||
POST /api/v1/chat/stream — 프론트 `lib/streaming/streamLLM.ts` SSE 계약.
|
||||
|
||||
요청 {sessionId, content, images?[{mediaType,data}], forcedSkill?, explain?}
|
||||
이벤트 token{delta} ×N → usage{used,limit,ratio,elapsed_ms} → done{}
|
||||
중간 title{title}, 실패 error{message,code}
|
||||
|
||||
흐름
|
||||
1. 인증(Bearer) → 본인 세션 → 생성 중이면 409
|
||||
2. 미러: user 메시지 저장 + is_generating=true
|
||||
3. 이벤트 버스 구독 → OpenCode prompt_async (블록 없음)
|
||||
4. 턴 감시 태스크(run_turn)가 /event 를 우리 이벤트로 바꿔 큐에 넣음.
|
||||
클라이언트가 끊겨도 태스크는 idle 까지 돌고 DB 를 마무리함 → 프론트 isGenerating 폴링이 복구.
|
||||
5. session.idle 이면 OpenCode 에서 최종 메시지·tokens·cost 가져와 저장 → usage → done
|
||||
|
||||
순수 Django async 뷰 — DRF 는 async 스트리밍을 못 함. 반드시 ASGI(uvicorn).
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
import time
|
||||
|
||||
from django.conf import settings
|
||||
from django.http import StreamingHttpResponse
|
||||
from django.views.decorators.csrf import csrf_exempt
|
||||
|
||||
from apps.accounts.authentication import user_from_token
|
||||
from asgiref.sync import sync_to_async
|
||||
from common.envelope import json_error
|
||||
from common.opencode_service import opencode_service
|
||||
|
||||
from .events import event_bus
|
||||
from .models import ChatMessage, ChatSession
|
||||
|
||||
log = logging.getLogger(__name__)
|
||||
|
||||
ALLOWED_IMAGE_TYPES = {"image/png", "image/jpeg", "image/webp"}
|
||||
MAX_IMAGES = 4
|
||||
TURN_TIMEOUT_S = 600 # OpenCode 답변 상한
|
||||
# prompt 를 받았는데 이 세션 이벤트가 하나도 안 오면 OpenCode 가 조용히 실패한 것
|
||||
# (예: 다른 프로젝트의 세션 — 로그에만 "prompt_async failed" 남고 session.error 안 옴)
|
||||
FIRST_EVENT_TIMEOUT_S = 60
|
||||
KEEPALIVE_S = 15
|
||||
_END = object() # 큐 종료 표시
|
||||
|
||||
|
||||
# ── SSE 프레임 ──────────────────────────────────────────────────
|
||||
def sse(event: str, data) -> bytes:
|
||||
return f"event: {event}\ndata: {json.dumps(data, ensure_ascii=False)}\n\n".encode()
|
||||
|
||||
|
||||
def _token_from(request) -> str:
|
||||
auth = request.headers.get("Authorization", "")
|
||||
return auth[7:] if auth.startswith("Bearer ") else request.GET.get("token", "")
|
||||
|
||||
|
||||
def _parse_body(request) -> tuple[dict | None, str | None]:
|
||||
try:
|
||||
body = json.loads(request.body or b"{}")
|
||||
except ValueError:
|
||||
return None, "JSON 이 아니야"
|
||||
if not isinstance(body, dict) or not body.get("sessionId"):
|
||||
return None, "sessionId 가 없어"
|
||||
content = body.get("content") or ""
|
||||
images = body.get("images") or []
|
||||
if not isinstance(images, list) or len(images) > MAX_IMAGES:
|
||||
return None, f"이미지는 최대 {MAX_IMAGES}장"
|
||||
for img in images:
|
||||
if not isinstance(img, dict) or img.get("mediaType") not in ALLOWED_IMAGE_TYPES or not img.get("data"):
|
||||
return None, "이미지 형식이 잘못됐어 (png/jpeg/webp data URL)"
|
||||
if not content.strip() and not images:
|
||||
return None, "content 나 images 중 하나는 있어야 해"
|
||||
return {"sessionId": body["sessionId"], "content": content, "images": images, "explain": bool(body.get("explain"))}, None
|
||||
|
||||
|
||||
def _prompt_payload(req: dict) -> dict:
|
||||
text = req["content"]
|
||||
if req["explain"]:
|
||||
text = f"[설명 모드] {text}".strip()
|
||||
parts: list[dict] = []
|
||||
if text.strip():
|
||||
parts.append({"type": "text", "text": text})
|
||||
for i, img in enumerate(req["images"]):
|
||||
ext = img["mediaType"].split("/")[1]
|
||||
parts.append({"type": "file", "mime": img["mediaType"], "filename": f"image-{i + 1}.{ext}", "url": img["data"]})
|
||||
return {"agent": settings.OPENCODE_AGENT, "parts": parts}
|
||||
|
||||
|
||||
# ── OpenCode 이벤트 → 우리 이벤트 ────────────────────────────────
|
||||
class TurnState:
|
||||
"""한 턴 동안 delta 계산에 필요한 것."""
|
||||
|
||||
def __init__(self, session_id: str, user_text: str):
|
||||
self.session_id = session_id
|
||||
self.user_text = user_text
|
||||
self.user_message_ids: set[str] = set()
|
||||
self.assistant_message_ids: set[str] = set()
|
||||
self.seen_len: dict[str, int] = {} # partID → 지금까지 보낸 글자 수
|
||||
self.part_types: dict[str, str] = {} # partID → "text"/"reasoning"/… (part.updated 가 delta 보다 먼저 옴)
|
||||
self.text_by_part: dict[str, str] = {}
|
||||
self.part_order: list[str] = []
|
||||
self.title: str | None = None
|
||||
|
||||
def accumulated(self) -> str:
|
||||
return "".join(self.text_by_part.get(p, "") for p in self.part_order)
|
||||
|
||||
def handle(self, event: dict) -> list[tuple[str, dict]]:
|
||||
"""이벤트 하나 → 프론트로 보낼 (event, data) 목록. idle/error 는 호출자가 봄."""
|
||||
out: list[tuple[str, dict]] = []
|
||||
etype = event.get("type")
|
||||
props = event.get("properties") or {}
|
||||
|
||||
if etype == "message.updated":
|
||||
info = props.get("info") or {}
|
||||
if info.get("role") == "user":
|
||||
self.user_message_ids.add(info.get("id", ""))
|
||||
elif info.get("role") == "assistant":
|
||||
self.assistant_message_ids.add(info.get("id", ""))
|
||||
return out
|
||||
|
||||
if etype == "message.part.delta":
|
||||
# 실서버(1.18.6)의 글자 단위 스트리밍은 이 이벤트로 옴 — SDK 타입엔 없지만 실제로 174개/답변.
|
||||
# {sessionID, messageID, partID, field:"text", delta}. 파트 타입은 안 실려서 part.updated 로 알아둔 것으로 거름.
|
||||
if props.get("field") != "text" or not props.get("delta"):
|
||||
return out
|
||||
mid = props.get("messageID", "")
|
||||
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
|
||||
return out
|
||||
pid = props.get("partID", "")
|
||||
if self.part_types.get(pid) != "text":
|
||||
return out # reasoning/tool 파트, 또는 아직 타입 모름(최종 스냅샷이 메워줌)
|
||||
delta = props["delta"]
|
||||
if pid not in self.text_by_part:
|
||||
self.part_order.append(pid)
|
||||
self.text_by_part[pid] = self.text_by_part.get(pid, "") + delta
|
||||
self.seen_len[pid] = len(self.text_by_part[pid])
|
||||
out.append(("token", {"delta": delta}))
|
||||
return out
|
||||
|
||||
if etype == "message.part.updated":
|
||||
part = props.get("part") or {}
|
||||
if part.get("id"):
|
||||
self.part_types[part["id"]] = part.get("type", "")
|
||||
if part.get("type") != "text" or part.get("synthetic") or part.get("ignored"):
|
||||
return out
|
||||
mid = part.get("messageID", "")
|
||||
# 사용자 메시지 파트(우리가 보낸 질문의 echo)는 건너뜀
|
||||
if mid in self.user_message_ids and mid not in self.assistant_message_ids:
|
||||
return out
|
||||
pid = part.get("id", "")
|
||||
text = part.get("text") or ""
|
||||
delta = props.get("delta")
|
||||
if pid not in self.text_by_part:
|
||||
self.part_order.append(pid)
|
||||
if delta:
|
||||
self.text_by_part[pid] = self.text_by_part.get(pid, "") + delta
|
||||
self.seen_len[pid] = len(self.text_by_part[pid])
|
||||
out.append(("token", {"delta": delta}))
|
||||
else:
|
||||
seen = self.seen_len.get(pid, 0)
|
||||
if len(text) > seen and text.startswith(self.text_by_part.get(pid, "")):
|
||||
out.append(("token", {"delta": text[seen:]}))
|
||||
self.seen_len[pid] = len(text)
|
||||
self.text_by_part[pid] = text if len(text) >= seen else self.text_by_part.get(pid, "")
|
||||
return out
|
||||
|
||||
if etype == "session.updated":
|
||||
title = _real_title((props.get("info") or {}).get("title"))
|
||||
if title and title != self.title:
|
||||
self.title = title
|
||||
out.append(("title", {"title": title}))
|
||||
return out
|
||||
|
||||
return out
|
||||
|
||||
|
||||
def _real_title(raw) -> str | None:
|
||||
"""OpenCode 기본 제목("New session - 2026-…")은 제목이 아님 → None."""
|
||||
t = (raw or "").strip()
|
||||
if not t or t.startswith("New session"):
|
||||
return None
|
||||
return t
|
||||
|
||||
|
||||
def _error_message(err: dict | None) -> str:
|
||||
if not err:
|
||||
return "OpenCode 오류"
|
||||
data = err.get("data") or {}
|
||||
return data.get("message") or err.get("name") or "OpenCode 오류"
|
||||
|
||||
|
||||
async def _finalize(session: ChatSession, state: TurnState, started: float, *, failed: str | None = None):
|
||||
"""idle/에러/타임아웃 후 미러 DB 마무리. 실패해도 is_generating 은 반드시 풀림."""
|
||||
content = state.accumulated()
|
||||
usage: dict | None = None
|
||||
title = state.title
|
||||
try:
|
||||
msgs = await opencode_service.list_messages_a(session.id) or []
|
||||
assistant = [m for m in msgs if (m.get("info") or {}).get("role") == "assistant"]
|
||||
if assistant:
|
||||
info = assistant[-1]["info"]
|
||||
parts = assistant[-1].get("parts") or []
|
||||
full = "".join(p.get("text", "") for p in parts if p.get("type") == "text" and not p.get("synthetic"))
|
||||
if full:
|
||||
content = full
|
||||
tokens = info.get("tokens") or {}
|
||||
t = info.get("time") or {}
|
||||
elapsed = (t.get("completed") or int(time.time() * 1000)) - (t.get("created") or int(started * 1000))
|
||||
usage = {
|
||||
"input": int(tokens.get("input") or 0),
|
||||
"output": int(tokens.get("output") or 0) + int(tokens.get("reasoning") or 0),
|
||||
"cost": float(info.get("cost") or 0.0),
|
||||
"elapsed_ms": max(0, int(elapsed)),
|
||||
}
|
||||
if not title:
|
||||
info = await opencode_service.get_session_a(session.id) or {}
|
||||
title = _real_title(info.get("title"))
|
||||
except Exception: # noqa: BLE001
|
||||
log.warning("턴 마무리 중 OpenCode 조회 실패 — 누적 텍스트로 저장: %s", session.id)
|
||||
|
||||
if content or failed is None:
|
||||
await ChatMessage.objects.acreate(
|
||||
session=session,
|
||||
role="assistant",
|
||||
content=content or (f"(오류: {failed})" if failed else ""),
|
||||
input_tokens=usage["input"] if usage else None,
|
||||
output_tokens=usage["output"] if usage else None,
|
||||
cost_usd=usage["cost"] if usage else None,
|
||||
elapsed_ms=usage["elapsed_ms"] if usage else None,
|
||||
)
|
||||
session.is_generating = False
|
||||
if title:
|
||||
session.title_llm = title[:200]
|
||||
await session.asave(update_fields=["is_generating", "title_llm", "updated_at"])
|
||||
return usage, title
|
||||
|
||||
|
||||
TITLE_WAIT_S = 20 # OpenCode 제목 생성은 별도 LLM 호출이라 idle 뒤에 오기도 함
|
||||
|
||||
|
||||
async def _wait_late_title(session: ChatSession, q: asyncio.Queue) -> None:
|
||||
"""idle 뒤 늦게 오는 session.updated 제목을 미러에만 반영 (목록 재조회 때 보임)."""
|
||||
deadline = time.time() + TITLE_WAIT_S
|
||||
while (remaining := deadline - time.time()) > 0:
|
||||
try:
|
||||
event = await asyncio.wait_for(q.get(), timeout=remaining)
|
||||
except asyncio.TimeoutError:
|
||||
return
|
||||
if not event or event.get("type") != "session.updated":
|
||||
continue
|
||||
title = _real_title(((event.get("properties") or {}).get("info") or {}).get("title"))
|
||||
if title:
|
||||
session.title_llm = title[:200]
|
||||
await session.asave(update_fields=["title_llm", "updated_at"])
|
||||
return
|
||||
|
||||
|
||||
async def run_turn(session: ChatSession, req: dict, out: asyncio.Queue) -> None:
|
||||
"""한 턴 감시 — 클라이언트와 무관하게 idle 까지 돌고 DB 마무리. 결과는 out 큐로."""
|
||||
state = TurnState(session.id, req["content"])
|
||||
started = time.time()
|
||||
q = event_bus.subscribe(session.id)
|
||||
try:
|
||||
await opencode_service.prompt_async(session.id, _prompt_payload(req))
|
||||
except Exception as e: # noqa: BLE001
|
||||
event_bus.unsubscribe(session.id, q)
|
||||
log.warning("prompt_async 실패: %s", e)
|
||||
await _finalize(session, state, started, failed=str(e))
|
||||
out.put_nowait(("error", {"message": "OpenCode 에 질문을 못 보냈어", "code": "LLM_ERROR"}))
|
||||
out.put_nowait(_END)
|
||||
return
|
||||
|
||||
try:
|
||||
deadline = started + TURN_TIMEOUT_S
|
||||
got_any = False
|
||||
while True:
|
||||
now = time.time()
|
||||
remaining = deadline - now
|
||||
silent_too_long = not got_any and now - started > FIRST_EVENT_TIMEOUT_S
|
||||
if remaining <= 0 or silent_too_long:
|
||||
try:
|
||||
await opencode_service.abort_session_a(session.id)
|
||||
except Exception: # noqa: BLE001
|
||||
pass
|
||||
reason = "no-events" if silent_too_long else "timeout"
|
||||
await _finalize(session, state, started, failed=reason)
|
||||
msg = "OpenCode 가 응답을 시작하지 않았어" if silent_too_long else "답변 시간이 너무 길어서 중단했어"
|
||||
out.put_nowait(("error", {"message": msg, "code": "LLM_ERROR"}))
|
||||
break
|
||||
try:
|
||||
event = await asyncio.wait_for(q.get(), timeout=min(KEEPALIVE_S, remaining))
|
||||
except asyncio.TimeoutError:
|
||||
out.put_nowait(None) # keepalive
|
||||
continue
|
||||
if event is None:
|
||||
out.put_nowait(None)
|
||||
continue
|
||||
got_any = True
|
||||
|
||||
etype = event.get("type")
|
||||
props = event.get("properties") or {}
|
||||
if etype == "session.error" or (
|
||||
etype == "message.updated"
|
||||
and (props.get("info") or {}).get("role") == "assistant"
|
||||
and (props.get("info") or {}).get("error")
|
||||
):
|
||||
err = props.get("error") or (props.get("info") or {}).get("error")
|
||||
msg = _error_message(err)
|
||||
await _finalize(session, state, started, failed=msg)
|
||||
code = "LLM_ABORTED" if (err or {}).get("name") == "MessageAbortedError" else "LLM_ERROR"
|
||||
out.put_nowait(("error", {"message": msg, "code": code}))
|
||||
break
|
||||
|
||||
for ev in state.handle(event):
|
||||
out.put_nowait(ev)
|
||||
|
||||
if etype == "session.idle":
|
||||
usage, title = await _finalize(session, state, started)
|
||||
if title and title != state.title:
|
||||
out.put_nowait(("title", {"title": title}))
|
||||
if usage:
|
||||
used = usage["input"] + usage["output"]
|
||||
limit = settings.CONTEXT_LIMIT_TOKENS
|
||||
out.put_nowait(
|
||||
("usage", {"used": used, "limit": limit, "ratio": round(used / limit, 4) if limit else 0, "elapsed_ms": usage["elapsed_ms"]})
|
||||
)
|
||||
out.put_nowait(("done", {}))
|
||||
out.put_nowait(_END) # 클라이언트는 여기서 끝. 제목은 뒤에서 조용히 기다림
|
||||
if not title:
|
||||
await _wait_late_title(session, q)
|
||||
break
|
||||
except Exception as e: # noqa: BLE001
|
||||
log.exception("턴 감시 실패: %s", session.id)
|
||||
await _finalize(session, state, started, failed=str(e))
|
||||
out.put_nowait(("error", {"message": "서버 오류로 답변을 못 받았어", "code": "LLM_ERROR"}))
|
||||
finally:
|
||||
event_bus.unsubscribe(session.id, q)
|
||||
out.put_nowait(_END)
|
||||
|
||||
|
||||
# ── 뷰 ──────────────────────────────────────────────────────────
|
||||
@csrf_exempt
|
||||
async def stream_chat(request):
|
||||
if request.method != "POST":
|
||||
return json_error(405, "METHOD_NOT_ALLOWED", "POST 만 받아")
|
||||
user = await sync_to_async(user_from_token)(_token_from(request))
|
||||
if user is None:
|
||||
return json_error(401, "UNAUTHORIZED", "로그인이 필요해")
|
||||
req, err = _parse_body(request)
|
||||
if err:
|
||||
return json_error(400, "VALIDATION_ERROR", err)
|
||||
|
||||
try:
|
||||
session = await ChatSession.objects.aget(id=req["sessionId"], user=user)
|
||||
except ChatSession.DoesNotExist:
|
||||
return json_error(404, "NOT_FOUND", "세션이 없어")
|
||||
if session.is_generating:
|
||||
return json_error(409, "CHAT_GENERATION_IN_PROGRESS", "아직 답변 생성 중이야")
|
||||
|
||||
session.is_generating = True
|
||||
await session.asave(update_fields=["is_generating", "updated_at"])
|
||||
await ChatMessage.objects.acreate(session=session, role="user", content=req["content"])
|
||||
|
||||
out: asyncio.Queue = asyncio.Queue()
|
||||
asyncio.get_running_loop().create_task(run_turn(session, req, out), name=f"turn-{session.id}")
|
||||
|
||||
async def gen():
|
||||
while True:
|
||||
item = await out.get()
|
||||
if item is _END:
|
||||
break
|
||||
if item is None:
|
||||
yield b": keepalive\n\n"
|
||||
continue
|
||||
yield sse(*item)
|
||||
|
||||
resp = StreamingHttpResponse(gen(), content_type="text/event-stream")
|
||||
resp["Cache-Control"] = "no-cache"
|
||||
resp["X-Accel-Buffering"] = "no"
|
||||
return resp
|
||||
@@ -0,0 +1,12 @@
|
||||
from django.urls import path
|
||||
|
||||
from .stream import stream_chat
|
||||
from .views import CancelView, SearchView, SessionListView, SessionMessagesView
|
||||
|
||||
urlpatterns = [
|
||||
path("stream", stream_chat),
|
||||
path("sessions", SessionListView.as_view()),
|
||||
path("sessions/search", SearchView.as_view()), # <str:session_id> 보다 먼저
|
||||
path("sessions/<str:session_id>/messages", SessionMessagesView.as_view()),
|
||||
path("sessions/<str:session_id>/cancel", CancelView.as_view()),
|
||||
]
|
||||
@@ -0,0 +1,103 @@
|
||||
"""
|
||||
세션 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)
|
||||
Reference in New Issue
Block a user