From 286998513954031c980e9243e0600de2c9716f25 Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Wed, 16 Sep 2026 21:28:31 +0900 Subject: [PATCH] =?UTF-8?q?feat(backend):=20Django=20+=20OpenCode=20?= =?UTF-8?q?=EB=B0=B1=EC=97=94=EB=93=9C=20=EC=B6=94=EA=B0=80=20=E2=80=94=20?= =?UTF-8?q?5=5Fdjango=5Fbackend/?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 앱이 기대하는 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 --- 5_django_backend/.env.example | 17 + 5_django_backend/.gitignore | 7 + 5_django_backend/README.md | 77 + 5_django_backend/apps/__init__.py | 0 5_django_backend/apps/accounts/__init__.py | 0 5_django_backend/apps/accounts/apps.py | 15 + .../apps/accounts/authentication.py | 43 + .../apps/accounts/migrations/0001_initial.py | 145 + .../apps/accounts/migrations/__init__.py | 0 5_django_backend/apps/accounts/models.py | 41 + 5_django_backend/apps/accounts/seed.py | 22 + 5_django_backend/apps/accounts/serializers.py | 10 + 5_django_backend/apps/accounts/urls.py | 10 + 5_django_backend/apps/accounts/views.py | 77 + 5_django_backend/apps/chat/__init__.py | 0 5_django_backend/apps/chat/apps.py | 7 + 5_django_backend/apps/chat/events.py | 76 + .../apps/chat/migrations/0001_initial.py | 84 + .../apps/chat/migrations/__init__.py | 0 5_django_backend/apps/chat/models.py | 61 + 5_django_backend/apps/chat/stream.py | 381 ++ 5_django_backend/apps/chat/urls.py | 12 + 5_django_backend/apps/chat/views.py | 103 + 5_django_backend/common/__init__.py | 0 5_django_backend/common/envelope.py | 154 + 5_django_backend/common/opencode_service.py | 167 + 5_django_backend/config/__init__.py | 0 5_django_backend/config/asgi.py | 11 + 5_django_backend/config/settings.py | 208 + 5_django_backend/config/urls.py | 24 + 5_django_backend/docs-lib/README.md | 9 + .../docs-lib/opencode-sdk-1.18.6-types.d.ts | 3383 +++++++++++++++++ 5_django_backend/manage.py | 16 + .../opencode/.opencode/agent/codeassist.md | 12 + 5_django_backend/opencode/AGENTS.md | 19 + 5_django_backend/opencode/README.md | 25 + 5_django_backend/opencode/opencode.json | 21 + 5_django_backend/pytest.ini | 5 + 5_django_backend/requirements.txt | 12 + 5_django_backend/tests/__init__.py | 0 5_django_backend/tests/conftest.py | 26 + 5_django_backend/tests/test_auth.py | 87 + 5_django_backend/tests/test_envelope.py | 29 + 5_django_backend/tests/test_sessions.py | 123 + 5_django_backend/tests/test_stream.py | 359 ++ docs/orders/20260916.md | 2 +- docs/tech/opencode-django-backend.md | 6 +- specs/006-opencode-django-backend/stage-1.md | 2 +- z-my-docs/work-log/2026-09/2026-09-16.md | 1 + 49 files changed, 5884 insertions(+), 5 deletions(-) create mode 100644 5_django_backend/.env.example create mode 100644 5_django_backend/.gitignore create mode 100644 5_django_backend/README.md create mode 100644 5_django_backend/apps/__init__.py create mode 100644 5_django_backend/apps/accounts/__init__.py create mode 100644 5_django_backend/apps/accounts/apps.py create mode 100644 5_django_backend/apps/accounts/authentication.py create mode 100644 5_django_backend/apps/accounts/migrations/0001_initial.py create mode 100644 5_django_backend/apps/accounts/migrations/__init__.py create mode 100644 5_django_backend/apps/accounts/models.py create mode 100644 5_django_backend/apps/accounts/seed.py create mode 100644 5_django_backend/apps/accounts/serializers.py create mode 100644 5_django_backend/apps/accounts/urls.py create mode 100644 5_django_backend/apps/accounts/views.py create mode 100644 5_django_backend/apps/chat/__init__.py create mode 100644 5_django_backend/apps/chat/apps.py create mode 100644 5_django_backend/apps/chat/events.py create mode 100644 5_django_backend/apps/chat/migrations/0001_initial.py create mode 100644 5_django_backend/apps/chat/migrations/__init__.py create mode 100644 5_django_backend/apps/chat/models.py create mode 100644 5_django_backend/apps/chat/stream.py create mode 100644 5_django_backend/apps/chat/urls.py create mode 100644 5_django_backend/apps/chat/views.py create mode 100644 5_django_backend/common/__init__.py create mode 100644 5_django_backend/common/envelope.py create mode 100644 5_django_backend/common/opencode_service.py create mode 100644 5_django_backend/config/__init__.py create mode 100644 5_django_backend/config/asgi.py create mode 100644 5_django_backend/config/settings.py create mode 100644 5_django_backend/config/urls.py create mode 100644 5_django_backend/docs-lib/README.md create mode 100644 5_django_backend/docs-lib/opencode-sdk-1.18.6-types.d.ts create mode 100644 5_django_backend/manage.py create mode 100644 5_django_backend/opencode/.opencode/agent/codeassist.md create mode 100644 5_django_backend/opencode/AGENTS.md create mode 100644 5_django_backend/opencode/README.md create mode 100644 5_django_backend/opencode/opencode.json create mode 100644 5_django_backend/pytest.ini create mode 100644 5_django_backend/requirements.txt create mode 100644 5_django_backend/tests/__init__.py create mode 100644 5_django_backend/tests/conftest.py create mode 100644 5_django_backend/tests/test_auth.py create mode 100644 5_django_backend/tests/test_envelope.py create mode 100644 5_django_backend/tests/test_sessions.py create mode 100644 5_django_backend/tests/test_stream.py diff --git a/5_django_backend/.env.example b/5_django_backend/.env.example new file mode 100644 index 0000000..83ca944 --- /dev/null +++ b/5_django_backend/.env.example @@ -0,0 +1,17 @@ +# CodeAssist 전용 OpenCode 서버 (opencode/ 폴더에서 `opencode serve --port 4096`) +OPENCODE_BASE_URL=http://localhost:4096 +OPENCODE_AGENT=codeassist + +# 토큰 서명 키 (비우면 data/.auth_secret 자동 생성) +AUTH_SECRET= + +# DB — 비우면 SQLite(data/app.db) +DB_HOST= +DB_PORT=5432 +DB_USER= +DB_PASSWORD= +DB_NAME= +DB_SCHEMA=codeassist + +# 프론트 origin (dev 는 Vite proxy 라 안 탐. Tauri 빌드용) +CORS_ORIGINS=http://localhost:15173,http://tauri.localhost,tauri://localhost diff --git a/5_django_backend/.gitignore b/5_django_backend/.gitignore new file mode 100644 index 0000000..8b05b25 --- /dev/null +++ b/5_django_backend/.gitignore @@ -0,0 +1,7 @@ +.venv/ +data/ +.env +__pycache__/ +*.pyc +staticfiles/ +.pytest_cache/ diff --git a/5_django_backend/README.md b/5_django_backend/README.md new file mode 100644 index 0000000..7436ce3 --- /dev/null +++ b/5_django_backend/README.md @@ -0,0 +1,77 @@ +# 5_django_backend — CodeAssist 백엔드 (Django + OpenCode) + +프론트(`2_frontend`)가 기대하는 base-backend 계약을 그대로 구현한 Django 서버. AI 답변은 CodeAssist 전용 OpenCode 서버(`opencode/`)가 만들고, 여기는 인증·세션 미러·SSE 어댑터를 맡음. +ABAP_OPENCODE `web/BE` 에서 뼈대(settings·JWT·OpenCode 클라)를 복사해 왔고, 그쪽 서버와는 독립. + +``` +CodeAssist(Tauri/React) ──/api/v1──▶ Django(uvicorn :8001) ──HTTP/SSE──▶ opencode serve(:4096) ──▶ LLM + Bearer JWT (localStorage) ├─ accounts 로그인·refresh + ├─ chat 세션·검색 미러(SQLite/PG) + /chat/stream 어댑터 + └─ opencode/ workspace (AGENTS.md·agent·opencode.json) +``` + +## 띄우기 + +```bash +# 1) OpenCode (터미널 1) +cd 5_django_backend/opencode +export OPENROUTER_API_KEY=sk-or-... +npx -y opencode-ai@1.18.6 serve --hostname 127.0.0.1 --port 4096 + +# 2) Django (터미널 2) +cd 5_django_backend +python3.12 -m venv .venv && .venv/bin/pip install -r requirements.txt +cp .env.example .env # 필요하면 값 수정 +.venv/bin/python manage.py migrate # 빈 DB 면 admin@codeassist.local / guest@codeassist.local (test1234) 시드 +.venv/bin/uvicorn config.asgi:application --host 0.0.0.0 --port 8001 --reload + +# 3) 프론트 (터미널 3) — .env 의 VITE_DEV_API_TARGET 기본이 :8001 이라 그대로 +cd 2_frontend && npm run dev +``` + +`runserver` 말고 **uvicorn(ASGI)** 이어야 SSE 가 버퍼링 없이 흐름. + +## API (전부 envelope `CommonResponse`) + +| 엔드포인트 | 설명 | +|---|---| +| `GET /api/v1/health` | 상태 (무인증) | +| `POST /api/v1/auth/login` `{email,password}` | `TokenResponse` — access 60분 / refresh 14일 | +| `POST /api/v1/auth/refresh` `{refreshToken}` | access 재발급 (refresh 회전 안 함). 없으면 401 `REFRESH_TOKEN_MISSING` | +| `POST /api/v1/auth/logout` | null (클라가 토큰 버림) | +| `GET /api/v1/users/me` | `UserResponse` | +| `GET /api/v1/auth/entra/config` | 항상 501 — 프론트가 Entra 버튼 숨김 | +| `GET /api/v1/chat/sessions?page&limit` | 내 세션 목록, 최근순 + meta | +| `POST /api/v1/chat/sessions` | OpenCode 세션 생성 + 미러 | +| `GET /api/v1/chat/sessions/search?query&page&limit` | 내 메시지 본문 검색 | +| `GET /api/v1/chat/sessions/{id}/messages` | 세션 + 메시지 (`isGenerating` 폴링용) | +| `POST /api/v1/chat/sessions/{id}/cancel` | OpenCode abort + isGenerating 해제 | +| `POST /api/v1/chat/stream` | SSE — `token{delta}`… `title{title}` `usage{used,limit,ratio,elapsed_ms}` `done{}` / `error{message,code}` | + +에러 code: `INVALID_CREDENTIALS` `REFRESH_TOKEN_MISSING` `REFRESH_TOKEN_INVALID` `CHAT_GENERATION_IN_PROGRESS`(409) `UPSTREAM_UNAVAILABLE`(503) `LLM_ERROR` `LLM_ABORTED`. + +## 스트림이 도는 법 (`apps/chat/stream.py`) + +1. Bearer 검사 → 내 세션 → `is_generating` 이면 409. +2. user 메시지 미러 저장, `is_generating=true`. +3. `apps/chat/events.py` 버스(프로세스당 하나가 OpenCode `/event` 구독) 에 세션 큐 등록 → `POST /session/{id}/prompt_async`. +4. `message.part.updated`(text, delta) → `token`, `session.updated` 제목 → `title`, `session.idle` → OpenCode 에서 최종 메시지·tokens·cost 가져와 assistant 미러 저장 → `usage` → `done`. `session.error` → `error` + 부분 답변 저장. +5. 이 감시는 클라이언트와 별개 태스크라 창을 닫아도 idle 까지 돌고 DB 를 마무리함 → 프론트 `isGenerating` 폴링이 답변 복구. + +## 테스트 + +```bash +.venv/bin/python -m pytest -q # OpenCode 는 가짜(tests/test_stream.py FakeOpencode) +``` + +## 폴더 + +``` +config/ settings · urls · asgi +common/ envelope.py(응답 포장·CodedError) · opencode_service.py(OpenCode HTTP/SSE) +apps/accounts User(email 로그인) · JWT · 시드 +apps/chat models(ChatSession/ChatMessage) · views · events(버스) · stream(어댑터) +opencode/ OpenCode workspace — AGENTS.md · opencode.json · .opencode/agent/codeassist.md +docs-lib/ OpenCode SDK 1.18.6 타입 (API 진실원천) +tests/ pytest +``` diff --git a/5_django_backend/apps/__init__.py b/5_django_backend/apps/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/accounts/__init__.py b/5_django_backend/apps/accounts/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/accounts/apps.py b/5_django_backend/apps/accounts/apps.py new file mode 100644 index 0000000..6b824e0 --- /dev/null +++ b/5_django_backend/apps/accounts/apps.py @@ -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) diff --git a/5_django_backend/apps/accounts/authentication.py b/5_django_backend/apps/accounts/authentication.py new file mode 100644 index 0000000..d1f035d --- /dev/null +++ b/5_django_backend/apps/accounts/authentication.py @@ -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 diff --git a/5_django_backend/apps/accounts/migrations/0001_initial.py b/5_django_backend/apps/accounts/migrations/0001_initial.py new file mode 100644 index 0000000..831da53 --- /dev/null +++ b/5_django_backend/apps/accounts/migrations/0001_initial.py @@ -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()), + ], + ), + ] diff --git a/5_django_backend/apps/accounts/migrations/__init__.py b/5_django_backend/apps/accounts/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/accounts/models.py b/5_django_backend/apps/accounts/models.py new file mode 100644 index 0000000..1d34117 --- /dev/null +++ b/5_django_backend/apps/accounts/models.py @@ -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(), + } diff --git a/5_django_backend/apps/accounts/seed.py b/5_django_backend/apps/accounts/seed.py new file mode 100644 index 0000000..8f136e0 --- /dev/null +++ b/5_django_backend/apps/accounts/seed.py @@ -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)) diff --git a/5_django_backend/apps/accounts/serializers.py b/5_django_backend/apps/accounts/serializers.py new file mode 100644 index 0000000..1af2636 --- /dev/null +++ b/5_django_backend/apps/accounts/serializers.py @@ -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="") diff --git a/5_django_backend/apps/accounts/urls.py b/5_django_backend/apps/accounts/urls.py new file mode 100644 index 0000000..d98ccb0 --- /dev/null +++ b/5_django_backend/apps/accounts/urls.py @@ -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()), +] diff --git a/5_django_backend/apps/accounts/views.py b/5_django_backend/apps/accounts/views.py new file mode 100644 index 0000000..afc3f93 --- /dev/null +++ b/5_django_backend/apps/accounts/views.py @@ -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 로그인은 지원하지 않아") diff --git a/5_django_backend/apps/chat/__init__.py b/5_django_backend/apps/chat/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/chat/apps.py b/5_django_backend/apps/chat/apps.py new file mode 100644 index 0000000..f71fae7 --- /dev/null +++ b/5_django_backend/apps/chat/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class ChatConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.chat" + verbose_name = "채팅" diff --git a/5_django_backend/apps/chat/events.py b/5_django_backend/apps/chat/events.py new file mode 100644 index 0000000..e5f6636 --- /dev/null +++ b/5_django_backend/apps/chat/events.py @@ -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() diff --git a/5_django_backend/apps/chat/migrations/0001_initial.py b/5_django_backend/apps/chat/migrations/0001_initial.py new file mode 100644 index 0000000..17606a6 --- /dev/null +++ b/5_django_backend/apps/chat/migrations/0001_initial.py @@ -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"], + }, + ), + ] diff --git a/5_django_backend/apps/chat/migrations/__init__.py b/5_django_backend/apps/chat/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/apps/chat/models.py b/5_django_backend/apps/chat/models.py new file mode 100644 index 0000000..1ed5eae --- /dev/null +++ b/5_django_backend/apps/chat/models.py @@ -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, + } diff --git a/5_django_backend/apps/chat/stream.py b/5_django_backend/apps/chat/stream.py new file mode 100644 index 0000000..fbc1501 --- /dev/null +++ b/5_django_backend/apps/chat/stream.py @@ -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 diff --git a/5_django_backend/apps/chat/urls.py b/5_django_backend/apps/chat/urls.py new file mode 100644 index 0000000..1aaf8b0 --- /dev/null +++ b/5_django_backend/apps/chat/urls.py @@ -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()), # 보다 먼저 + path("sessions//messages", SessionMessagesView.as_view()), + path("sessions//cancel", CancelView.as_view()), +] diff --git a/5_django_backend/apps/chat/views.py b/5_django_backend/apps/chat/views.py new file mode 100644 index 0000000..9675403 --- /dev/null +++ b/5_django_backend/apps/chat/views.py @@ -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) diff --git a/5_django_backend/common/__init__.py b/5_django_backend/common/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/common/envelope.py b/5_django_backend/common/envelope.py new file mode 100644 index 0000000..b66f237 --- /dev/null +++ b/5_django_backend/common/envelope.py @@ -0,0 +1,154 @@ +""" +응답 envelope — 프론트 `types/api.ts` 의 CommonResponse 와 1:1. + +{ + success, statusCode, code, message, data, counts, errors, timestamp, meta +} + +- DRF 뷰: 그냥 `Response(data)` 하면 EnvelopeRenderer 가 감쌈. + 목록은 `Response(paged(items, page, limit, total))` — data=items, meta 채워짐. +- DRF 예외: envelope_exception_handler 가 success:false 로 감쌈. code 는 CodedError 로 지정. +- 순수 Django async 뷰(스트림): `json_error()` 로 직접 만듦. +""" + +from datetime import datetime, timezone + +from django.http import JsonResponse +from rest_framework.exceptions import APIException +from rest_framework.renderers import JSONRenderer + +_LIST_MARK = "__paged__" + + +def _now() -> str: + return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z") + + +def envelope( + *, + success: bool, + status_code: int, + data=None, + code: str | None = None, + message: str | None = None, + errors: list[str] | None = None, + counts: int | None = None, + meta: dict | None = None, +) -> dict: + return { + "success": success, + "statusCode": status_code, + "code": code, + "message": message, + "data": data, + "counts": counts, + "errors": errors or [], + "timestamp": _now(), + "meta": meta, + } + + +def paged(items: list, page: int, limit: int, total: int) -> dict: + """목록 응답용. Renderer 가 data=items, meta=페이지 정보로 풀어줌.""" + pages = (total + limit - 1) // limit if limit > 0 else 0 + return { + _LIST_MARK: True, + "items": items, + "counts": total, + "meta": { + "currentPage": page, + "pageSize": limit, + "totalItems": total, + "totalPages": pages, + "hasNextPage": page < pages, + "hasPreviousPage": page > 1, + }, + } + + +def _is_wrapped(data) -> bool: + return isinstance(data, dict) and "success" in data and "statusCode" in data + + +class EnvelopeRenderer(JSONRenderer): + def render(self, data, accepted_media_type=None, renderer_context=None): + response = (renderer_context or {}).get("response") + status_code = getattr(response, "status_code", 200) + if _is_wrapped(data): + body = data + elif isinstance(data, dict) and data.get(_LIST_MARK): + body = envelope( + success=True, + status_code=status_code, + data=data["items"], + counts=data["counts"], + meta=data["meta"], + ) + else: + body = envelope(success=True, status_code=status_code, data=data) + return super().render(body, accepted_media_type, renderer_context) + + +class CodedError(APIException): + """상태코드 + 프론트가 분기하는 code 문자열을 같이 던짐.""" + + def __init__(self, status_code: int, code: str, message: str): + super().__init__(detail=message) + self.status_code = status_code + self.code_name = code + + +_DEFAULT_CODES = { + 400: "VALIDATION_ERROR", + 401: "UNAUTHORIZED", + 403: "FORBIDDEN", + 404: "NOT_FOUND", + 405: "METHOD_NOT_ALLOWED", + 409: "CONFLICT", + 501: "NOT_IMPLEMENTED", + 502: "UPSTREAM_ERROR", + 503: "UPSTREAM_UNAVAILABLE", +} + + +def _flatten(data) -> str: + """serializer 검증 오류(dict/list)를 한 줄로.""" + if isinstance(data, str): + return data + if isinstance(data, list): + return " ".join(_flatten(d) for d in data) + if isinstance(data, dict): + parts = [] + for k, v in data.items(): + msg = _flatten(v) + parts.append(msg if k in ("detail", "non_field_errors") else f"{k}: {msg}") + return " ".join(parts) + return str(data) + + +def envelope_exception_handler(exc, context): + # rest_framework.views 는 settings 의 renderer(=이 모듈)를 import 하므로 여기서 늦게 import (순환 방지) + from rest_framework.views import exception_handler + + response = exception_handler(exc, context) + if response is None: + return None + code = getattr(exc, "code_name", None) or _DEFAULT_CODES.get(response.status_code, "ERROR") + message = _flatten(response.data) + response.data = envelope( + success=False, + status_code=response.status_code, + code=code, + message=message, + errors=[message], + ) + return response + + +def json_error(status_code: int, code: str, message: str) -> JsonResponse: + """DRF 안 거치는 async 뷰용.""" + return JsonResponse( + envelope(success=False, status_code=status_code, code=code, message=message, errors=[message]), + status=status_code, + json_dumps_params={"ensure_ascii": False}, + ) diff --git a/5_django_backend/common/opencode_service.py b/5_django_backend/common/opencode_service.py new file mode 100644 index 0000000..fd5cbed --- /dev/null +++ b/5_django_backend/common/opencode_service.py @@ -0,0 +1,167 @@ +""" +=================================================================== +모듈: common/opencode_service.py +설명: OpenCode 서버(opencode serve)와의 HTTP 통신 + + - JSON API (세션/메시지/프로바이더/에이전트) → 동기 httpx (DRF 뷰용) + - SSE 이벤트 스트림 → 비동기 (SSE 뷰·파일 워처용) +=================================================================== +""" + +import asyncio +import logging +from typing import Any, AsyncIterator + +import httpx +from django.conf import settings + +log = logging.getLogger(__name__) + +# 메시지 전송은 LLM 응답 완료까지 걸리므로 타임아웃을 길게 잡는다 (10분) +LONG_TIMEOUT = httpx.Timeout(600.0, connect=10.0) +DEFAULT_TIMEOUT = httpx.Timeout(30.0, connect=10.0) + + +class OpencodeService: + """OpenCode 서버 프록시.""" + + def __init__(self) -> None: + self.base_url = settings.OPENCODE_BASE_URL + # 모든 요청에 ?directory= 를 붙여 CodeAssist workspace 프로젝트로 고정 (1.18 멀티 프로젝트 서버) + self.directory = getattr(settings, "OPENCODE_DIRECTORY", "") or None + + def _params(self) -> dict | None: + return {"directory": self.directory} if self.directory else None + + def _request( + self, + method: str, + path: str, + json: Any | None = None, + timeout: httpx.Timeout = DEFAULT_TIMEOUT, + ) -> Any: + with httpx.Client(base_url=self.base_url, timeout=timeout) as client: + response = client.request(method, path, json=json, params=self._params()) + response.raise_for_status() + return response.json() if response.content else None + + # ── 세션 + def list_sessions(self) -> Any: + return self._request("GET", "/session") + + def create_session(self) -> Any: + return self._request("POST", "/session", json={}) + + def delete_session(self, session_id: str) -> Any: + return self._request("DELETE", f"/session/{session_id}") + + def rename_session(self, session_id: str, title: str) -> Any: + return self._request("PATCH", f"/session/{session_id}", json={"title": title}) + + # ── 메시지 + def list_messages(self, session_id: str) -> Any: + return self._request("GET", f"/session/{session_id}/message") + + def abort_session(self, session_id: str) -> Any: + log.info("응답 중단: session=%s", session_id) + return self._request("POST", f"/session/{session_id}/abort") + + def send_message(self, session_id: str, payload: dict) -> Any: + log.info( + "메시지 전송: session=%s provider=%s model=%s", + session_id, payload.get("providerID"), payload.get("modelID"), + ) + # OpenCode 1.18+ 는 최상위 providerID/modelID 를 무시한다(세션 기본 모델 사용). + # model:{providerID,modelID} 오브젝트 형식으로 변환해서 보내야 선택이 먹는다. + provider_id = payload.pop("providerID", None) + model_id = payload.pop("modelID", None) + if provider_id and model_id: + payload["model"] = {"providerID": provider_id, "modelID": model_id} + return self._request( + "POST", f"/session/{session_id}/message", json=payload, timeout=LONG_TIMEOUT + ) + + # ── 설정 + def get_providers(self) -> Any: + return self._request("GET", "/config/providers") + + def list_agents(self) -> Any: + return self._request("GET", "/agent") + + # ── 비동기 버전 (스트림 어댑터용 — 이벤트 루프 안에서 블록 없이) + async def _arequest(self, method: str, path: str, json: Any | None = None) -> Any: + async with httpx.AsyncClient(base_url=self.base_url, timeout=DEFAULT_TIMEOUT) as client: + response = await client.request(method, path, json=json, params=self._params()) + response.raise_for_status() + return response.json() if response.content else None + + async def prompt_async(self, session_id: str, payload: dict) -> None: + """POST /session/{id}/prompt_async — 204 즉시 반환, 답변은 /event 로 흘러옴.""" + await self._arequest("POST", f"/session/{session_id}/prompt_async", json=payload) + + async def get_session_a(self, session_id: str) -> Any: + return await self._arequest("GET", f"/session/{session_id}") + + async def list_messages_a(self, session_id: str) -> Any: + return await self._arequest("GET", f"/session/{session_id}/message") + + async def abort_session_a(self, session_id: str) -> Any: + return await self._arequest("POST", f"/session/{session_id}/abort") + + # ── SSE 이벤트 스트림 (비동기) + async def iter_event_jsons(self) -> AsyncIterator[str | None]: + """SSE 프레임을 파싱해 이벤트 JSON 문자열 단위로 순회한다. + + 자가 치유: 업스트림이 60초간 조용하면 연결을 재수립한다 — + 서버 재시작 등으로 반쯤 죽은 소켓이 남아도 스스로 복구된다. + 무활동/재연결 시점에는 None(하트비트)을 내보낸다.""" + stream_timeout = httpx.Timeout(None, connect=10.0) + while True: + try: + async with httpx.AsyncClient( + base_url=self.base_url, timeout=stream_timeout + ) as client: + async with client.stream("GET", "/event", params=self._params()) as response: + response.raise_for_status() + data_lines: list[str] = [] + lines = response.aiter_lines() + while True: + try: + line = await asyncio.wait_for(lines.__anext__(), timeout=60.0) + except asyncio.TimeoutError: + yield None # 60초 무활동 — 하트비트 후 재연결 + break + except StopAsyncIteration: + break + if line == "": + if data_lines: + yield "\n".join(data_lines) + data_lines = [] + elif line.startswith("data:"): + data_lines.append(line[5:].lstrip()) + # 그 외 필드(id:, event:, 코멘트)는 무시 + except Exception: + log.warning("OpenCode SSE 업스트림 끊김 — 2초 후 재연결") + yield None + await asyncio.sleep(2) + + +opencode_service = OpencodeService() + + +def upstream_error(e: Exception): + """OpenCode 서버 에러 → DRF APIException (FE 는 detail 필드를 읽는다).""" + from rest_framework.exceptions import APIException + + if isinstance(e, httpx.ConnectError): + exc = APIException("OpenCode 서버에 연결할 수 없습니다. opencode serve 실행 여부를 확인하세요.") + exc.status_code = 503 + return exc + if isinstance(e, httpx.HTTPStatusError): + exc = APIException(f"OpenCode 서버 오류: {e.response.text[:500]}") + exc.status_code = e.response.status_code + return exc + log.exception("OpenCode 중계 오류") + exc = APIException(f"OpenCode 중계 오류: {e}") + exc.status_code = 502 + return exc diff --git a/5_django_backend/config/__init__.py b/5_django_backend/config/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/config/asgi.py b/5_django_backend/config/asgi.py new file mode 100644 index 0000000..f4af22c --- /dev/null +++ b/5_django_backend/config/asgi.py @@ -0,0 +1,11 @@ +"""ASGI 진입점 — SSE 때문에 반드시 uvicorn 으로. + uvicorn config.asgi:application --host 0.0.0.0 --port 8001 +""" + +import os + +from django.core.asgi import get_asgi_application + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + +application = get_asgi_application() diff --git a/5_django_backend/config/settings.py b/5_django_backend/config/settings.py new file mode 100644 index 0000000..69349f9 --- /dev/null +++ b/5_django_backend/config/settings.py @@ -0,0 +1,208 @@ +""" +서버 설정표 — 켤 때 한 번 읽음. + +값 우선순위: 환경변수 > 5_django_backend/.env > 여기 기본값. +DB 는 DB_HOST 있으면 PostgreSQL, 없으면 data/app.db (SQLite). + +ABAP_OPENCODE web/BE/code/config/settings.py 에서 CodeAssist 에 필요한 것만 남김 +(산출물·ITO 게이트웨이·bare 배포 관련은 뺌). +""" + +import os +import secrets +from datetime import timedelta +from pathlib import Path + +BASE_DIR = Path(__file__).resolve().parent.parent + + +def _load_dotenv(path: Path) -> None: + """이름=값 줄을 환경변수로 올림. 이미 있는 환경변수가 우선.""" + if not path.exists(): + return + for line in path.read_text(encoding="utf-8").splitlines(): + line = line.strip() + if not line or line.startswith("#") or "=" not in line: + continue + k, v = line.split("=", 1) + os.environ.setdefault(k.strip(), v.strip().strip('"\x27')) + + +_load_dotenv(BASE_DIR / ".env") + +DATA_DIR = BASE_DIR / "data" +DATA_DIR.mkdir(parents=True, exist_ok=True) + + +def _env(name: str, default: str = "") -> str: + return os.environ.get(name, default).strip() + + +def _env_bool(name: str, default: bool) -> bool: + v = _env(name) + return default if not v else v.lower() in ("1", "true", "yes", "on") + + +APP_NAME = _env("APP_NAME", "CodeAssist Backend") +APP_VERSION = "0.1.0" + + +def _secret_key() -> str: + """토큰 서명 키. 바뀌면 전원 재로그인이라 파일에 저장해 재사용.""" + env = _env("AUTH_SECRET") + if env: + return env + p = DATA_DIR / ".auth_secret" + if p.exists(): + return p.read_text(encoding="utf-8").strip() or secrets.token_hex(32) + generated = secrets.token_hex(32) + p.write_text(generated, encoding="utf-8") + return generated + + +SECRET_KEY = _secret_key() +DEBUG = _env_bool("DEBUG", True) +ALLOWED_HOSTS = ["*"] + +# ── 바깥 것들 ───────────────────────────────────────────────────── +# CodeAssist 전용 OpenCode 인스턴스 (opencode/ workspace 로 띄운 것) +OPENCODE_BASE_URL = _env("OPENCODE_BASE_URL", "http://localhost:4096").rstrip("/") +# OpenCode 1.18 은 서버 하나가 여러 프로젝트(directory)를 다루고 요청마다 ?directory= 로 고름. +# 안 넘기면 마지막에 열린 프로젝트로 가서 세션이 엉뚱한 workspace 에 생김 → 항상 넘긴다. +# 기본은 이 repo 의 opencode/ workspace. 서버를 다른 경로에서 띄우면 그 절대경로로. +OPENCODE_DIRECTORY = _env("OPENCODE_DIRECTORY") or str((BASE_DIR / "opencode").resolve()) +# OpenCode 에 넘길 에이전트 이름 (opencode/.opencode/agent/<이름>.md) +OPENCODE_AGENT = _env("OPENCODE_AGENT", "codeassist") +# 세션 컨텍스트 하드 한도 — usage.limit 로 프론트 게이지에 감 +CONTEXT_LIMIT_TOKENS = int(_env("CONTEXT_LIMIT_TOKENS", "128000") or "128000") + +INSTALLED_APPS = [ + "django.contrib.admin", + "django.contrib.auth", + "django.contrib.contenttypes", + "django.contrib.sessions", + "django.contrib.messages", + "django.contrib.staticfiles", + "rest_framework", + "corsheaders", + "apps.accounts", + "apps.chat", +] + +MIDDLEWARE = [ + "corsheaders.middleware.CorsMiddleware", + "django.middleware.security.SecurityMiddleware", + "django.contrib.sessions.middleware.SessionMiddleware", + "django.middleware.common.CommonMiddleware", + "django.middleware.csrf.CsrfViewMiddleware", + "django.contrib.auth.middleware.AuthenticationMiddleware", + "django.contrib.messages.middleware.MessageMiddleware", +] + +ROOT_URLCONF = "config.urls" +ASGI_APPLICATION = "config.asgi.application" + +TEMPLATES = [ + { + "BACKEND": "django.template.backends.django.DjangoTemplates", + "DIRS": [], + "APP_DIRS": True, + "OPTIONS": { + "context_processors": [ + "django.template.context_processors.request", + "django.contrib.auth.context_processors.auth", + "django.contrib.messages.context_processors.messages", + ], + }, + }, +] + +# ── DB ──────────────────────────────────────────────────────────── +_pg_host = _env("DB_HOST") +if _pg_host: + _schema = _env("DB_SCHEMA", "codeassist") or "codeassist" + DATABASES = { + "default": { + "ENGINE": "django.db.backends.postgresql", + "HOST": _pg_host, + "PORT": _env("DB_PORT", "5432") or "5432", + "USER": _env("DB_USER"), + "PASSWORD": _env("DB_PASSWORD"), + "NAME": _env("DB_NAME"), + # ASGI 스레드마다 연결 눌러앉는 것 방지 — 요청 끝나면 바로 반납 + "CONN_MAX_AGE": 0, + "OPTIONS": { + "sslmode": "require", + "connect_timeout": 10, + "options": f"-c search_path={_schema}", + }, + } + } +else: + DATABASES = { + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": DATA_DIR / "app.db", + } + } + +DEFAULT_AUTO_FIELD = "django.db.models.BigAutoField" + +# ── 인증 ───────────────────────────────────────────────────────── +AUTH_USER_MODEL = "accounts.User" +AUTH_PASSWORD_VALIDATORS = [] + +REST_FRAMEWORK = { + "DEFAULT_AUTHENTICATION_CLASSES": [ + "apps.accounts.authentication.BearerOrQueryJWTAuthentication", + ], + "DEFAULT_PERMISSION_CLASSES": ["rest_framework.permissions.IsAuthenticated"], + # 모든 응답을 프론트 CommonResponse envelope 로 감쌈 + "DEFAULT_RENDERER_CLASSES": ["common.envelope.EnvelopeRenderer"], + "DEFAULT_PARSER_CLASSES": ["rest_framework.parsers.JSONParser"], + "EXCEPTION_HANDLER": "common.envelope.envelope_exception_handler", + "UNAUTHENTICATED_USER": None, +} + +# 프론트가 401 → /auth/refresh 자동 재시도를 하므로 access 는 짧게, refresh 는 길게. +SIMPLE_JWT = { + "ACCESS_TOKEN_LIFETIME": timedelta(minutes=60), + "REFRESH_TOKEN_LIFETIME": timedelta(days=14), + "AUTH_HEADER_TYPES": ("Bearer",), + "USER_ID_FIELD": "id", + "USER_ID_CLAIM": "uid", + "SIGNING_KEY": SECRET_KEY, +} + +# ── CORS ───────────────────────────────────────────────────────── +# dev: Vite proxy 로 same-origin 이라 안 탐. Tauri 빌드는 tauri://localhost 에서 오므로 여기 필요. +CORS_ALLOWED_ORIGINS = [ + o.strip() + for o in _env( + "CORS_ORIGINS", "http://localhost:15173,http://tauri.localhost,tauri://localhost" + ).split(",") + if o.strip() +] +CORS_ALLOW_CREDENTIALS = True + +# 채팅 이미지 4장 × 5MiB + 여유 (프론트 제한과 동일 계열) +DATA_UPLOAD_MAX_MEMORY_SIZE = 32 * 1024 * 1024 + +LANGUAGE_CODE = "ko-kr" +TIME_ZONE = "Asia/Seoul" +USE_I18N = True +USE_TZ = True +STATIC_URL = "static/" +STATIC_ROOT = BASE_DIR / "staticfiles" + +LOGGING = { + "version": 1, + "disable_existing_loggers": False, + "formatters": {"std": {"format": "%(asctime)s %(levelname)s %(name)s: %(message)s"}}, + "handlers": {"console": {"class": "logging.StreamHandler", "formatter": "std"}}, + "root": {"handlers": ["console"], "level": "INFO"}, + "loggers": { + "django.request": {"level": "WARNING"}, + "httpx": {"level": "WARNING"}, + }, +} diff --git a/5_django_backend/config/urls.py b/5_django_backend/config/urls.py new file mode 100644 index 0000000..bb95e78 --- /dev/null +++ b/5_django_backend/config/urls.py @@ -0,0 +1,24 @@ +"""최상위 URL — 프론트 VITE_API_BASE_URL(/api/v1) 아래에 매닮.""" + +from django.conf import settings +from django.contrib import admin +from django.http import JsonResponse +from django.urls import include, path + +from apps.accounts.views import MeView +from common.envelope import envelope + + +def health(_request): + return JsonResponse( + envelope(success=True, status_code=200, data={"app": settings.APP_NAME, "version": settings.APP_VERSION}) + ) + + +urlpatterns = [ + path("admin/", admin.site.urls), + path("api/v1/health", health), + path("api/v1/auth/", include("apps.accounts.urls")), + path("api/v1/users/me", MeView.as_view()), + path("api/v1/chat/", include("apps.chat.urls")), +] diff --git a/5_django_backend/docs-lib/README.md b/5_django_backend/docs-lib/README.md new file mode 100644 index 0000000..f3afd18 --- /dev/null +++ b/5_django_backend/docs-lib/README.md @@ -0,0 +1,9 @@ +# docs-lib — 5_django_backend 가 쓰는 라이브러리 참고 문서 + +구현 전 여기부터 `Grep` 으로 필요한 부분만 꺼내 본다. 기억·추측으로 API 쓰지 말 것. + +| 파일 | 뭐냐 | 출처 | +|---|---|---| +| `opencode-sdk-1.18.6-types.d.ts` | OpenCode 서버(`opencode serve`) HTTP API 전체 타입 — 세션·메시지·파트·이벤트·엔드포인트 url. `url: "/session…"` 로 grep 하면 엔드포인트, `export type Event…` 로 grep 하면 SSE 이벤트 | `@opencode-ai/sdk@1.18.6` `dist/gen/types.gen.d.ts` (ABAP_OPENCODE `.opencode/node_modules`) | + +갱신: OpenCode 버전 올리면 같은 경로에서 다시 복사하고 파일명 버전 바꿈. diff --git a/5_django_backend/docs-lib/opencode-sdk-1.18.6-types.d.ts b/5_django_backend/docs-lib/opencode-sdk-1.18.6-types.d.ts new file mode 100644 index 0000000..b9ff2e4 --- /dev/null +++ b/5_django_backend/docs-lib/opencode-sdk-1.18.6-types.d.ts @@ -0,0 +1,3383 @@ +export type EventServerInstanceDisposed = { + type: "server.instance.disposed"; + properties: { + directory: string; + }; +}; +export type EventInstallationUpdated = { + type: "installation.updated"; + properties: { + version: string; + }; +}; +export type EventInstallationUpdateAvailable = { + type: "installation.update-available"; + properties: { + version: string; + }; +}; +export type EventLspClientDiagnostics = { + type: "lsp.client.diagnostics"; + properties: { + serverID: string; + path: string; + }; +}; +export type EventLspUpdated = { + type: "lsp.updated"; + properties: { + [key: string]: unknown; + }; +}; +export type FileDiff = { + file: string; + before: string; + after: string; + additions: number; + deletions: number; +}; +export type UserMessage = { + id: string; + sessionID: string; + role: "user"; + time: { + created: number; + }; + summary?: { + title?: string; + body?: string; + diffs: Array; + }; + agent: string; + model: { + providerID: string; + modelID: string; + }; + system?: string; + tools?: { + [key: string]: boolean; + }; +}; +export type ProviderAuthError = { + name: "ProviderAuthError"; + data: { + providerID: string; + message: string; + }; +}; +export type UnknownError = { + name: "UnknownError"; + data: { + message: string; + }; +}; +export type MessageOutputLengthError = { + name: "MessageOutputLengthError"; + data: { + [key: string]: unknown; + }; +}; +export type MessageAbortedError = { + name: "MessageAbortedError"; + data: { + message: string; + }; +}; +export type ApiError = { + name: "APIError"; + data: { + message: string; + statusCode?: number; + isRetryable: boolean; + responseHeaders?: { + [key: string]: string; + }; + responseBody?: string; + }; +}; +export type AssistantMessage = { + id: string; + sessionID: string; + role: "assistant"; + time: { + created: number; + completed?: number; + }; + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError; + parentID: string; + modelID: string; + providerID: string; + mode: string; + path: { + cwd: string; + root: string; + }; + summary?: boolean; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; + finish?: string; +}; +export type Message = UserMessage | AssistantMessage; +export type EventMessageUpdated = { + type: "message.updated"; + properties: { + info: Message; + }; +}; +export type EventMessageRemoved = { + type: "message.removed"; + properties: { + sessionID: string; + messageID: string; + }; +}; +export type TextPart = { + id: string; + sessionID: string; + messageID: string; + type: "text"; + text: string; + synthetic?: boolean; + ignored?: boolean; + time?: { + start: number; + end?: number; + }; + metadata?: { + [key: string]: unknown; + }; +}; +export type ReasoningPart = { + id: string; + sessionID: string; + messageID: string; + type: "reasoning"; + text: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + end?: number; + }; +}; +export type FilePartSourceText = { + value: string; + start: number; + end: number; +}; +export type FileSource = { + text: FilePartSourceText; + type: "file"; + path: string; +}; +export type Range = { + start: { + line: number; + character: number; + }; + end: { + line: number; + character: number; + }; +}; +export type SymbolSource = { + text: FilePartSourceText; + type: "symbol"; + path: string; + range: Range; + name: string; + kind: number; +}; +export type FilePartSource = FileSource | SymbolSource; +export type FilePart = { + id: string; + sessionID: string; + messageID: string; + type: "file"; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; +export type ToolStatePending = { + status: "pending"; + input: { + [key: string]: unknown; + }; + raw: string; +}; +export type ToolStateRunning = { + status: "running"; + input: { + [key: string]: unknown; + }; + title?: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + }; +}; +export type ToolStateCompleted = { + status: "completed"; + input: { + [key: string]: unknown; + }; + output: string; + title: string; + metadata: { + [key: string]: unknown; + }; + time: { + start: number; + end: number; + compacted?: number; + }; + attachments?: Array; +}; +export type ToolStateError = { + status: "error"; + input: { + [key: string]: unknown; + }; + error: string; + metadata?: { + [key: string]: unknown; + }; + time: { + start: number; + end: number; + }; +}; +export type ToolState = ToolStatePending | ToolStateRunning | ToolStateCompleted | ToolStateError; +export type ToolPart = { + id: string; + sessionID: string; + messageID: string; + type: "tool"; + callID: string; + tool: string; + state: ToolState; + metadata?: { + [key: string]: unknown; + }; +}; +export type StepStartPart = { + id: string; + sessionID: string; + messageID: string; + type: "step-start"; + snapshot?: string; +}; +export type StepFinishPart = { + id: string; + sessionID: string; + messageID: string; + type: "step-finish"; + reason: string; + snapshot?: string; + cost: number; + tokens: { + input: number; + output: number; + reasoning: number; + cache: { + read: number; + write: number; + }; + }; +}; +export type SnapshotPart = { + id: string; + sessionID: string; + messageID: string; + type: "snapshot"; + snapshot: string; +}; +export type PatchPart = { + id: string; + sessionID: string; + messageID: string; + type: "patch"; + hash: string; + files: Array; +}; +export type AgentPart = { + id: string; + sessionID: string; + messageID: string; + type: "agent"; + name: string; + source?: { + value: string; + start: number; + end: number; + }; +}; +export type RetryPart = { + id: string; + sessionID: string; + messageID: string; + type: "retry"; + attempt: number; + error: ApiError; + time: { + created: number; + }; +}; +export type CompactionPart = { + id: string; + sessionID: string; + messageID: string; + type: "compaction"; + auto: boolean; +}; +export type Part = TextPart | { + id: string; + sessionID: string; + messageID: string; + type: "subtask"; + prompt: string; + description: string; + agent: string; +} | ReasoningPart | FilePart | ToolPart | StepStartPart | StepFinishPart | SnapshotPart | PatchPart | AgentPart | RetryPart | CompactionPart; +export type EventMessagePartUpdated = { + type: "message.part.updated"; + properties: { + part: Part; + delta?: string; + }; +}; +export type EventMessagePartRemoved = { + type: "message.part.removed"; + properties: { + sessionID: string; + messageID: string; + partID: string; + }; +}; +export type Permission = { + id: string; + type: string; + pattern?: string | Array; + sessionID: string; + messageID: string; + callID?: string; + title: string; + metadata: { + [key: string]: unknown; + }; + time: { + created: number; + }; +}; +export type EventPermissionUpdated = { + type: "permission.updated"; + properties: Permission; +}; +export type EventPermissionReplied = { + type: "permission.replied"; + properties: { + sessionID: string; + permissionID: string; + response: string; + }; +}; +export type SessionStatus = { + type: "idle"; +} | { + type: "retry"; + attempt: number; + message: string; + next: number; +} | { + type: "busy"; +}; +export type EventSessionStatus = { + type: "session.status"; + properties: { + sessionID: string; + status: SessionStatus; + }; +}; +export type EventSessionIdle = { + type: "session.idle"; + properties: { + sessionID: string; + }; +}; +export type EventSessionCompacted = { + type: "session.compacted"; + properties: { + sessionID: string; + }; +}; +export type EventFileEdited = { + type: "file.edited"; + properties: { + file: string; + }; +}; +export type Todo = { + /** + * Brief description of the task + */ + content: string; + /** + * Current status of the task: pending, in_progress, completed, cancelled + */ + status: string; + /** + * Priority level of the task: high, medium, low + */ + priority: string; + /** + * Unique identifier for the todo item + */ + id: string; +}; +export type EventTodoUpdated = { + type: "todo.updated"; + properties: { + sessionID: string; + todos: Array; + }; +}; +export type EventCommandExecuted = { + type: "command.executed"; + properties: { + name: string; + sessionID: string; + arguments: string; + messageID: string; + }; +}; +export type Session = { + id: string; + projectID: string; + directory: string; + parentID?: string; + summary?: { + additions: number; + deletions: number; + files: number; + diffs?: Array; + }; + share?: { + url: string; + }; + title: string; + version: string; + time: { + created: number; + updated: number; + compacting?: number; + }; + revert?: { + messageID: string; + partID?: string; + snapshot?: string; + diff?: string; + }; +}; +export type EventSessionCreated = { + type: "session.created"; + properties: { + info: Session; + }; +}; +export type EventSessionUpdated = { + type: "session.updated"; + properties: { + info: Session; + }; +}; +export type EventSessionDeleted = { + type: "session.deleted"; + properties: { + info: Session; + }; +}; +export type EventSessionDiff = { + type: "session.diff"; + properties: { + sessionID: string; + diff: Array; + }; +}; +export type EventSessionError = { + type: "session.error"; + properties: { + sessionID?: string; + error?: ProviderAuthError | UnknownError | MessageOutputLengthError | MessageAbortedError | ApiError; + }; +}; +export type EventFileWatcherUpdated = { + type: "file.watcher.updated"; + properties: { + file: string; + event: "add" | "change" | "unlink"; + }; +}; +export type EventVcsBranchUpdated = { + type: "vcs.branch.updated"; + properties: { + branch?: string; + }; +}; +export type EventTuiPromptAppend = { + type: "tui.prompt.append"; + properties: { + text: string; + }; +}; +export type EventTuiCommandExecute = { + type: "tui.command.execute"; + properties: { + command: ("session.list" | "session.new" | "session.share" | "session.interrupt" | "session.compact" | "session.page.up" | "session.page.down" | "session.half.page.up" | "session.half.page.down" | "session.first" | "session.last" | "prompt.clear" | "prompt.submit" | "agent.cycle") | string; + }; +}; +export type EventTuiToastShow = { + type: "tui.toast.show"; + properties: { + title?: string; + message: string; + variant: "info" | "success" | "warning" | "error"; + /** + * Duration in milliseconds + */ + duration?: number; + }; +}; +export type Pty = { + id: string; + title: string; + command: string; + args: Array; + cwd: string; + status: "running" | "exited"; + pid: number; +}; +export type EventPtyCreated = { + type: "pty.created"; + properties: { + info: Pty; + }; +}; +export type EventPtyUpdated = { + type: "pty.updated"; + properties: { + info: Pty; + }; +}; +export type EventPtyExited = { + type: "pty.exited"; + properties: { + id: string; + exitCode: number; + }; +}; +export type EventPtyDeleted = { + type: "pty.deleted"; + properties: { + id: string; + }; +}; +export type EventServerConnected = { + type: "server.connected"; + properties: { + [key: string]: unknown; + }; +}; +export type Event = EventServerInstanceDisposed | EventInstallationUpdated | EventInstallationUpdateAvailable | EventLspClientDiagnostics | EventLspUpdated | EventMessageUpdated | EventMessageRemoved | EventMessagePartUpdated | EventMessagePartRemoved | EventPermissionUpdated | EventPermissionReplied | EventSessionStatus | EventSessionIdle | EventSessionCompacted | EventFileEdited | EventTodoUpdated | EventCommandExecuted | EventSessionCreated | EventSessionUpdated | EventSessionDeleted | EventSessionDiff | EventSessionError | EventFileWatcherUpdated | EventVcsBranchUpdated | EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow | EventPtyCreated | EventPtyUpdated | EventPtyExited | EventPtyDeleted | EventServerConnected; +export type GlobalEvent = { + directory: string; + payload: Event; +}; +export type Project = { + id: string; + worktree: string; + vcsDir?: string; + vcs?: "git"; + time: { + created: number; + initialized?: number; + }; +}; +export type BadRequestError = { + name: "BadRequest"; + data: { + message: string; + kind?: "Params" | "Headers" | "Query" | "Body" | "Payload"; + }; +}; +export type NotFoundError = { + name: "NotFoundError"; + data: { + message: string; + }; +}; +/** + * Custom keybind configurations + */ +export type KeybindsConfig = { + /** + * Leader key for keybind combinations + */ + leader?: string; + /** + * Exit the application + */ + app_exit?: string; + /** + * Open external editor + */ + editor_open?: string; + /** + * List available themes + */ + theme_list?: string; + /** + * Toggle sidebar + */ + sidebar_toggle?: string; + /** + * Toggle session scrollbar + */ + scrollbar_toggle?: string; + /** + * Toggle username visibility + */ + username_toggle?: string; + /** + * View status + */ + status_view?: string; + /** + * Export session to editor + */ + session_export?: string; + /** + * Create a new session + */ + session_new?: string; + /** + * List all sessions + */ + session_list?: string; + /** + * Show session timeline + */ + session_timeline?: string; + /** + * Share current session + */ + session_share?: string; + /** + * Unshare current session + */ + session_unshare?: string; + /** + * Interrupt current session + */ + session_interrupt?: string; + /** + * Compact the session + */ + session_compact?: string; + /** + * Scroll messages up by one page + */ + messages_page_up?: string; + /** + * Scroll messages down by one page + */ + messages_page_down?: string; + /** + * Scroll messages up by one line + */ + messages_line_up?: string; + /** + * Scroll messages down by one line + */ + messages_line_down?: string; + /** + * Scroll messages up by half page + */ + messages_half_page_up?: string; + /** + * Scroll messages down by half page + */ + messages_half_page_down?: string; + /** + * Navigate to first message + */ + messages_first?: string; + /** + * Navigate to last message + */ + messages_last?: string; + /** + * Navigate to next message + */ + messages_next?: string; + /** + * Navigate to previous message + */ + messages_previous?: string; + /** + * Navigate to last user message + */ + messages_last_user?: string; + /** + * Copy message + */ + messages_copy?: string; + /** + * Undo message + */ + messages_undo?: string; + /** + * Redo message + */ + messages_redo?: string; + /** + * Toggle code block concealment in messages + */ + messages_toggle_conceal?: string; + /** + * Toggle tool details visibility + */ + tool_details?: string; + /** + * List available models + */ + model_list?: string; + /** + * Next recently used model + */ + model_cycle_recent?: string; + /** + * Previous recently used model + */ + model_cycle_recent_reverse?: string; + /** + * List available commands + */ + command_list?: string; + /** + * List agents + */ + agent_list?: string; + /** + * Next agent + */ + agent_cycle?: string; + /** + * Previous agent + */ + agent_cycle_reverse?: string; + /** + * Clear input field + */ + input_clear?: string; + /** + * Forward delete + */ + input_forward_delete?: string; + /** + * Paste from clipboard + */ + input_paste?: string; + /** + * Submit input + */ + input_submit?: string; + /** + * Insert newline in input + */ + input_newline?: string; + /** + * Previous history item + */ + history_previous?: string; + /** + * Next history item + */ + history_next?: string; + /** + * Next child session + */ + session_child_cycle?: string; + /** + * Previous child session + */ + session_child_cycle_reverse?: string; + /** + * Suspend terminal + */ + terminal_suspend?: string; + /** + * Toggle terminal title + */ + terminal_title_toggle?: string; +}; +export type AgentConfig = { + model?: string; + temperature?: number; + top_p?: number; + prompt?: string; + tools?: { + [key: string]: boolean; + }; + disable?: boolean; + /** + * Description of when to use the agent + */ + description?: string; + mode?: "subagent" | "primary" | "all"; + /** + * Hex color code for the agent (e.g., #FF5733) + */ + color?: string; + /** + * Maximum number of agentic iterations before forcing text-only response + */ + maxSteps?: number; + permission?: { + edit?: "ask" | "allow" | "deny"; + bash?: ("ask" | "allow" | "deny") | { + [key: string]: "ask" | "allow" | "deny"; + }; + webfetch?: "ask" | "allow" | "deny"; + doom_loop?: "ask" | "allow" | "deny"; + external_directory?: "ask" | "allow" | "deny"; + }; + [key: string]: unknown | string | number | { + [key: string]: boolean; + } | boolean | ("subagent" | "primary" | "all") | number | { + edit?: "ask" | "allow" | "deny"; + bash?: ("ask" | "allow" | "deny") | { + [key: string]: "ask" | "allow" | "deny"; + }; + webfetch?: "ask" | "allow" | "deny"; + doom_loop?: "ask" | "allow" | "deny"; + external_directory?: "ask" | "allow" | "deny"; + } | undefined; +}; +export type ProviderConfig = { + api?: string; + name?: string; + env?: Array; + id?: string; + npm?: string; + models?: { + [key: string]: { + id?: string; + name?: string; + release_date?: string; + attachment?: boolean; + reasoning?: boolean; + temperature?: boolean; + tool_call?: boolean; + cost?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + context_over_200k?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; + limit?: { + context: number; + output: number; + }; + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf">; + output: Array<"text" | "audio" | "image" | "video" | "pdf">; + }; + experimental?: boolean; + status?: "alpha" | "beta" | "deprecated" | "active"; + options?: { + [key: string]: unknown; + }; + headers?: { + [key: string]: string; + }; + provider?: { + npm: string; + }; + }; + }; + whitelist?: Array; + blacklist?: Array; + options?: { + apiKey?: string; + baseURL?: string; + /** + * GitHub Enterprise URL for copilot authentication + */ + enterpriseUrl?: string; + /** + * Enable promptCacheKey for this provider (default false) + */ + setCacheKey?: boolean; + /** + * Timeout in milliseconds for requests to this provider. Default is 300000 (5 minutes). Set to false to disable timeout. + */ + timeout?: number | false; + [key: string]: unknown | string | boolean | (number | false) | undefined; + }; +}; +export type McpLocalConfig = { + /** + * Type of MCP server connection + */ + type: "local"; + /** + * Command and arguments to run the MCP server + */ + command: Array; + /** + * Environment variables to set when running the MCP server + */ + environment?: { + [key: string]: string; + }; + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean; + /** + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. + */ + timeout?: number; +}; +export type McpOAuthConfig = { + /** + * OAuth client ID. If not provided, dynamic client registration (RFC 7591) will be attempted. + */ + clientId?: string; + /** + * OAuth client secret (if required by the authorization server) + */ + clientSecret?: string; + /** + * OAuth scopes to request during authorization + */ + scope?: string; +}; +export type McpRemoteConfig = { + /** + * Type of MCP server connection + */ + type: "remote"; + /** + * URL of the remote MCP server + */ + url: string; + /** + * Enable or disable the MCP server on startup + */ + enabled?: boolean; + /** + * Headers to send with the request + */ + headers?: { + [key: string]: string; + }; + /** + * OAuth authentication configuration for the MCP server. Set to false to disable OAuth auto-detection. + */ + oauth?: McpOAuthConfig | false; + /** + * Timeout in ms for fetching tools from the MCP server. Defaults to 5000 (5 seconds) if not specified. + */ + timeout?: number; +}; +/** + * @deprecated Always uses stretch layout. + */ +export type LayoutConfig = "auto" | "stretch"; +export type Config = { + /** + * JSON schema reference for configuration validation + */ + $schema?: string; + /** + * Theme name to use for the interface + */ + theme?: string; + keybinds?: KeybindsConfig; + /** + * Log level + */ + logLevel?: "DEBUG" | "INFO" | "WARN" | "ERROR"; + /** + * TUI specific settings + */ + tui?: { + /** + * TUI scroll speed + */ + scroll_speed?: number; + /** + * Scroll acceleration settings + */ + scroll_acceleration?: { + /** + * Enable scroll acceleration + */ + enabled: boolean; + }; + /** + * Control diff rendering style: 'auto' adapts to terminal width, 'stacked' always shows single column + */ + diff_style?: "auto" | "stacked"; + }; + /** + * Command configuration, see https://opencode.ai/docs/commands + */ + command?: { + [key: string]: { + template: string; + description?: string; + agent?: string; + model?: string; + subtask?: boolean; + }; + }; + watcher?: { + ignore?: Array; + }; + plugin?: Array; + snapshot?: boolean; + /** + * Control sharing behavior:'manual' allows manual sharing via commands, 'auto' enables automatic sharing, 'disabled' disables all sharing + */ + share?: "manual" | "auto" | "disabled"; + /** + * @deprecated Use 'share' field instead. Share newly created sessions automatically + */ + autoshare?: boolean; + /** + * Automatically update to the latest version. Set to true to auto-update, false to disable, or 'notify' to show update notifications + */ + autoupdate?: boolean | "notify"; + /** + * Disable providers that are loaded automatically + */ + disabled_providers?: Array; + /** + * When set, ONLY these providers will be enabled. All other providers will be ignored + */ + enabled_providers?: Array; + /** + * Model to use in the format of provider/model, eg anthropic/claude-2 + */ + model?: string; + /** + * Small model to use for tasks like title generation in the format of provider/model + */ + small_model?: string; + /** + * Custom username to display in conversations instead of system username + */ + username?: string; + /** + * @deprecated Use `agent` field instead. + */ + mode?: { + build?: AgentConfig; + plan?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; + /** + * Agent configuration, see https://opencode.ai/docs/agent + */ + agent?: { + plan?: AgentConfig; + build?: AgentConfig; + general?: AgentConfig; + explore?: AgentConfig; + [key: string]: AgentConfig | undefined; + }; + /** + * Custom provider configurations and model overrides + */ + provider?: { + [key: string]: ProviderConfig; + }; + /** + * MCP (Model Context Protocol) server configurations + */ + mcp?: { + [key: string]: McpLocalConfig | McpRemoteConfig; + }; + formatter?: false | { + [key: string]: { + disabled?: boolean; + command?: Array; + environment?: { + [key: string]: string; + }; + extensions?: Array; + }; + }; + lsp?: false | { + [key: string]: { + disabled: true; + } | { + command: Array; + extensions?: Array; + disabled?: boolean; + env?: { + [key: string]: string; + }; + initialization?: { + [key: string]: unknown; + }; + }; + }; + /** + * Additional instruction files or patterns to include + */ + instructions?: Array; + layout?: LayoutConfig; + permission?: { + edit?: "ask" | "allow" | "deny"; + bash?: ("ask" | "allow" | "deny") | { + [key: string]: "ask" | "allow" | "deny"; + }; + webfetch?: "ask" | "allow" | "deny"; + doom_loop?: "ask" | "allow" | "deny"; + external_directory?: "ask" | "allow" | "deny"; + }; + tools?: { + [key: string]: boolean; + }; + enterprise?: { + /** + * Enterprise URL + */ + url?: string; + }; + experimental?: { + hook?: { + file_edited?: { + [key: string]: Array<{ + command: Array; + environment?: { + [key: string]: string; + }; + }>; + }; + session_completed?: Array<{ + command: Array; + environment?: { + [key: string]: string; + }; + }>; + }; + /** + * Number of retries for chat completions on failure + */ + chatMaxRetries?: number; + disable_paste_summary?: boolean; + /** + * Enable the batch tool + */ + batch_tool?: boolean; + /** + * Enable OpenTelemetry spans for AI SDK calls (using the 'experimental_telemetry' flag) + */ + openTelemetry?: boolean; + /** + * Tools that should only be available to primary agents. + */ + primary_tools?: Array; + }; +}; +export type ToolIds = Array; +export type ToolListItem = { + id: string; + description: string; + parameters: unknown; +}; +export type ToolList = Array; +export type Path = { + state: string; + config: string; + worktree: string; + directory: string; +}; +export type VcsInfo = { + branch: string; +}; +export type TextPartInput = { + id?: string; + type: "text"; + text: string; + synthetic?: boolean; + ignored?: boolean; + time?: { + start: number; + end?: number; + }; + metadata?: { + [key: string]: unknown; + }; +}; +export type FilePartInput = { + id?: string; + type: "file"; + mime: string; + filename?: string; + url: string; + source?: FilePartSource; +}; +export type AgentPartInput = { + id?: string; + type: "agent"; + name: string; + source?: { + value: string; + start: number; + end: number; + }; +}; +export type SubtaskPartInput = { + id?: string; + type: "subtask"; + prompt: string; + description: string; + agent: string; +}; +export type Command = { + name: string; + description?: string; + agent?: string; + model?: string; + template: string; + subtask?: boolean; +}; +export type Model = { + id: string; + providerID: string; + api: { + id: string; + url: string; + npm: string; + }; + name: string; + capabilities: { + temperature: boolean; + reasoning: boolean; + attachment: boolean; + toolcall: boolean; + input: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + output: { + text: boolean; + audio: boolean; + image: boolean; + video: boolean; + pdf: boolean; + }; + }; + cost: { + input: number; + output: number; + cache: { + read: number; + write: number; + }; + experimentalOver200K?: { + input: number; + output: number; + cache: { + read: number; + write: number; + }; + }; + }; + limit: { + context: number; + output: number; + }; + status: "alpha" | "beta" | "deprecated" | "active"; + options: { + [key: string]: unknown; + }; + headers: { + [key: string]: string; + }; +}; +export type Provider = { + id: string; + name: string; + source: "env" | "config" | "custom" | "api"; + env: Array; + key?: string; + options: { + [key: string]: unknown; + }; + models: { + [key: string]: Model; + }; +}; +export type ProviderAuthMethod = { + type: "oauth" | "api"; + label: string; +}; +export type ProviderAuthAuthorization = { + url: string; + method: "auto" | "code"; + instructions: string; +}; +export type Symbol = { + name: string; + kind: number; + location: { + uri: string; + range: Range; + }; +}; +export type FileNode = { + name: string; + path: string; + absolute: string; + type: "file" | "directory"; + ignored: boolean; +}; +export type FileContent = { + type: "text" | "binary"; + content: string; + diff?: string; + patch?: { + oldFileName: string; + newFileName: string; + oldHeader?: string; + newHeader?: string; + hunks: Array<{ + oldStart: number; + oldLines: number; + newStart: number; + newLines: number; + lines: Array; + }>; + index?: string; + }; + encoding?: "base64"; + mimeType?: string; +}; +export type File = { + path: string; + added: number; + removed: number; + status: "added" | "deleted" | "modified"; +}; +export type Agent = { + name: string; + description?: string; + mode: "subagent" | "primary" | "all"; + builtIn: boolean; + topP?: number; + temperature?: number; + color?: string; + permission: { + edit: "ask" | "allow" | "deny"; + bash: { + [key: string]: "ask" | "allow" | "deny"; + }; + webfetch?: "ask" | "allow" | "deny"; + doom_loop?: "ask" | "allow" | "deny"; + external_directory?: "ask" | "allow" | "deny"; + }; + model?: { + modelID: string; + providerID: string; + }; + prompt?: string; + tools: { + [key: string]: boolean; + }; + options: { + [key: string]: unknown; + }; + maxSteps?: number; +}; +export type McpStatusConnected = { + status: "connected"; +}; +export type McpStatusDisabled = { + status: "disabled"; +}; +export type McpStatusFailed = { + status: "failed"; + error: string; +}; +export type McpStatusNeedsAuth = { + status: "needs_auth"; +}; +export type McpStatusNeedsClientRegistration = { + status: "needs_client_registration"; + error: string; +}; +export type McpStatus = McpStatusConnected | McpStatusDisabled | McpStatusFailed | McpStatusNeedsAuth | McpStatusNeedsClientRegistration; +export type LspStatus = { + id: string; + name: string; + root: string; + status: "connected" | "error"; +}; +export type FormatterStatus = { + name: string; + extensions: Array; + enabled: boolean; +}; +export type OAuth = { + type: "oauth"; + refresh: string; + access: string; + expires: number; + enterpriseUrl?: string; +}; +export type ApiAuth = { + type: "api"; + key: string; + metadata?: { + [key: string]: string; + }; +}; +export type WellKnownAuth = { + type: "wellknown"; + key: string; + token: string; +}; +export type Auth = OAuth | ApiAuth | WellKnownAuth; +export type GlobalEventData = { + body?: never; + path?: never; + query?: never; + url: "/global/event"; +}; +export type GlobalEventResponses = { + /** + * Event stream + */ + 200: GlobalEvent; +}; +export type GlobalEventResponse = GlobalEventResponses[keyof GlobalEventResponses]; +export type ProjectListData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/project"; +}; +export type ProjectListResponses = { + /** + * List of projects + */ + 200: Array; +}; +export type ProjectListResponse = ProjectListResponses[keyof ProjectListResponses]; +export type ProjectCurrentData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/project/current"; +}; +export type ProjectCurrentResponses = { + /** + * Current project + */ + 200: Project; +}; +export type ProjectCurrentResponse = ProjectCurrentResponses[keyof ProjectCurrentResponses]; +export type PtyListData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/pty"; +}; +export type PtyListResponses = { + /** + * List of sessions + */ + 200: Array; +}; +export type PtyListResponse = PtyListResponses[keyof PtyListResponses]; +export type PtyCreateData = { + body?: { + command?: string; + args?: Array; + cwd?: string; + title?: string; + env?: { + [key: string]: string; + }; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/pty"; +}; +export type PtyCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type PtyCreateError = PtyCreateErrors[keyof PtyCreateErrors]; +export type PtyCreateResponses = { + /** + * Created session + */ + 200: Pty; +}; +export type PtyCreateResponse = PtyCreateResponses[keyof PtyCreateResponses]; +export type PtyRemoveData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/pty/{id}"; +}; +export type PtyRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError; +}; +export type PtyRemoveError = PtyRemoveErrors[keyof PtyRemoveErrors]; +export type PtyRemoveResponses = { + /** + * Session removed + */ + 200: boolean; +}; +export type PtyRemoveResponse = PtyRemoveResponses[keyof PtyRemoveResponses]; +export type PtyGetData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/pty/{id}"; +}; +export type PtyGetErrors = { + /** + * Not found + */ + 404: NotFoundError; +}; +export type PtyGetError = PtyGetErrors[keyof PtyGetErrors]; +export type PtyGetResponses = { + /** + * Session info + */ + 200: Pty; +}; +export type PtyGetResponse = PtyGetResponses[keyof PtyGetResponses]; +export type PtyUpdateData = { + body?: { + title?: string; + size?: { + rows: number; + cols: number; + }; + }; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/pty/{id}"; +}; +export type PtyUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type PtyUpdateError = PtyUpdateErrors[keyof PtyUpdateErrors]; +export type PtyUpdateResponses = { + /** + * Updated session + */ + 200: Pty; +}; +export type PtyUpdateResponse = PtyUpdateResponses[keyof PtyUpdateResponses]; +export type PtyConnectData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/pty/{id}/connect"; +}; +export type PtyConnectErrors = { + /** + * Not found + */ + 404: NotFoundError; +}; +export type PtyConnectError = PtyConnectErrors[keyof PtyConnectErrors]; +export type PtyConnectResponses = { + /** + * Connected session + */ + 200: boolean; +}; +export type PtyConnectResponse = PtyConnectResponses[keyof PtyConnectResponses]; +export type ConfigGetData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/config"; +}; +export type ConfigGetResponses = { + /** + * Get config info + */ + 200: Config; +}; +export type ConfigGetResponse = ConfigGetResponses[keyof ConfigGetResponses]; +export type ConfigUpdateData = { + body?: Config; + path?: never; + query?: { + directory?: string; + }; + url: "/config"; +}; +export type ConfigUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type ConfigUpdateError = ConfigUpdateErrors[keyof ConfigUpdateErrors]; +export type ConfigUpdateResponses = { + /** + * Successfully updated config + */ + 200: Config; +}; +export type ConfigUpdateResponse = ConfigUpdateResponses[keyof ConfigUpdateResponses]; +export type ToolIdsData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/experimental/tool/ids"; +}; +export type ToolIdsErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type ToolIdsError = ToolIdsErrors[keyof ToolIdsErrors]; +export type ToolIdsResponses = { + /** + * Tool IDs + */ + 200: ToolIds; +}; +export type ToolIdsResponse = ToolIdsResponses[keyof ToolIdsResponses]; +export type ToolListData = { + body?: never; + path?: never; + query: { + directory?: string; + provider: string; + model: string; + }; + url: "/experimental/tool"; +}; +export type ToolListErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type ToolListError = ToolListErrors[keyof ToolListErrors]; +export type ToolListResponses = { + /** + * Tools + */ + 200: ToolList; +}; +export type ToolListResponse = ToolListResponses[keyof ToolListResponses]; +export type InstanceDisposeData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/instance/dispose"; +}; +export type InstanceDisposeResponses = { + /** + * Instance disposed + */ + 200: boolean; +}; +export type InstanceDisposeResponse = InstanceDisposeResponses[keyof InstanceDisposeResponses]; +export type PathGetData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/path"; +}; +export type PathGetResponses = { + /** + * Path + */ + 200: Path; +}; +export type PathGetResponse = PathGetResponses[keyof PathGetResponses]; +export type VcsGetData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/vcs"; +}; +export type VcsGetResponses = { + /** + * VCS info + */ + 200: VcsInfo; +}; +export type VcsGetResponse = VcsGetResponses[keyof VcsGetResponses]; +export type SessionListData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/session"; +}; +export type SessionListResponses = { + /** + * List of sessions + */ + 200: Array; +}; +export type SessionListResponse = SessionListResponses[keyof SessionListResponses]; +export type SessionCreateData = { + body?: { + parentID?: string; + title?: string; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/session"; +}; +export type SessionCreateErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type SessionCreateError = SessionCreateErrors[keyof SessionCreateErrors]; +export type SessionCreateResponses = { + /** + * Successfully created session + */ + 200: Session; +}; +export type SessionCreateResponse = SessionCreateResponses[keyof SessionCreateResponses]; +export type SessionStatusData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/session/status"; +}; +export type SessionStatusErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type SessionStatusError = SessionStatusErrors[keyof SessionStatusErrors]; +export type SessionStatusResponses = { + /** + * Get session status + */ + 200: { + [key: string]: SessionStatus; + }; +}; +export type SessionStatusResponse = SessionStatusResponses[keyof SessionStatusResponses]; +export type SessionDeleteData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}"; +}; +export type SessionDeleteErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionDeleteError = SessionDeleteErrors[keyof SessionDeleteErrors]; +export type SessionDeleteResponses = { + /** + * Successfully deleted session + */ + 200: boolean; +}; +export type SessionDeleteResponse = SessionDeleteResponses[keyof SessionDeleteResponses]; +export type SessionGetData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}"; +}; +export type SessionGetErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionGetError = SessionGetErrors[keyof SessionGetErrors]; +export type SessionGetResponses = { + /** + * Get session + */ + 200: Session; +}; +export type SessionGetResponse = SessionGetResponses[keyof SessionGetResponses]; +export type SessionUpdateData = { + body?: { + title?: string; + }; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}"; +}; +export type SessionUpdateErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionUpdateError = SessionUpdateErrors[keyof SessionUpdateErrors]; +export type SessionUpdateResponses = { + /** + * Successfully updated session + */ + 200: Session; +}; +export type SessionUpdateResponse = SessionUpdateResponses[keyof SessionUpdateResponses]; +export type SessionChildrenData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/children"; +}; +export type SessionChildrenErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionChildrenError = SessionChildrenErrors[keyof SessionChildrenErrors]; +export type SessionChildrenResponses = { + /** + * List of children + */ + 200: Array; +}; +export type SessionChildrenResponse = SessionChildrenResponses[keyof SessionChildrenResponses]; +export type SessionTodoData = { + body?: never; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/todo"; +}; +export type SessionTodoErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionTodoError = SessionTodoErrors[keyof SessionTodoErrors]; +export type SessionTodoResponses = { + /** + * Todo list + */ + 200: Array; +}; +export type SessionTodoResponse = SessionTodoResponses[keyof SessionTodoResponses]; +export type SessionInitData = { + body?: { + modelID: string; + providerID: string; + messageID: string; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/init"; +}; +export type SessionInitErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionInitError = SessionInitErrors[keyof SessionInitErrors]; +export type SessionInitResponses = { + /** + * 200 + */ + 200: boolean; +}; +export type SessionInitResponse = SessionInitResponses[keyof SessionInitResponses]; +export type SessionForkData = { + body?: { + messageID?: string; + }; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/fork"; +}; +export type SessionForkResponses = { + /** + * 200 + */ + 200: Session; +}; +export type SessionForkResponse = SessionForkResponses[keyof SessionForkResponses]; +export type SessionAbortData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/abort"; +}; +export type SessionAbortErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionAbortError = SessionAbortErrors[keyof SessionAbortErrors]; +export type SessionAbortResponses = { + /** + * Aborted session + */ + 200: boolean; +}; +export type SessionAbortResponse = SessionAbortResponses[keyof SessionAbortResponses]; +export type SessionUnshareData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/share"; +}; +export type SessionUnshareErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionUnshareError = SessionUnshareErrors[keyof SessionUnshareErrors]; +export type SessionUnshareResponses = { + /** + * Successfully unshared session + */ + 200: Session; +}; +export type SessionUnshareResponse = SessionUnshareResponses[keyof SessionUnshareResponses]; +export type SessionShareData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/share"; +}; +export type SessionShareErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionShareError = SessionShareErrors[keyof SessionShareErrors]; +export type SessionShareResponses = { + /** + * Successfully shared session + */ + 200: Session; +}; +export type SessionShareResponse = SessionShareResponses[keyof SessionShareResponses]; +export type SessionDiffData = { + body?: never; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + messageID?: string; + }; + url: "/session/{id}/diff"; +}; +export type SessionDiffErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionDiffError = SessionDiffErrors[keyof SessionDiffErrors]; +export type SessionDiffResponses = { + /** + * List of diffs + */ + 200: Array; +}; +export type SessionDiffResponse = SessionDiffResponses[keyof SessionDiffResponses]; +export type SessionSummarizeData = { + body?: { + providerID: string; + modelID: string; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/summarize"; +}; +export type SessionSummarizeErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionSummarizeError = SessionSummarizeErrors[keyof SessionSummarizeErrors]; +export type SessionSummarizeResponses = { + /** + * Summarized session + */ + 200: boolean; +}; +export type SessionSummarizeResponse = SessionSummarizeResponses[keyof SessionSummarizeResponses]; +export type SessionMessagesData = { + body?: never; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + limit?: number; + }; + url: "/session/{id}/message"; +}; +export type SessionMessagesErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionMessagesError = SessionMessagesErrors[keyof SessionMessagesErrors]; +export type SessionMessagesResponses = { + /** + * List of messages + */ + 200: Array<{ + info: Message; + parts: Array; + }>; +}; +export type SessionMessagesResponse = SessionMessagesResponses[keyof SessionMessagesResponses]; +export type SessionPromptData = { + body?: { + messageID?: string; + model?: { + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; + system?: string; + tools?: { + [key: string]: boolean; + }; + parts: Array; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/message"; +}; +export type SessionPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionPromptError = SessionPromptErrors[keyof SessionPromptErrors]; +export type SessionPromptResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage; + parts: Array; + }; +}; +export type SessionPromptResponse = SessionPromptResponses[keyof SessionPromptResponses]; +export type SessionMessageData = { + body?: never; + path: { + /** + * Session ID + */ + id: string; + /** + * Message ID + */ + messageID: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/message/{messageID}"; +}; +export type SessionMessageErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionMessageError = SessionMessageErrors[keyof SessionMessageErrors]; +export type SessionMessageResponses = { + /** + * Message + */ + 200: { + info: Message; + parts: Array; + }; +}; +export type SessionMessageResponse = SessionMessageResponses[keyof SessionMessageResponses]; +export type SessionPromptAsyncData = { + body?: { + messageID?: string; + model?: { + providerID: string; + modelID: string; + }; + agent?: string; + noReply?: boolean; + system?: string; + tools?: { + [key: string]: boolean; + }; + parts: Array; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/prompt_async"; +}; +export type SessionPromptAsyncErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionPromptAsyncError = SessionPromptAsyncErrors[keyof SessionPromptAsyncErrors]; +export type SessionPromptAsyncResponses = { + /** + * Prompt accepted + */ + 204: void; +}; +export type SessionPromptAsyncResponse = SessionPromptAsyncResponses[keyof SessionPromptAsyncResponses]; +export type SessionCommandData = { + body?: { + messageID?: string; + agent?: string; + model?: string; + arguments: string; + command: string; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/command"; +}; +export type SessionCommandErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionCommandError = SessionCommandErrors[keyof SessionCommandErrors]; +export type SessionCommandResponses = { + /** + * Created message + */ + 200: { + info: AssistantMessage; + parts: Array; + }; +}; +export type SessionCommandResponse = SessionCommandResponses[keyof SessionCommandResponses]; +export type SessionShellData = { + body?: { + agent: string; + model?: { + providerID: string; + modelID: string; + }; + command: string; + }; + path: { + /** + * Session ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/shell"; +}; +export type SessionShellErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionShellError = SessionShellErrors[keyof SessionShellErrors]; +export type SessionShellResponses = { + /** + * Created message + */ + 200: AssistantMessage; +}; +export type SessionShellResponse = SessionShellResponses[keyof SessionShellResponses]; +export type SessionRevertData = { + body?: { + messageID: string; + partID?: string; + }; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/revert"; +}; +export type SessionRevertErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionRevertError = SessionRevertErrors[keyof SessionRevertErrors]; +export type SessionRevertResponses = { + /** + * Updated session + */ + 200: Session; +}; +export type SessionRevertResponse = SessionRevertResponses[keyof SessionRevertResponses]; +export type SessionUnrevertData = { + body?: never; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/unrevert"; +}; +export type SessionUnrevertErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type SessionUnrevertError = SessionUnrevertErrors[keyof SessionUnrevertErrors]; +export type SessionUnrevertResponses = { + /** + * Updated session + */ + 200: Session; +}; +export type SessionUnrevertResponse = SessionUnrevertResponses[keyof SessionUnrevertResponses]; +export type PostSessionIdPermissionsPermissionIdData = { + body?: { + response: "once" | "always" | "reject"; + }; + path: { + id: string; + permissionID: string; + }; + query?: { + directory?: string; + }; + url: "/session/{id}/permissions/{permissionID}"; +}; +export type PostSessionIdPermissionsPermissionIdErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type PostSessionIdPermissionsPermissionIdError = PostSessionIdPermissionsPermissionIdErrors[keyof PostSessionIdPermissionsPermissionIdErrors]; +export type PostSessionIdPermissionsPermissionIdResponses = { + /** + * Permission processed successfully + */ + 200: boolean; +}; +export type PostSessionIdPermissionsPermissionIdResponse = PostSessionIdPermissionsPermissionIdResponses[keyof PostSessionIdPermissionsPermissionIdResponses]; +export type CommandListData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/command"; +}; +export type CommandListResponses = { + /** + * List of commands + */ + 200: Array; +}; +export type CommandListResponse = CommandListResponses[keyof CommandListResponses]; +export type ConfigProvidersData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/config/providers"; +}; +export type ConfigProvidersResponses = { + /** + * List of providers + */ + 200: { + providers: Array; + default: { + [key: string]: string; + }; + }; +}; +export type ConfigProvidersResponse = ConfigProvidersResponses[keyof ConfigProvidersResponses]; +export type ProviderListData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/provider"; +}; +export type ProviderListResponses = { + /** + * List of providers + */ + 200: { + all: Array<{ + api?: string; + name: string; + env: Array; + id: string; + npm?: string; + models: { + [key: string]: { + id: string; + name: string; + release_date: string; + attachment: boolean; + reasoning: boolean; + temperature: boolean; + tool_call: boolean; + cost?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + context_over_200k?: { + input: number; + output: number; + cache_read?: number; + cache_write?: number; + }; + }; + limit: { + context: number; + output: number; + }; + modalities?: { + input: Array<"text" | "audio" | "image" | "video" | "pdf">; + output: Array<"text" | "audio" | "image" | "video" | "pdf">; + }; + experimental?: boolean; + status?: "alpha" | "beta" | "deprecated" | "active"; + options: { + [key: string]: unknown; + }; + headers?: { + [key: string]: string; + }; + provider?: { + npm: string; + }; + }; + }; + }>; + default: { + [key: string]: string; + }; + connected: Array; + }; +}; +export type ProviderListResponse = ProviderListResponses[keyof ProviderListResponses]; +export type ProviderAuthData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/provider/auth"; +}; +export type ProviderAuthResponses = { + /** + * Provider auth methods + */ + 200: { + [key: string]: Array; + }; +}; +export type ProviderAuthResponse = ProviderAuthResponses[keyof ProviderAuthResponses]; +export type ProviderOauthAuthorizeData = { + body?: { + /** + * Auth method index + */ + method: number; + }; + path: { + /** + * Provider ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/provider/{id}/oauth/authorize"; +}; +export type ProviderOauthAuthorizeErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type ProviderOauthAuthorizeError = ProviderOauthAuthorizeErrors[keyof ProviderOauthAuthorizeErrors]; +export type ProviderOauthAuthorizeResponses = { + /** + * Authorization URL and method + */ + 200: ProviderAuthAuthorization; +}; +export type ProviderOauthAuthorizeResponse = ProviderOauthAuthorizeResponses[keyof ProviderOauthAuthorizeResponses]; +export type ProviderOauthCallbackData = { + body?: { + /** + * Auth method index + */ + method: number; + /** + * OAuth authorization code + */ + code?: string; + }; + path: { + /** + * Provider ID + */ + id: string; + }; + query?: { + directory?: string; + }; + url: "/provider/{id}/oauth/callback"; +}; +export type ProviderOauthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type ProviderOauthCallbackError = ProviderOauthCallbackErrors[keyof ProviderOauthCallbackErrors]; +export type ProviderOauthCallbackResponses = { + /** + * OAuth callback processed successfully + */ + 200: boolean; +}; +export type ProviderOauthCallbackResponse = ProviderOauthCallbackResponses[keyof ProviderOauthCallbackResponses]; +export type FindTextData = { + body?: never; + path?: never; + query: { + directory?: string; + pattern: string; + }; + url: "/find"; +}; +export type FindTextResponses = { + /** + * Matches + */ + 200: Array<{ + path: { + text: string; + }; + lines: { + text: string; + }; + line_number: number; + absolute_offset: number; + submatches: Array<{ + match: { + text: string; + }; + start: number; + end: number; + }>; + }>; +}; +export type FindTextResponse = FindTextResponses[keyof FindTextResponses]; +export type FindFilesData = { + body?: never; + path?: never; + query: { + directory?: string; + query: string; + dirs?: "true" | "false"; + }; + url: "/find/file"; +}; +export type FindFilesResponses = { + /** + * File paths + */ + 200: Array; +}; +export type FindFilesResponse = FindFilesResponses[keyof FindFilesResponses]; +export type FindSymbolsData = { + body?: never; + path?: never; + query: { + directory?: string; + query: string; + }; + url: "/find/symbol"; +}; +export type FindSymbolsResponses = { + /** + * Symbols + */ + 200: Array; +}; +export type FindSymbolsResponse = FindSymbolsResponses[keyof FindSymbolsResponses]; +export type FileListData = { + body?: never; + path?: never; + query: { + directory?: string; + path: string; + }; + url: "/file"; +}; +export type FileListResponses = { + /** + * Files and directories + */ + 200: Array; +}; +export type FileListResponse = FileListResponses[keyof FileListResponses]; +export type FileReadData = { + body?: never; + path?: never; + query: { + directory?: string; + path: string; + }; + url: "/file/content"; +}; +export type FileReadResponses = { + /** + * File content + */ + 200: FileContent; +}; +export type FileReadResponse = FileReadResponses[keyof FileReadResponses]; +export type FileStatusData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/file/status"; +}; +export type FileStatusResponses = { + /** + * File status + */ + 200: Array; +}; +export type FileStatusResponse = FileStatusResponses[keyof FileStatusResponses]; +export type AppLogData = { + body?: { + /** + * Service name for the log entry + */ + service: string; + /** + * Log level + */ + level: "debug" | "info" | "error" | "warn"; + /** + * Log message + */ + message: string; + /** + * Additional metadata for the log entry + */ + extra?: { + [key: string]: unknown; + }; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/log"; +}; +export type AppLogErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type AppLogError = AppLogErrors[keyof AppLogErrors]; +export type AppLogResponses = { + /** + * Log entry written successfully + */ + 200: boolean; +}; +export type AppLogResponse = AppLogResponses[keyof AppLogResponses]; +export type AppAgentsData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/agent"; +}; +export type AppAgentsResponses = { + /** + * List of agents + */ + 200: Array; +}; +export type AppAgentsResponse = AppAgentsResponses[keyof AppAgentsResponses]; +export type McpStatusData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/mcp"; +}; +export type McpStatusResponses = { + /** + * MCP server status + */ + 200: { + [key: string]: McpStatus; + }; +}; +export type McpStatusResponse = McpStatusResponses[keyof McpStatusResponses]; +export type McpAddData = { + body?: { + name: string; + config: McpLocalConfig | McpRemoteConfig; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/mcp"; +}; +export type McpAddErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type McpAddError = McpAddErrors[keyof McpAddErrors]; +export type McpAddResponses = { + /** + * MCP server added successfully + */ + 200: { + [key: string]: McpStatus; + }; +}; +export type McpAddResponse = McpAddResponses[keyof McpAddResponses]; +export type McpAuthRemoveData = { + body?: never; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/auth"; +}; +export type McpAuthRemoveErrors = { + /** + * Not found + */ + 404: NotFoundError; +}; +export type McpAuthRemoveError = McpAuthRemoveErrors[keyof McpAuthRemoveErrors]; +export type McpAuthRemoveResponses = { + /** + * OAuth credentials removed + */ + 200: { + success: true; + }; +}; +export type McpAuthRemoveResponse = McpAuthRemoveResponses[keyof McpAuthRemoveResponses]; +export type McpAuthStartData = { + body?: never; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/auth"; +}; +export type McpAuthStartErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type McpAuthStartError = McpAuthStartErrors[keyof McpAuthStartErrors]; +export type McpAuthStartResponses = { + /** + * OAuth flow started + */ + 200: { + /** + * URL to open in browser for authorization + */ + authorizationUrl: string; + }; +}; +export type McpAuthStartResponse = McpAuthStartResponses[keyof McpAuthStartResponses]; +export type McpAuthCallbackData = { + body?: { + /** + * Authorization code from OAuth callback + */ + code: string; + }; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/auth/callback"; +}; +export type McpAuthCallbackErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type McpAuthCallbackError = McpAuthCallbackErrors[keyof McpAuthCallbackErrors]; +export type McpAuthCallbackResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus; +}; +export type McpAuthCallbackResponse = McpAuthCallbackResponses[keyof McpAuthCallbackResponses]; +export type McpAuthAuthenticateData = { + body?: never; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/auth/authenticate"; +}; +export type McpAuthAuthenticateErrors = { + /** + * Bad request + */ + 400: BadRequestError; + /** + * Not found + */ + 404: NotFoundError; +}; +export type McpAuthAuthenticateError = McpAuthAuthenticateErrors[keyof McpAuthAuthenticateErrors]; +export type McpAuthAuthenticateResponses = { + /** + * OAuth authentication completed + */ + 200: McpStatus; +}; +export type McpAuthAuthenticateResponse = McpAuthAuthenticateResponses[keyof McpAuthAuthenticateResponses]; +export type McpConnectData = { + body?: never; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/connect"; +}; +export type McpConnectResponses = { + /** + * MCP server connected successfully + */ + 200: boolean; +}; +export type McpConnectResponse = McpConnectResponses[keyof McpConnectResponses]; +export type McpDisconnectData = { + body?: never; + path: { + name: string; + }; + query?: { + directory?: string; + }; + url: "/mcp/{name}/disconnect"; +}; +export type McpDisconnectResponses = { + /** + * MCP server disconnected successfully + */ + 200: boolean; +}; +export type McpDisconnectResponse = McpDisconnectResponses[keyof McpDisconnectResponses]; +export type LspStatusData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/lsp"; +}; +export type LspStatusResponses = { + /** + * LSP server status + */ + 200: Array; +}; +export type LspStatusResponse = LspStatusResponses[keyof LspStatusResponses]; +export type FormatterStatusData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/formatter"; +}; +export type FormatterStatusResponses = { + /** + * Formatter status + */ + 200: Array; +}; +export type FormatterStatusResponse = FormatterStatusResponses[keyof FormatterStatusResponses]; +export type TuiAppendPromptData = { + body?: { + text: string; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/append-prompt"; +}; +export type TuiAppendPromptErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type TuiAppendPromptError = TuiAppendPromptErrors[keyof TuiAppendPromptErrors]; +export type TuiAppendPromptResponses = { + /** + * Prompt processed successfully + */ + 200: boolean; +}; +export type TuiAppendPromptResponse = TuiAppendPromptResponses[keyof TuiAppendPromptResponses]; +export type TuiOpenHelpData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/open-help"; +}; +export type TuiOpenHelpResponses = { + /** + * Help dialog opened successfully + */ + 200: boolean; +}; +export type TuiOpenHelpResponse = TuiOpenHelpResponses[keyof TuiOpenHelpResponses]; +export type TuiOpenSessionsData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/open-sessions"; +}; +export type TuiOpenSessionsResponses = { + /** + * Session dialog opened successfully + */ + 200: boolean; +}; +export type TuiOpenSessionsResponse = TuiOpenSessionsResponses[keyof TuiOpenSessionsResponses]; +export type TuiOpenThemesData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/open-themes"; +}; +export type TuiOpenThemesResponses = { + /** + * Theme dialog opened successfully + */ + 200: boolean; +}; +export type TuiOpenThemesResponse = TuiOpenThemesResponses[keyof TuiOpenThemesResponses]; +export type TuiOpenModelsData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/open-models"; +}; +export type TuiOpenModelsResponses = { + /** + * Model dialog opened successfully + */ + 200: boolean; +}; +export type TuiOpenModelsResponse = TuiOpenModelsResponses[keyof TuiOpenModelsResponses]; +export type TuiSubmitPromptData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/submit-prompt"; +}; +export type TuiSubmitPromptResponses = { + /** + * Prompt submitted successfully + */ + 200: boolean; +}; +export type TuiSubmitPromptResponse = TuiSubmitPromptResponses[keyof TuiSubmitPromptResponses]; +export type TuiClearPromptData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/clear-prompt"; +}; +export type TuiClearPromptResponses = { + /** + * Prompt cleared successfully + */ + 200: boolean; +}; +export type TuiClearPromptResponse = TuiClearPromptResponses[keyof TuiClearPromptResponses]; +export type TuiExecuteCommandData = { + body?: { + command: string; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/execute-command"; +}; +export type TuiExecuteCommandErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type TuiExecuteCommandError = TuiExecuteCommandErrors[keyof TuiExecuteCommandErrors]; +export type TuiExecuteCommandResponses = { + /** + * Command executed successfully + */ + 200: boolean; +}; +export type TuiExecuteCommandResponse = TuiExecuteCommandResponses[keyof TuiExecuteCommandResponses]; +export type TuiShowToastData = { + body?: { + title?: string; + message: string; + variant: "info" | "success" | "warning" | "error"; + /** + * Duration in milliseconds + */ + duration?: number; + }; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/show-toast"; +}; +export type TuiShowToastResponses = { + /** + * Toast notification shown successfully + */ + 200: boolean; +}; +export type TuiShowToastResponse = TuiShowToastResponses[keyof TuiShowToastResponses]; +export type TuiPublishData = { + body?: EventTuiPromptAppend | EventTuiCommandExecute | EventTuiToastShow; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/publish"; +}; +export type TuiPublishErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type TuiPublishError = TuiPublishErrors[keyof TuiPublishErrors]; +export type TuiPublishResponses = { + /** + * Event published successfully + */ + 200: boolean; +}; +export type TuiPublishResponse = TuiPublishResponses[keyof TuiPublishResponses]; +export type TuiControlNextData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/control/next"; +}; +export type TuiControlNextResponses = { + /** + * Next TUI request + */ + 200: { + path: string; + body: unknown; + }; +}; +export type TuiControlNextResponse = TuiControlNextResponses[keyof TuiControlNextResponses]; +export type TuiControlResponseData = { + body?: unknown; + path?: never; + query?: { + directory?: string; + }; + url: "/tui/control/response"; +}; +export type TuiControlResponseResponses = { + /** + * Response submitted successfully + */ + 200: boolean; +}; +export type TuiControlResponseResponse = TuiControlResponseResponses[keyof TuiControlResponseResponses]; +export type AuthSetData = { + body?: Auth; + path: { + id: string; + }; + query?: { + directory?: string; + }; + url: "/auth/{id}"; +}; +export type AuthSetErrors = { + /** + * Bad request + */ + 400: BadRequestError; +}; +export type AuthSetError = AuthSetErrors[keyof AuthSetErrors]; +export type AuthSetResponses = { + /** + * Successfully set authentication credentials + */ + 200: boolean; +}; +export type AuthSetResponse = AuthSetResponses[keyof AuthSetResponses]; +export type EventSubscribeData = { + body?: never; + path?: never; + query?: { + directory?: string; + }; + url: "/event"; +}; +export type EventSubscribeResponses = { + /** + * Event stream + */ + 200: Event; +}; +export type EventSubscribeResponse = EventSubscribeResponses[keyof EventSubscribeResponses]; +export type ClientOptions = { + baseUrl: `${string}://${string}` | (string & {}); +}; diff --git a/5_django_backend/manage.py b/5_django_backend/manage.py new file mode 100644 index 0000000..f7f11d0 --- /dev/null +++ b/5_django_backend/manage.py @@ -0,0 +1,16 @@ +#!/usr/bin/env python +"""Django 명령 진입점 — `python manage.py migrate` 등.""" + +import os +import sys + + +def main() -> None: + os.environ.setdefault("DJANGO_SETTINGS_MODULE", "config.settings") + from django.core.management import execute_from_command_line + + execute_from_command_line(sys.argv) + + +if __name__ == "__main__": + main() diff --git a/5_django_backend/opencode/.opencode/agent/codeassist.md b/5_django_backend/opencode/.opencode/agent/codeassist.md new file mode 100644 index 0000000..f13a142 --- /dev/null +++ b/5_django_backend/opencode/.opencode/agent/codeassist.md @@ -0,0 +1,12 @@ +--- +description: CodeAssist 즉답형 ABAP 코드 도우미 — 질문에 바로 코드·답을 준다 +mode: primary +temperature: 0.2 +tools: + write: false + edit: false + bash: false +--- + +너는 CodeAssist 의 답변 에이전트다. AGENTS.md 의 "답하는 방식"을 그대로 따른다. +절차 질문 없이 바로 답하고, 코드는 완결된 조각으로 코드펜스에 넣는다. diff --git a/5_django_backend/opencode/AGENTS.md b/5_django_backend/opencode/AGENTS.md new file mode 100644 index 0000000..657cde4 --- /dev/null +++ b/5_django_backend/opencode/AGENTS.md @@ -0,0 +1,19 @@ +# CodeAssist — OpenCode 작업 지침 + +너는 SAP ABAP 개발자가 SAP GUI·Eclipse(ADT) 옆에 띄워놓고 쓰는 **즉답형 코드 도우미**다. +사용자는 질문하고 바로 코드나 답을 받아서 원래 창에 붙여넣는다. 흐름을 끊지 마라. + +## 답하는 방식 + +- 한국어. SAP 기술 용어(테이블명·필드명·T-code·키워드)는 원문 그대로. +- **바로 답한다.** 요구사항 정리 질문, 단계별 확인, "~해도 될까요" 금지. 정보가 부족하면 가장 흔한 경우로 가정하고 답한 뒤 가정을 한 줄 덧붙인다. +- 코드는 항상 코드펜스 + 언어 태그(```abap, ```sql, ```cds). 붙여넣어 바로 되는 완결된 조각으로. +- 기본은 짧게 — 코드 + 필요한 설명 두세 줄. 사용자 메시지에 `[설명 모드]` 표시가 있으면 배경·원리·대안까지 자세히. +- 이미지(화면 캡처)가 오면 그 안의 코드·에러·화면을 읽고 답한다. 이미지에 대해 묘사하지 말고 바로 문제 해결로. +- 모르는 SAP 오브젝트는 지어내지 않는다. 도구(sap-icf MCP)가 연결돼 있으면 조회하고, 없으면 모른다고 하고 확인 방법(T-code, 테이블)을 알려준다. + +## 하지 않는 것 + +- 파일을 만들거나 수정하지 않는다. 이 workspace 는 도구 실행용이 아니라 대화용이다. +- 설계서·문서 작성 절차를 시작하지 않는다 (그건 다른 제품이 한다). +- 지침 문서·도구 이름을 사용자에게 언급하지 않는다. diff --git a/5_django_backend/opencode/README.md b/5_django_backend/opencode/README.md new file mode 100644 index 0000000..12e1259 --- /dev/null +++ b/5_django_backend/opencode/README.md @@ -0,0 +1,25 @@ +# CodeAssist 전용 OpenCode workspace + +Django(`5_django_backend`)가 부르는 AI 엔진. ABAP_OPENCODE 의 OpenCode 와는 **별도 인스턴스** — 거긴 AGENTS.md 가 "설계서 브레인스토밍" 규칙이라 즉답 도우미랑 충돌해서 따로 띄움. + +## 띄우기 (개발 PC) + +```bash +cd 5_django_backend/opencode +export OPENROUTER_API_KEY=sk-or-... +npx -y opencode-ai@1.18.6 serve --hostname 127.0.0.1 --port 4096 +``` + +- 버전은 1.18.6 고정 — `docs-lib/opencode-sdk-1.18.6-types.d.ts` 가 이 버전 API 기준. +- **gotcha 1 — `OPENCODE_CONFIG_DIR`:** 이 환경변수가 잡혀 있으면(예: orca 같은 툴이 셸에 박아둠) workspace 의 `opencode.json`·`.opencode/agent` 를 무시하고 그쪽 걸 읽어서 `Agent not found: codeassist` 가 남. 띄우기 전에 `env -u OPENCODE_CONFIG_DIR -u ORCA_OPENCODE_CONFIG_DIR opencode serve …` 로 풀거나 `.zshrc` 에서 빼. +- **gotcha 2 — 프로젝트(directory):** 1.18 서버는 한 프로세스가 여러 프로젝트를 다루고 요청마다 `?directory=` 로 고름. Django 가 `OPENCODE_DIRECTORY`(기본: 이 폴더 절대경로) 를 모든 요청에 붙이니, 서버를 다른 경로에서 띄우면 `.env` 의 `OPENCODE_DIRECTORY` 를 그 경로로 맞춰. 안 맞으면 세션이 딴 프로젝트에 생기고 prompt 가 로그에만 실패를 남기고 조용히 죽음(60초 뒤 error 로 끝냄). +- 확인: `curl http://127.0.0.1:4096/session` → `[]`. +- SAP 조회(MCP)는 `opencode.json` 의 `sap-icf.enabled` 를 true 로 바꾸고 `SAP_MCP_URL`/`SAP_MCP_KEY` 환경변수 주면 붙음 (ABAP_OPENCODE 의 :3200 서버 재사용). + +## 파일 + +| 파일 | 뭐냐 | +|---|---| +| `AGENTS.md` | 모든 세션에 붙는 지침 — 즉답, 코드펜스, 절차 질문 금지 | +| `opencode.json` | 모델(OpenRouter GLM 5.2)·`question` 도구 끔·MCP | +| `.opencode/agent/codeassist.md` | Django 가 `agent: "codeassist"` 로 지정하는 에이전트. 파일 쓰기·bash 끔 | diff --git a/5_django_backend/opencode/opencode.json b/5_django_backend/opencode/opencode.json new file mode 100644 index 0000000..a92d7e0 --- /dev/null +++ b/5_django_backend/opencode/opencode.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://opencode.ai/config.json", + "provider": { + "openrouter": { + "options": { "apiKey": "{env:OPENROUTER_API_KEY}" }, + "models": { + "z-ai/glm-5.2": { "name": "GLM 5.2 (OpenRouter)" } + } + } + }, + "model": "openrouter/z-ai/glm-5.2", + "tools": { "question": false }, + "mcp": { + "sap-icf": { + "type": "remote", + "url": "{env:SAP_MCP_URL}", + "headers": { "Authorization": "Bearer {env:SAP_MCP_KEY}" }, + "enabled": false + } + } +} diff --git a/5_django_backend/pytest.ini b/5_django_backend/pytest.ini new file mode 100644 index 0000000..43b6f50 --- /dev/null +++ b/5_django_backend/pytest.ini @@ -0,0 +1,5 @@ +[pytest] +DJANGO_SETTINGS_MODULE = config.settings +python_files = test_*.py +testpaths = tests +asyncio_mode = auto diff --git a/5_django_backend/requirements.txt b/5_django_backend/requirements.txt new file mode 100644 index 0000000..d9e8715 --- /dev/null +++ b/5_django_backend/requirements.txt @@ -0,0 +1,12 @@ +django==5.1.* +djangorestframework==3.17.* +djangorestframework-simplejwt==5.5.* +django-cors-headers==4.9.* +uvicorn[standard]==0.52.* +httpx==0.28.1 +psycopg2-binary==2.9.* + +# 테스트 +pytest +pytest-django +pytest-asyncio diff --git a/5_django_backend/tests/__init__.py b/5_django_backend/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/5_django_backend/tests/conftest.py b/5_django_backend/tests/conftest.py new file mode 100644 index 0000000..0c5e83e --- /dev/null +++ b/5_django_backend/tests/conftest.py @@ -0,0 +1,26 @@ +import pytest +from rest_framework.test import APIClient + + +@pytest.fixture +def api(): + return APIClient() + + +@pytest.fixture +def user(db): + from apps.accounts.models import User + + return User.objects.create_user( + username="u@x.com", email="u@x.com", password="pw1234", user_name="유저" + ) + + +@pytest.fixture +def auth_api(api, user): + """로그인된 클라이언트 + 토큰 응답.""" + res = api.post("/api/v1/auth/login", {"email": "u@x.com", "password": "pw1234"}, format="json") + tokens = res.json()["data"] + api.credentials(HTTP_AUTHORIZATION=f"Bearer {tokens['token']}") + api.tokens = tokens + return api diff --git a/5_django_backend/tests/test_auth.py b/5_django_backend/tests/test_auth.py new file mode 100644 index 0000000..9051007 --- /dev/null +++ b/5_django_backend/tests/test_auth.py @@ -0,0 +1,87 @@ +"""인증 API — 프론트 auth.api.ts / types/api.ts 계약.""" + +TOKEN_KEYS = { + "token", + "tokenExpirationTime", + "refreshToken", + "refreshTokenExpirationTime", + "tokenType", + "user", +} +USER_KEYS = {"id", "email", "userName", "role", "employeeId", "department", "authProvider"} + + +def test_login_returns_token_response(api, user): + res = api.post("/api/v1/auth/login", {"email": "U@X.com", "password": "pw1234"}, format="json") + assert res.status_code == 200 + data = res.json()["data"] + assert set(data) == TOKEN_KEYS + assert data["tokenType"] == "bearer" + assert set(data["user"]) == USER_KEYS + assert data["user"]["email"] == "u@x.com" + assert data["user"]["role"] == "USER" + assert data["user"]["authProvider"] == "local" + assert data["tokenExpirationTime"] < data["refreshTokenExpirationTime"] + + +def test_login_wrong_password_401_with_code(api, user): + res = api.post("/api/v1/auth/login", {"email": "u@x.com", "password": "nope"}, format="json") + assert res.status_code == 401 + assert res.json()["code"] == "INVALID_CREDENTIALS" + + +def test_me_with_bearer(auth_api): + res = auth_api.get("/api/v1/users/me") + assert res.status_code == 200 + data = res.json()["data"] + assert data["email"] == "u@x.com" + assert data["isActive"] is True + assert "createdAt" in data + + +def test_me_with_query_token(api, auth_api): + # EventSource 등 헤더 못 붙이는 곳용 + plain = api.__class__() + res = plain.get(f"/api/v1/users/me?token={auth_api.tokens['token']}") + assert res.status_code == 200 + + +def test_refresh_issues_new_access(auth_api): + old = auth_api.tokens + res = auth_api.post("/api/v1/auth/refresh", {"refreshToken": old["refreshToken"]}, format="json") + assert res.status_code == 200 + data = res.json()["data"] + assert set(data) == TOKEN_KEYS + assert data["refreshToken"] == old["refreshToken"] # 회전 안 함 + assert data["user"]["email"] == "u@x.com" + + +def test_refresh_missing_token(api, db): + res = api.post("/api/v1/auth/refresh", {}, format="json") + assert res.status_code == 401 + assert res.json()["code"] == "REFRESH_TOKEN_MISSING" + + +def test_refresh_bad_token(api, db): + res = api.post("/api/v1/auth/refresh", {"refreshToken": "garbage"}, format="json") + assert res.status_code == 401 + assert res.json()["code"] == "REFRESH_TOKEN_INVALID" + + +def test_logout_is_public_and_null(api, db): + res = api.post("/api/v1/auth/logout", format="json") + assert res.status_code == 200 + assert res.json()["data"] is None + + +def test_entra_config_is_501(api, db): + res = api.get("/api/v1/auth/entra/config") + assert res.status_code == 501 + assert res.json()["code"] == "NOT_IMPLEMENTED" + + +def test_seed_users_exist_after_migrate(db): + from apps.accounts.models import User + + assert User.objects.filter(email="admin@codeassist.local", is_superuser=True).exists() + assert User.objects.filter(email="guest@codeassist.local").exists() diff --git a/5_django_backend/tests/test_envelope.py b/5_django_backend/tests/test_envelope.py new file mode 100644 index 0000000..df2be7d --- /dev/null +++ b/5_django_backend/tests/test_envelope.py @@ -0,0 +1,29 @@ +"""응답 envelope 가 프론트 CommonResponse 모양인지.""" + +ENVELOPE_KEYS = {"success", "statusCode", "code", "message", "data", "counts", "errors", "timestamp", "meta"} + + +def test_health_is_wrapped(api, db): + body = api.get("/api/v1/health").json() + assert set(body) == ENVELOPE_KEYS + assert body["success"] is True and body["statusCode"] == 200 + assert body["data"]["app"] + + +def test_error_is_wrapped_with_code(api, db): + res = api.get("/api/v1/users/me") + body = res.json() + assert res.status_code == 401 + assert set(body) == ENVELOPE_KEYS + assert body["success"] is False + assert body["code"] == "UNAUTHORIZED" + assert body["data"] is None + assert body["errors"] and body["message"] + + +def test_validation_error_is_400(api, db): + res = api.post("/api/v1/auth/login", {"email": "not-an-email"}, format="json") + body = res.json() + assert res.status_code == 400 + assert body["code"] == "VALIDATION_ERROR" + assert "password" in body["message"] diff --git a/5_django_backend/tests/test_sessions.py b/5_django_backend/tests/test_sessions.py new file mode 100644 index 0000000..5b2aff3 --- /dev/null +++ b/5_django_backend/tests/test_sessions.py @@ -0,0 +1,123 @@ +"""세션 미러 API — snap.api.ts 계약. OpenCode 는 가짜로.""" + +import httpx +import pytest + +from apps.chat.models import ChatMessage, ChatSession + +SESSION_KEYS = {"id", "title", "titleLlm", "isGenerating", "createdAt", "updatedAt"} +MESSAGE_KEYS = {"sessionId", "role", "content", "createdAt", "inputTokens", "outputTokens", "costUsd", "elapsedMs"} + + +class FakeOpencode: + def __init__(self): + self.n = 0 + self.aborted = [] + self.down = False + + def create_session(self): + if self.down: + raise httpx.ConnectError("refused") + self.n += 1 + return {"id": f"ses_{self.n}", "title": ""} + + def abort_session(self, sid): + self.aborted.append(sid) + return True + + +@pytest.fixture +def oc(monkeypatch): + fake = FakeOpencode() + monkeypatch.setattr("apps.chat.views.opencode_service", fake) + return fake + + +def _mk(user, sid="ses_a", **kw): + return ChatSession.objects.create(id=sid, user=user, **kw) + + +def test_create_session_mirrors_opencode(auth_api, oc): + res = auth_api.post("/api/v1/chat/sessions", {}, format="json") + assert res.status_code == 201 + data = res.json()["data"] + assert set(data) == SESSION_KEYS + assert data["id"] == "ses_1" and data["isGenerating"] is False and data["titleLlm"] is None + assert ChatSession.objects.get(id="ses_1").user.email == "u@x.com" + + +def test_create_session_when_opencode_down_503(auth_api, oc): + oc.down = True + res = auth_api.post("/api/v1/chat/sessions", {}, format="json") + assert res.status_code == 503 + assert res.json()["code"] == "UPSTREAM_UNAVAILABLE" + + +def test_list_paginated_newest_first_own_only(auth_api, user, django_user_model): + other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x") + for i in range(5): + _mk(user, f"ses_{i}") + _mk(other, "ses_other") + res = auth_api.get("/api/v1/chat/sessions?page=1&limit=3") + body = res.json() + assert [s["id"] for s in body["data"]] == ["ses_4", "ses_3", "ses_2"] + assert body["counts"] == 5 + assert body["meta"] == { + "currentPage": 1, + "pageSize": 3, + "totalItems": 5, + "totalPages": 2, + "hasNextPage": True, + "hasPreviousPage": False, + } + res2 = auth_api.get("/api/v1/chat/sessions?page=2&limit=3") + assert [s["id"] for s in res2.json()["data"]] == ["ses_1", "ses_0"] + + +def test_messages_detail(auth_api, user): + s = _mk(user, title_llm="첫 질문") + ChatMessage.objects.create(session=s, role="user", content="hi") + ChatMessage.objects.create( + session=s, role="assistant", content="hello", input_tokens=10, output_tokens=5, cost_usd=0.001, elapsed_ms=1200 + ) + res = auth_api.get("/api/v1/chat/sessions/ses_a/messages") + data = res.json()["data"] + assert set(data) == SESSION_KEYS | {"messages"} + assert data["titleLlm"] == "첫 질문" + assert [m["role"] for m in data["messages"]] == ["user", "assistant"] + assert set(data["messages"][1]) == MESSAGE_KEYS + assert data["messages"][1]["inputTokens"] == 10 + assert data["messages"][0]["inputTokens"] is None + + +def test_messages_of_other_user_404(auth_api, django_user_model): + other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x") + _mk(other, "ses_other") + assert auth_api.get("/api/v1/chat/sessions/ses_other/messages").status_code == 404 + + +def test_search_messages_icontains_own_only(auth_api, user, django_user_model): + other = django_user_model.objects.create_user(username="o@x.com", email="o@x.com", password="x") + mine = _mk(user, "ses_m") + theirs = _mk(other, "ses_t") + ChatMessage.objects.create(session=mine, role="user", content="SELECT * FROM mara") + ChatMessage.objects.create(session=mine, role="assistant", content="MARA 는 자재 마스터") + ChatMessage.objects.create(session=mine, role="user", content="다른 얘기") + ChatMessage.objects.create(session=theirs, role="user", content="mara 남의 것") + res = auth_api.get("/api/v1/chat/sessions/search?query=mara&page=1&limit=20") + body = res.json() + assert body["counts"] == 2 + assert {m["content"] for m in body["data"]} == {"SELECT * FROM mara", "MARA 는 자재 마스터"} + assert all(m["sessionId"] == "ses_m" for m in body["data"]) + + +def test_search_empty_query_400(auth_api): + assert auth_api.get("/api/v1/chat/sessions/search?query=%20").status_code == 400 + + +def test_cancel_aborts_and_clears_generating(auth_api, user, oc): + _mk(user, is_generating=True) + res = auth_api.post("/api/v1/chat/sessions/ses_a/cancel", {}, format="json") + assert res.status_code == 200 and res.json()["data"] is None + assert oc.aborted == ["ses_a"] + assert ChatSession.objects.get(id="ses_a").is_generating is False diff --git a/5_django_backend/tests/test_stream.py b/5_django_backend/tests/test_stream.py new file mode 100644 index 0000000..c181b30 --- /dev/null +++ b/5_django_backend/tests/test_stream.py @@ -0,0 +1,359 @@ +"""POST /chat/stream — OpenCode 이벤트 → 프론트 SSE 계약(token/title/usage/done/error).""" + +import asyncio +import json + +import pytest +from django.test import AsyncClient + +from apps.chat import events as events_mod +from apps.chat import stream as stream_mod +from apps.chat.models import ChatMessage, ChatSession +from apps.chat.stream import TurnState + + +# ── TurnState 단위 ─────────────────────────────────────────────── +def _part_updated(mid, pid, text, delta=None, ptype="text", sid="s1"): + props = {"part": {"id": pid, "messageID": mid, "sessionID": sid, "type": ptype, "text": text}} + if delta is not None: + props["delta"] = delta + return {"type": "message.part.updated", "properties": props} + + +def _msg_updated(mid, role, sid="s1", error=None): + info = {"id": mid, "sessionID": sid, "role": role} + if error: + info["error"] = error + return {"type": "message.updated", "properties": {"info": info}} + + +def test_turnstate_delta_path(): + st = TurnState("s1", "q") + st.handle(_msg_updated("mu", "user")) + st.handle(_part_updated("mu", "pu", "q")) # 사용자 echo → 무시 + st.handle(_msg_updated("ma", "assistant")) + assert st.handle(_part_updated("ma", "pa", "안", delta="안")) == [("token", {"delta": "안"})] + assert st.handle(_part_updated("ma", "pa", "안녕", delta="녕")) == [("token", {"delta": "녕"})] + assert st.accumulated() == "안녕" + + +def test_turnstate_snapshot_path_without_delta(): + st = TurnState("s1", "q") + st.handle(_msg_updated("ma", "assistant")) + assert st.handle(_part_updated("ma", "pa", "ab")) == [("token", {"delta": "ab"})] + assert st.handle(_part_updated("ma", "pa", "abcd")) == [("token", {"delta": "cd"})] + assert st.handle(_part_updated("ma", "pa", "abcd")) == [] # 같은 스냅샷 재전송 + assert st.accumulated() == "abcd" + + +def test_turnstate_ignores_reasoning_and_emits_title(): + st = TurnState("s1", "q") + st.handle(_msg_updated("ma", "assistant")) + assert st.handle(_part_updated("ma", "pr", "생각중", ptype="reasoning")) == [] + ev = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 조회"}}} + assert st.handle(ev) == [("title", {"title": "MARA 조회"})] + assert st.handle(ev) == [] # 같은 제목 반복 안 보냄 + placeholder = {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "New session - 2026-09-16T08:54:31.272Z"}}} + assert st.handle(placeholder) == [] # OpenCode 기본 제목은 무시 + + +def _delta(mid, pid, delta, sid="s1", field="text"): + return {"type": "message.part.delta", "properties": {"sessionID": sid, "messageID": mid, "partID": pid, "field": field, "delta": delta}} + + +def test_turnstate_real_server_shape_delta_events(): + """실서버 1.18.6 순서: part.updated(타입, 빈 텍스트) → message.part.delta ×N → part.updated(최종 스냅샷).""" + st = TurnState("s1", "q") + st.handle(_msg_updated("mu", "user")) + st.handle(_part_updated("mu", "pu", "q")) + st.handle(_msg_updated("ma", "assistant")) + assert st.handle(_part_updated("ma", "pr", "", ptype="reasoning")) == [] + assert st.handle(_delta("ma", "pr", "생각")) == [] # reasoning delta 는 안 보냄 + assert st.handle(_part_updated("ma", "pt", "")) == [] # 빈 스냅샷 + assert st.handle(_delta("ma", "pt", "SELECT")) == [("token", {"delta": "SELECT"})] + assert st.handle(_delta("ma", "pt", " SINGLE")) == [("token", {"delta": " SINGLE"})] + assert st.handle(_part_updated("ma", "pt", "SELECT SINGLE")) == [] # 최종 스냅샷 = 누적 → 중복 없음 + assert st.handle(_part_updated("ma", "pt", "SELECT SINGLE mtart")) == [("token", {"delta": " mtart"})] # 못 받은 꼬리 보충 + assert st.handle(_delta("ma", "unknown", "x")) == [] # 타입 모르는 파트 + assert st.accumulated() == "SELECT SINGLE mtart" + + +# ── 통합: 뷰 + 이벤트 버스 + 가짜 OpenCode ─────────────────────── +class FakeOpencode: + def __init__(self, script): + self.script = script # prompt 뒤에 흘려보낼 이벤트 dict 목록 + self.prompted = asyncio.Event() + self.payloads = [] + self.aborted = [] + self.fail_prompt = False + self.messages = [] + self.session_info = {"id": "s1", "title": ""} + + async def prompt_async(self, sid, payload): + self.payloads.append((sid, payload)) + if self.fail_prompt: + raise RuntimeError("down") + self.prompted.set() + + async def list_messages_a(self, sid): + return self.messages + + async def get_session_a(self, sid): + return self.session_info + + async def abort_session_a(self, sid): + self.aborted.append(sid) + + async def events(self): + await self.prompted.wait() + for ev in self.script: + yield json.dumps(ev) + await asyncio.sleep(3600) # 진짜처럼 계속 열려 있음 + + +@pytest.fixture +def fake(monkeypatch): + def _install(script): + oc = FakeOpencode(script) + monkeypatch.setattr(stream_mod, "opencode_service", oc) + monkeypatch.setattr(events_mod, "event_bus", events_mod.EventBus(source=oc.events)) + monkeypatch.setattr(stream_mod, "event_bus", events_mod.event_bus) + return oc + + return _install + + +async def _login(client: AsyncClient, email, pw): + res = await client.post("/api/v1/auth/login", {"email": email, "password": pw}, content_type="application/json") + return res.json()["data"]["token"] + + +async def _collect(resp): + """SSE 프레임 → [(event, data)]""" + raw = b"" + async for chunk in resp.streaming_content: + raw += chunk + out = [] + for frame in raw.decode().split("\n\n"): + if not frame.startswith("event:"): + continue + lines = frame.split("\n") + ev = lines[0][7:] + data = json.loads(lines[1][6:]) + out.append((ev, data)) + return out + + +ASSISTANT_DONE = [ + _msg_updated("mu", "user"), + _part_updated("mu", "pu", "MARA 뭐야"), + _msg_updated("ma", "assistant"), + _part_updated("ma", "pa", "자재", delta="자재"), + _part_updated("ma", "pa", "자재 마스터", delta=" 마스터"), + {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "MARA 설명"}}}, + {"type": "session.idle", "properties": {"sessionID": "s1"}}, +] + +FINAL_MESSAGES = [ + {"info": {"id": "mu", "role": "user"}, "parts": [{"type": "text", "text": "MARA 뭐야"}]}, + { + "info": { + "id": "ma", + "role": "assistant", + "tokens": {"input": 120, "output": 30, "reasoning": 0, "cache": {"read": 0, "write": 0}}, + "cost": 0.0012, + "time": {"created": 1000, "completed": 3500}, + }, + "parts": [{"type": "text", "text": "자재 마스터"}], + }, +] + + +@pytest.mark.django_db(transaction=True) +async def test_stream_happy_path(fake, django_user_model): + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + oc = fake(ASSISTANT_DONE) + oc.messages = FINAL_MESSAGES + + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + resp = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "MARA 뭐야", "explain": True}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + assert resp.status_code == 200 + assert resp["Content-Type"].startswith("text/event-stream") + frames = await _collect(resp) + + assert [f[0] for f in frames] == ["token", "token", "title", "usage", "done"] + assert "".join(d["delta"] for e, d in frames if e == "token") == "자재 마스터" + assert frames[2][1] == {"title": "MARA 설명"} + assert frames[3][1] == {"used": 150, "limit": 128000, "ratio": round(150 / 128000, 4), "elapsed_ms": 2500} + + # OpenCode 에 보낸 payload — agent + explain 표시 + sid, payload = oc.payloads[0] + assert sid == "s1" and payload["agent"] == "codeassist" + assert payload["parts"] == [{"type": "text", "text": "[설명 모드] MARA 뭐야"}] + + # 미러 DB + session = await ChatSession.objects.aget(id="s1") + assert session.is_generating is False and session.title_llm == "MARA 설명" + msgs = [m async for m in ChatMessage.objects.filter(session_id="s1").order_by("id")] + assert [(m.role, m.content) for m in msgs] == [("user", "MARA 뭐야"), ("assistant", "자재 마스터")] + assert (msgs[1].input_tokens, msgs[1].output_tokens, msgs[1].cost_usd, msgs[1].elapsed_ms) == (120, 30, 0.0012, 2500) + + +@pytest.mark.django_db(transaction=True) +async def test_stream_images_become_file_parts(fake, django_user_model): + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + oc = fake([_msg_updated("ma", "assistant"), {"type": "session.idle", "properties": {"sessionID": "s1"}}]) + + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + resp = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "", "images": [{"mediaType": "image/png", "data": "data:image/png;base64,AA=="}]}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + frames = await _collect(resp) + assert frames[-1] == ("done", {}) + _, payload = oc.payloads[0] + assert payload["parts"] == [ + {"type": "file", "mime": "image/png", "filename": "image-1.png", "url": "data:image/png;base64,AA=="} + ] + + +@pytest.mark.django_db(transaction=True) +async def test_stream_session_error_emits_error_and_clears_generating(fake, django_user_model): + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + fake( + [ + _msg_updated("ma", "assistant"), + _part_updated("ma", "pa", "절반", delta="절반"), + {"type": "session.error", "properties": {"sessionID": "s1", "error": {"name": "ApiError", "data": {"message": "rate limited"}}}}, + ] + ) + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + resp = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "q"}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + frames = await _collect(resp) + assert frames == [("token", {"delta": "절반"}), ("error", {"message": "rate limited", "code": "LLM_ERROR"})] + session = await ChatSession.objects.aget(id="s1") + assert session.is_generating is False + last = await ChatMessage.objects.filter(session_id="s1", role="assistant").alast() + assert last.content == "절반" # 부분 답변 보존 + + +@pytest.mark.django_db(transaction=True) +async def test_stream_409_when_generating_and_401_without_token(fake, django_user_model): + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user, is_generating=True) + fake([]) + client = AsyncClient() + res = await client.post("/api/v1/chat/stream", {"sessionId": "s1", "content": "q"}, content_type="application/json") + assert res.status_code == 401 and res.json()["code"] == "UNAUTHORIZED" + token = await _login(client, "u@x.com", "pw1234") + res = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "q"}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + assert res.status_code == 409 and res.json()["code"] == "CHAT_GENERATION_IN_PROGRESS" + + +@pytest.mark.django_db(transaction=True) +async def test_stream_validation(fake, django_user_model): + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + fake([]) + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + hdr = {"Authorization": f"Bearer {token}"} + res = await client.post("/api/v1/chat/stream", {"sessionId": "s1", "content": " "}, content_type="application/json", headers=hdr) + assert res.status_code == 400 + res = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "x", "images": [{"mediaType": "image/gif", "data": "d"}]}, + content_type="application/json", + headers=hdr, + ) + assert res.status_code == 400 + res = await client.post("/api/v1/chat/stream", {"sessionId": "nope", "content": "x"}, content_type="application/json", headers=hdr) + assert res.status_code == 404 + + +@pytest.mark.django_db(transaction=True) +async def test_stream_no_events_at_all_fails_fast(fake, django_user_model, monkeypatch): + """OpenCode 가 prompt 는 받았는데(204) 이벤트가 하나도 안 오면 → 첫 이벤트 타임아웃으로 error.""" + monkeypatch.setattr(stream_mod, "FIRST_EVENT_TIMEOUT_S", 0.2) + monkeypatch.setattr(stream_mod, "KEEPALIVE_S", 0.05) + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + oc = fake([]) # 이벤트 없음 + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + resp = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "q"}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + frames = await _collect(resp) + assert frames[-1][0] == "error" and frames[-1][1]["code"] == "LLM_ERROR" + assert oc.aborted == ["s1"] + session = await ChatSession.objects.aget(id="s1") + assert session.is_generating is False + + +@pytest.mark.django_db(transaction=True) +async def test_stream_title_arriving_after_idle_is_mirrored(fake, django_user_model, monkeypatch): + monkeypatch.setattr(stream_mod, "TITLE_WAIT_S", 2) + user = await django_user_model.objects.acreate(username="u@x.com", email="u@x.com", password="x") + user.set_password("pw1234") + await user.asave() + await ChatSession.objects.acreate(id="s1", user=user) + fake( + [ + _msg_updated("ma", "assistant"), + {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "New session - 2026"}}}, + {"type": "session.idle", "properties": {"sessionID": "s1"}}, + {"type": "session.updated", "properties": {"info": {"id": "s1", "title": "늦게 온 제목"}}}, + ] + ) + client = AsyncClient() + token = await _login(client, "u@x.com", "pw1234") + resp = await client.post( + "/api/v1/chat/stream", + {"sessionId": "s1", "content": "q"}, + content_type="application/json", + headers={"Authorization": f"Bearer {token}"}, + ) + frames = await _collect(resp) + assert frames[-1] == ("done", {}) # 제목 기다리느라 done 이 늦어지면 안 됨 + await asyncio.sleep(0.3) + session = await ChatSession.objects.aget(id="s1") + assert session.title_llm == "늦게 온 제목" diff --git a/docs/orders/20260916.md b/docs/orders/20260916.md index c7fc893..3444a3c 100644 --- a/docs/orders/20260916.md +++ b/docs/orders/20260916.md @@ -21,7 +21,7 @@ ## (오후) 백엔드를 OpenCode + Django 로 — `specs/006-opencode-django-backend/` - 분석·결정 5개: `docs/tech/opencode-django-backend.md` (Django 새로 / 토큰 localStorage / Entra 안 함 / OpenCode 분리 / 스니펫 로컬) -- 구현: `../ABAP_CODE_ASSISTANT_BACKEND/` 신설 + 프론트 Bearer 모드 2군데. 진행 상황 `specs/006-opencode-django-backend/stage-1.md` +- 구현: `5_django_backend/` 신설 + 프론트 Bearer 모드 2군데. 진행 상황 `specs/006-opencode-django-backend/stage-1.md` - 배포는 보류(사용자 지시) ## 다음 diff --git a/docs/tech/opencode-django-backend.md b/docs/tech/opencode-django-backend.md index c257592..b923ae3 100644 --- a/docs/tech/opencode-django-backend.md +++ b/docs/tech/opencode-django-backend.md @@ -74,7 +74,7 @@ CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 포트는 base-backend 가 쓰던 `8001` 그대로 → 프론트 `.env` 의 `VITE_DEV_API_TARGET` 도 안 바꿈. ABAP_OPENCODE 스택(8100/4096/3200)과 같은 VM 에 둘 땐 OpenCode 포트만 4097 로. -**Django 코드 위치 (결정 1 = 새로):** 이 repo 안 `../ABAP_CODE_ASSISTANT_BACKEND/` 로 제안. 이유: 프론트·Tauri·docs-lib·spec 이 다 여기라 계약 맞추기 쉽고, `docs-lib/` 패턴도 그대로. 별도 repo 원하면 폴더째 떼면 됨 — 지금 결정 안 해도 됨. +**Django 코드 위치 (결정 1 = 새로):** 이 repo 안 `5_django_backend/` 로 제안. 이유: 프론트·Tauri·docs-lib·spec 이 다 여기라 계약 맞추기 쉽고, `docs-lib/` 패턴도 그대로. 별도 repo 원하면 폴더째 떼면 됨 — 지금 결정 안 해도 됨. ## 5. 어댑터 — 뭘 만드나 @@ -105,7 +105,7 @@ OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usa 6. cancel → OpenCode `abort` + `is_generating=false`. 7. 프론트가 스트림 잃어도 `isGenerating` 폴링이 미러를 보니 답변 복구 — 기존 동작 그대로. -### 5.5 OpenCode workspace `../ABAP_CODE_ASSISTANT_BACKEND/opencode/` +### 5.5 OpenCode workspace `5_django_backend/opencode/` - `AGENTS.md`: 한국어, ABAP/SAP 코드 즉답, 코드펜스 언어 태그, 절차 질문 금지, `explain` 이면 배경 설명. - `opencode.json`: dev = OpenRouter, 고객사 = FabriX(게이트웨이 경유, 2차). `tools.question=false`. MCP `sap-icf`. - `.opencode/agent/codeassist.md`: 시스템 프롬프트. `forcedSkill` 1차 무시. @@ -128,7 +128,7 @@ OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usa | 단계 | 내용 | 검증 | |---|---|---| -| 0 | `../ABAP_CODE_ASSISTANT_BACKEND/docs-lib/` 에 OpenCode 서버 API(세션·메시지 parts·event), DRF, simplejwt 문서 떨어뜨림 | 카탈로그 README | +| 0 | `5_django_backend/docs-lib/` 에 OpenCode 서버 API(세션·메시지 parts·event), DRF, simplejwt 문서 떨어뜨림 | 카탈로그 README | | 1 | 뼈대 복사 + envelope renderer + accounts(login/refresh/logout/me) + entra 501 | 프론트 로그인 통과 | | 2 | 미러 테이블 + sessions list/create/messages/search | 목록·검색 화면 | | 3 | opencode-ca workspace + compose + `OPENCODE_BASE_URL` | curl 로 세션 생성·프롬프트 | diff --git a/specs/006-opencode-django-backend/stage-1.md b/specs/006-opencode-django-backend/stage-1.md index 6753f82..877e3bb 100644 --- a/specs/006-opencode-django-backend/stage-1.md +++ b/specs/006-opencode-django-backend/stage-1.md @@ -6,7 +6,7 @@ | # | 내용 | 검증 | |---|---|---| -| 1 | `../ABAP_CODE_ASSISTANT_BACKEND/` 신설 — ABAP_OPENCODE `web/BE` 에서 settings·JWT·OpenCode 클라 복사·다듬음 | pytest | +| 1 | `5_django_backend/` 신설 — ABAP_OPENCODE `web/BE` 에서 settings·JWT·OpenCode 클라 복사·다듬음 | pytest | | 2 | envelope renderer/예외 핸들러 (`CommonResponse`, `CodedError`) | test_envelope 3 | | 3 | auth: email 로그인 → `TokenResponse`, refresh(body)·logout·me, entra/config 501 | test_auth 10 | | 4 | 세션 미러 `ChatSession/ChatMessage` + sessions list/create/search/messages/cancel | test_sessions 8 | diff --git a/z-my-docs/work-log/2026-09/2026-09-16.md b/z-my-docs/work-log/2026-09/2026-09-16.md index 98b0fac..689cf67 100644 --- a/z-my-docs/work-log/2026-09/2026-09-16.md +++ b/z-my-docs/work-log/2026-09/2026-09-16.md @@ -17,3 +17,4 @@ | 20:23 | 로그인(Tauri 중복 헤더 제거·가운데 정렬)과 새 대화 화면 재구성 — 목록과 같은 760px 열, 빈 레일 제거, 로고·제목·한 줄 설명·예시 질문 칩 3개(누르면 바로 시작), 입력창을 테두리 상자 하나에 도구줄(고스트 버튼)로 축소, 대화 헤더 뒤로가기도 같은 스타일 | | 20:27 | 대화 화면 입력창을 본문 열 안으로 — 레일 오른쪽 열에 메시지·입력창을 같이 묶어 같은 폭(760px)·같은 왼쪽 끝. 전체 폭 기준 가운데라 본문과 어긋나던 것 해소 | | 20:43 | 대화 말풍선 흰 배경 제거 — 메시지 영역의 theme-light 강제 해제, AI 답변은 상자 없이 본문, 질문은 연한 칩. ABAP 흰 에디터 룩 코드블럭은 라이트 모드에서만(themeStore.resolved), 다크는 어두운 코드 테마. 스니펫 미리보기도 같은 규칙 | +| 21:28 | 백엔드 폴더를 repo 안 5_django_backend/ 로 복귀(밖으로 나가 있던 건 내 실수), venv 재생성·pytest 32 통과, 문서 경로 원복, Django·OpenCode 재기동 |