From 08fdc1a258daedf9c7f67679433c6bb9fabe55be Mon Sep 17 00:00:00 2001 From: lee-hyeon-cheol Date: Wed, 16 Sep 2026 21:24:17 +0900 Subject: [PATCH] =?UTF-8?q?docs:=20OpenCode+Django=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EC=A0=84=ED=99=98=20=EB=B6=84=EC=84=9D=C2=B7?= =?UTF-8?q?=EA=B2=B0=EC=A0=95=C2=B7stage-1=20=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Fable 5.1 --- docs/orders/20260916.md | 12 ++ docs/tech/opencode-django-backend.md | 176 +++++++++---------- specs/006-opencode-django-backend/stage-1.md | 32 ++++ z-my-docs/work-log/2026-09/2026-09-16.md | 14 ++ 4 files changed, 145 insertions(+), 89 deletions(-) create mode 100644 specs/006-opencode-django-backend/stage-1.md diff --git a/docs/orders/20260916.md b/docs/orders/20260916.md index 25710a9..c7fc893 100644 --- a/docs/orders/20260916.md +++ b/docs/orders/20260916.md @@ -15,3 +15,15 @@ ## 다음 - 실제 backend 환경에서 vision 응답을 수동 확인함. + +--- + +## (오후) 백엔드를 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` +- 배포는 보류(사용자 지시) + +## 다음 + +- 유효한 LLM 키로 실제 답변 스트림 확인 → 프론트 실연결 → Tauri 빌드 end-to-end diff --git a/docs/tech/opencode-django-backend.md b/docs/tech/opencode-django-backend.md index 4d931c6..c257592 100644 --- a/docs/tech/opencode-django-backend.md +++ b/docs/tech/opencode-django-backend.md @@ -1,12 +1,12 @@ # CodeAssist 백엔드를 OpenCode + Django 로 — 분석과 인프라 제안 -작성 2026-09-16. 코드 변경 전 분석 단계. 확정되면 `/speckit-specify` 로 `specs/006-*` 파고 여기 내용은 research 로 승계. +작성 2026-09-16. 코드 변경 전 분석 단계. 결정 5개 확정됨(6번). 다음은 `/speckit-specify` 로 `specs/006-*` 파고 여기 내용은 research 로 승계. ## 0. 한 줄 결론 -**프론트 계약은 그대로 두고, ABAP_OPENCODE 의 Django(`web/BE`) 안에 CodeAssist 전용 어댑터 앱을 하나 얹는다.** -Django 가 OpenCode 서버를 호출하고, OpenCode 의 이벤트 스트림을 프론트가 이미 아는 `POST /chat/stream` SSE(token/title/usage/done/error)로 바꿔준다. -프론트 변경은 env 한 줄 + 토큰 저장 두 줄. 나머지는 전부 서버 쪽. +**프론트 계약은 그대로 두고, CodeAssist 전용 Django 를 새로 만든다.** ABAP_OPENCODE `web/BE` 에서 인증·OpenCode 클라이언트·배포 뼈대를 **복사**해 오고, 그 위에 어댑터(envelope·세션 미러·스트림 변환)를 얹는다. +Django 가 OpenCode 서버(CodeAssist 전용 workspace)를 호출하고, OpenCode 이벤트를 프론트가 이미 아는 `POST /chat/stream` SSE(token/title/usage/done/error)로 바꿔준다. +프론트 변경은 env 한 줄 + 토큰 저장 두 줄. ## 1. 지금 상태 — 프론트가 백엔드에 기대하는 것 @@ -17,123 +17,121 @@ Django 가 OpenCode 서버를 호출하고, OpenCode 의 이벤트 스트림을 |---|---| | 베이스 | `VITE_API_BASE_URL` (기본 `/api/v1`, 풀 URL 도 됨). dev 는 Vite proxy 로 same-origin | | 응답 모양 | 전부 envelope `CommonResponse{success,statusCode,code,message,data,counts,errors,timestamp,meta}`. 헬퍼가 `.data` 언랩, `apiList` 는 `meta{currentPage,pageSize,totalItems,totalPages,hasNextPage,hasPreviousPage}` 로 페이지네이션. `success:false` 면 `ApiError` | -| 인증 | httpOnly 쿠키(`accessToken`/`refreshToken`) + 401 → `POST /auth/refresh` 자동 재시도. **Bearer seam 이미 있음** — `lib/auth/tokenProvider.ts` 에 토큰 넣으면 axios·SSE 둘 다 `Authorization: Bearer` 로 감 | -| 인증 API | `POST /auth/login{email,password}` → `TokenResponse{token,refreshToken,…,user}` (프론트는 `.user` 만 씀) · `POST /auth/refresh` · `POST /auth/logout` · `GET /users/me` · Entra `POST /auth/entra/login`, `GET /auth/entra/config` (**501 이면 버튼 숨김**) | -| 세션 | `GET /chat/sessions?page&limit` (page=3개 peek) · `POST /chat/sessions` · `GET /chat/sessions/search?query&page&limit` (메시지 본문 ILIKE) · `GET /chat/sessions/{id}/messages` (`isGenerating` true 면 1.5초 폴링) · `POST /chat/sessions/{id}/cancel` | +| 인증 | httpOnly 쿠키 + 401 → `POST /auth/refresh` 자동 재시도. **Bearer seam 이미 있음** — `lib/auth/tokenProvider.ts` 에 토큰 넣으면 axios·SSE 둘 다 `Authorization: Bearer` 로 감 | +| 인증 API | `POST /auth/login{email,password}` → `TokenResponse{token,refreshToken,…,user}` · `POST /auth/refresh` · `POST /auth/logout` · `GET /users/me` · Entra `GET /auth/entra/config` (**501 이면 버튼 숨김** — 다른 코드는 에러로 뜸) | +| 세션 | `GET /chat/sessions?page&limit` · `POST /chat/sessions` · `GET /chat/sessions/search?query&page&limit` (메시지 본문 ILIKE) · `GET /chat/sessions/{id}/messages` (`isGenerating` true 면 1.5초 폴링) · `POST /chat/sessions/{id}/cancel` | | DTO | `SnapSession{id,title,titleLlm,isGenerating,createdAt,updatedAt}` · `SnapMessage{sessionId,role,content,createdAt,inputTokens?,outputTokens?,costUsd?,elapsedMs?}` | -| 스트림 | `POST /chat/stream` body `{sessionId,content,images?[{mediaType,data(dataURL)}],forcedSkill?,explain?}`. 요청 하나 = SSE 하나. 이벤트 `token{delta}` → `usage{used,limit,ratio,elapsed_ms}` → `done{traceId?}`, 중간 `title{title}`, 실패는 `error{message,code}`. `@microsoft/fetch-event-source` 라 POST 가능 | -| 스니펫 | **백엔드 안 씀.** Tauri 브릿지 → 로컬 SQLite | +| 스트림 | `POST /chat/stream` body `{sessionId,content,images?[{mediaType,data(dataURL)}],forcedSkill?,explain?}`. 요청 하나 = SSE 하나. `token{delta}` → `usage{used,limit,ratio,elapsed_ms}` → `done{traceId?}`, 중간 `title{title}`, 실패 `error{message,code}` | +| 스니펫 | **백엔드 안 씀.** Tauri 브릿지 → 로컬 SQLite (결정 5: 유지) | -**패키징 gotcha (백엔드 뭘 쓰든 동일):** Tauri 빌드는 dist 를 `tauri://` 계열 origin 으로 띄움. `VITE_API_BASE_URL=/api/v1` 상대경로는 dev(Vite proxy)에서만 되고, 배포본은 **풀 URL** 이어야 함. 그러면 cross-origin 이라 쿠키는 `SameSite=None; Secure` = **https 필수**. 고객사 bare 배포가 http(`10.196.81.34:8918`)라 쿠키 인증은 거기서 깨짐 → Bearer 로 가는 게 안전. +**패키징 gotcha:** Tauri 빌드는 dist 를 `tauri://` 계열 origin 으로 띄움. `/api/v1` 상대경로는 dev(Vite proxy)에서만 되고 배포본은 **풀 URL** 이어야 함. cross-origin 이면 쿠키는 `SameSite=None; Secure` = https 필수인데 고객사 bare 배포가 http → **Bearer 로 간다** (결정 2). -## 2. 가져다 쓸 것 — ABAP_OPENCODE `web/BE` Django 가 이미 하는 것 +## 2. ABAP_OPENCODE `web/BE` 에서 복사해 올 것 -출처: `web/BE/code/README.md`, `common/opencode_service.py`, `apps/chat/sse.py`, `apps/accounts/authentication.py`, `deploy/docker-compose.yml`, `apps_ito/aaf/urls.py`. +출처: `web/BE/code/README.md`, `common/opencode_service.py`, `apps/chat/sse.py`, `apps/accounts/*`, `deploy/*`. -| 영역 | 있는 것 | CodeAssist 계약과 차이 | +| 가져올 것 | 어디서 | 손볼 것 | |---|---|---| -| 런타임 | Django + DRF, **uvicorn(ASGI)** — SSE 버퍼링 없이 흐름 | 같음. 그대로 | -| 인증 | simplejwt **Bearer 헤더 또는 `?token=`**, 14일 액세스, refresh 없음, 쿠키 없음, `{"detail"}` 에러 | envelope 아님, 쿠키 아님, refresh 없음 | -| 세션 | OpenCode 가 세션·메시지 원본. Django 는 `session_owner` 매핑만 | 페이지네이션 없음, 검색 없음, `isGenerating`·usage 필드 없음 | -| 스트림 | `GET /chat/events` **전역 SSE 하나**(OpenCode `/event` 를 owner 필터해 통째로 중계). 전송은 `POST …/messages` 동기(최대 10분 블록). FE 가 `message.part.delta`/`session.idle` 직접 파싱 | 요청-단위 SSE 아님, 이벤트 어휘 다름 | -| OpenCode | `opencode serve :4096`, workspace = `AGENTS.md` + `skills/` + `opencode.json`(모델·MCP). `.opencode/agent/*.md` 로 에이전트 정의 | AGENTS.md 가 "ABAP FS 브레인스토밍 자동 트리거" — CodeAssist 짧은 코드 답변과 성격 충돌 | -| LLM 연결 | opencode.json → OpenRouter GLM. 고객사는 `render-opencode.py` 가 FabriX 모델 목록으로 렌더하고 `/api/ito` passthrough 게이트웨이가 FabriX 로 중계(401 4조합 재시도까지 됨) | 그대로 재사용. 폐쇄망 대응 다 돼 있음 | -| SAP | `sap-icf` MCP(:3200), 고객사 ZAI 모드 자동 | CodeAssist 챗도 "이 테이블 뭐야" 같은 질문에 쓸 수 있음 — 덤 | -| 배포 | compose(fe·db·be·be-watcher·opencode·mcp) + 고객사용 bare `run.sh` | 컨테이너/프로세스 하나 추가하면 됨 | -| DB | SQLite ↔ PostgreSQL 스위치, `DB_HOST` 로 자동 판단 | 그대로 | +| Django+DRF+uvicorn(ASGI) 뼈대, settings 의 DB 스위치(SQLite↔PG), CORS | `config/`, `requirements.txt` | 앱 이름·포트만 | +| JWT 인증 (simplejwt, Bearer 또는 `?token=`) | `apps/accounts/` | refresh 토큰 발급 추가, 응답을 `TokenResponse` 모양으로, 에러를 envelope 로 | +| OpenCode HTTP 클라 + `/event` SSE 파서(60초 무활동 재연결) | `common/opencode_service.py` | 그대로. base URL 만 env | +| 세션 owner 매핑 아이디어 | `apps/ownership/` | 미러 테이블(5.3)로 흡수, 파일 소유권·워처는 안 가져옴 | +| Dockerfile(be·opencode), compose, bare `run.sh` | `deploy/` | 서비스 3개(be·opencode-ca·mcp)로 줄임 | +| FabriX 게이트웨이 `apps_ito/aaf` (OpenAI 호환 중계, 401 재시도) | `apps_ito/` | **1차 안 가져옴.** 개발은 OpenRouter 직결. 고객사 갈 때 통째 복사(설계상 한 줄 include 로 붙게 돼 있음) | +| `sap-icf` MCP 서버 | `web/MCP/code` | 복사 안 하고 **같은 VM 이면 기존 :3200 공유**, 아니면 그때 복사 | -## 3. 선택지 +안 가져오는 것: `apps/files`(산출물), `apps/usage`, `be-watcher`, ABAP 스킬 4종, `AGENTS.md`(FS 브레인스토밍 규칙 — CodeAssist 랑 정반대). -| | A. Django 에 CodeAssist 어댑터 앱 추가 (추천) | B. 프론트를 ABAP_OPENCODE 계약으로 갈아타기 | C. OpenCode 안 거치고 `/api/ito` OpenAI 호환 중계만 | +## 3. 왜 이 안이냐 (대안 비교) + +| | A. 새 Django + 어댑터 (확정) | B. 프론트를 ABAP_OPENCODE 계약으로 | C. OpenCode 없이 OpenAI 호환 중계만 | |---|---|---|---| -| 프론트 변경 | env + 토큰 저장 2줄 | envelope·인증·스트림 모델 전부 (snap feature 대수술) | 스트림 어댑터 새로 | -| 서버 변경 | 앱 1개 + 어댑터 | 거의 없음 | 세션·히스토리·검색 전부 Django 가 직접 | -| OpenCode 장점(도구·MCP·스킬·세션 컨텍스트) | 씀 | 씀 | **못 씀** — "opencode + django" 요청과 어긋남 | -| 위험 | 이벤트 어댑터 품질 | 회귀 범위 큼(테스트 268개 건드림) | 기능은 단순하지만 요청 취지 밖 | +| 프론트 변경 | env + 토큰 2줄 | envelope·인증·스트림 모델 대수술 | 스트림 어댑터 새로 | +| OpenCode 장점(MCP·세션 컨텍스트·에이전트) | 씀 | 씀 | 못 씀 — 요청 취지 밖 | +| 위험 | 이벤트 어댑터 품질, 복사본 관리 | 회귀 범위 큼(테스트 268개) | 단순하지만 요청과 다름 | -**A 로 간다.** 프론트는 이미 "seam 만 바꾸면 되게" 짜여 있고(2026-07-18 설계의 방침 그대로), Django 쪽은 owner 매핑·OpenCode 클라이언트·SSE 파서가 다 있어서 어댑터만 얹으면 됨. +프론트는 "seam 만 바꾸면 되게" 짜여 있고(tokenProvider, streamLLM), Django 쪽 부품은 복사하면 되니 어댑터만 새로 짜면 됨. -## 4. 인프라 그림 (제안) +## 4. 인프라 그림 (확정 반영) ``` -[Windows PC] [서버 VM — ABAP_OPENCODE 스택 + 1] -CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 직접(bare :8080) - ├─ WebView2: React dist │ /base-api/* → web/BE (기존 ABAP OpenCode 화면용) - │ VITE_API_BASE_URL=https://<서버>/ca-api/v1 │ /ca-api/v1/* → web/BE apps/codeassist ← 신규 - │ Authorization: Bearer ───────────┼──▶ Django(uvicorn ASGI, :8100) - │ │ ├─ apps/accounts JWT 발급 (공용) - │ │ ├─ apps/codeassist 세션·검색·스트림 어댑터 + 미러 DB - │ │ └─ apps_ito/aaf /api/ito OpenAI 호환 중계 (FabriX/GLM) - └─ Rust: 핫키·캡처·스니펫 SQLite(로컬, 서버 X) │ - ├─ opencode-ca(:4097) ← 신규 컨테이너. 같은 이미지, 다른 workspace - │ /workspace-ca/AGENTS.md (짧은 코드 답변 규칙) - │ /workspace-ca/opencode.json (모델 = /api/ito 게이트웨이 또는 OpenRouter, MCP = sap-icf) - │ /workspace-ca/.opencode/agent/codeassist.md - ├─ opencode(:4096) 기존 ABAP OpenCode 용 (안 건드림) - ├─ mcp(:3200) sap-icf 둘이 공유 - └─ db SQLite 또는 PostgreSQL (공유, 테이블만 추가) +[Windows PC] [서버 VM] ← CodeAssist 스택 (ABAP_OPENCODE 와 별개 프로세스) +CodeAssist.exe (Tauri) ┌─ nginx(:80) 또는 Django 직접(bare) + ├─ WebView2: React dist │ /api/v1/* → codeassist-be + │ VITE_API_BASE_URL=http(s)://<서버>/api/v1 │ + │ Authorization: Bearer ───────────┼──▶ codeassist-be Django(uvicorn ASGI, :8001) + │ (토큰: localStorage — 결정 2) │ ├─ accounts JWT 발급·refresh (web/BE 복사) + │ │ ├─ chat 세션·검색·스트림 어댑터 + 미러 DB + │ │ └─ common/opencode_service.py (복사) + └─ Rust: 핫키·캡처·스니펫 SQLite(로컬) │ + ├─ opencode-ca(:4096) OpenCode 전용 인스턴스 (결정 4) + │ /workspace/AGENTS.md 짧은 코드 답변 규칙 + │ /workspace/opencode.json 모델(dev OpenRouter / 고객사 FabriX), MCP sap-icf + │ /workspace/.opencode/agent/codeassist.md + ├─ mcp sap-icf(:3200) 같은 VM 이면 ABAP_OPENCODE 것 공유 + └─ db SQLite 파일(개발) / PostgreSQL(고객사) — DB_HOST 로 스위치 ``` -**왜 OpenCode 를 따로 하나 더 띄우나:** OpenCode 는 workspace 의 `AGENTS.md` 를 모든 세션에 적용함. ABAP OpenCode 것은 "리포트 만들어줘 → 코드 쓰지 말고 질문 시작" 규칙이라 CodeAssist(즉답 코드 도우미)랑 정면 충돌. 이미지·볼륨만 다르게 하면 비용 거의 0. 포트 하나(4097)만 더. +포트는 base-backend 가 쓰던 `8001` 그대로 → 프론트 `.env` 의 `VITE_DEV_API_TARGET` 도 안 바꿈. ABAP_OPENCODE 스택(8100/4096/3200)과 같은 VM 에 둘 땐 OpenCode 포트만 4097 로. -**왜 Django 는 하나:** 인증(accounts)·ITO 게이트웨이(FabriX)·DB·배포 스크립트를 두 번 안 만들려고. CodeAssist 라우트는 `/ca-api/v1/` 프리픽스로 분리해서 기존 `/api/v1/chat/*` 랑 안 겹치게. +**Django 코드 위치 (결정 1 = 새로):** 이 repo 안 `../ABAP_CODE_ASSISTANT_BACKEND/` 로 제안. 이유: 프론트·Tauri·docs-lib·spec 이 다 여기라 계약 맞추기 쉽고, `docs-lib/` 패턴도 그대로. 별도 repo 원하면 폴더째 떼면 됨 — 지금 결정 안 해도 됨. -## 5. 어댑터 앱 `apps/codeassist` — 뭘 만드나 +## 5. 어댑터 — 뭘 만드나 ### 5.1 응답 모양 -- DRF Renderer + exception handler 를 **이 앱에만** 걸어 `CommonResponse` envelope 로 감쌈. 목록은 `meta` 채움. -- 에러 코드는 프론트가 아는 것 유지: `CHAT_GENERATION_IN_PROGRESS`(409), `LLM_ERROR`, `REFRESH_TOKEN_MISSING` 등. +- DRF Renderer + exception handler 로 전 응답 `CommonResponse` envelope. 목록은 `meta` 채움. +- 에러 코드 프론트가 아는 것 유지: `CHAT_GENERATION_IN_PROGRESS`(409), `LLM_ERROR`, `REFRESH_TOKEN_MISSING`(401). -### 5.2 인증 -- `POST /ca-api/v1/auth/login` → 기존 accounts 로그인 재사용, 응답만 `TokenResponse` 모양. `refreshToken` 은 simplejwt refresh 토큰. -- `POST /auth/refresh` 는 body 로 refreshToken 받는 걸로(쿠키 아님). `GET /users/me` 는 accounts `me` 감싸기. -- Entra: `GET /auth/entra/config` → **501** 로 시작 (프론트가 버튼 숨김). 필요해지면 2차. -- **프론트 변경 지점 (유일):** 로그인 응답의 `token` 을 `setAccessToken()` 에 넣고 저장. 저장소는 Tauri 쪽(`tauri-plugin-store` 또는 Windows Credential Manager) 이 정석, 1차는 localStorage 로 시작해도 됨. 401 인터셉터의 refresh 호출을 Bearer 모드에서도 돌게 하는 것까지 포함(지금은 `// TODO(host)`). +### 5.2 인증 (결정 2·3) +- `POST /auth/login` → simplejwt access+refresh 발급, 응답 `TokenResponse{token,refreshToken,tokenExpirationTime,…,user}`. +- `POST /auth/refresh` body `{refreshToken}` (쿠키 아님). `POST /auth/logout` 은 refresh 블랙리스트. `GET /users/me`. +- `GET /auth/entra/config` → **501 고정** (결정 3: Entra 안 함. 프론트가 버튼 숨김). `entra/login` 라우트 자체 없음. +- **프론트 변경 (유일):** 로그인 응답 `token`/`refreshToken` 을 `setAccessToken()` + localStorage 에. 401 인터셉터 refresh 를 Bearer 모드에서도 돌게(body 에 refreshToken). 지금 `// TODO(host)` 자리. ### 5.3 세션·메시지 — 미러 테이블 -OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usage 를 안 줌. 그래서 Django 에 **미러**를 둠(base-backend 도 DB 가 원본이었음): -- `ca_session(id=opencode session id, user, title, title_llm, is_generating, created, updated)` -- `ca_message(session, role, content, created, input_tokens, output_tokens, cost_usd, elapsed_ms)` -- 쓰는 시점: 스트림 시작 시 user 메시지 insert + `is_generating=true`, `session.idle` 받으면 OpenCode `GET /session/{id}/message` 로 마지막 assistant 메시지·`tokens`/`cost` 가져와 insert, `is_generating=false`. -- 검색은 미러에서 `icontains`. 목록은 미러에서 `-updated` 정렬 + page/limit. -- 대화 컨텍스트 원본은 계속 OpenCode(세션 이어서 질문하면 OpenCode 가 히스토리 들고 있음). 미러는 조회·검색 전용. +OpenCode 는 페이지네이션·본문 검색·`isGenerating`·메시지별 usage 를 안 줌 → Django 에 미러: +- `chat_session(id=opencode session id, user, title, title_llm, is_generating, created, updated)` +- `chat_message(session, role, content, created, input_tokens, output_tokens, cost_usd, elapsed_ms)` +- 스트림 시작: user 메시지 insert + `is_generating=true`. `session.idle`: OpenCode `GET /session/{id}/message` 로 마지막 assistant 본문·`tokens`/`cost` 가져와 insert, `is_generating=false`. +- 목록 = 미러 `-updated` + page/limit. 검색 = 미러 `icontains`. 대화 컨텍스트 원본은 계속 OpenCode. ### 5.4 스트림 어댑터 `POST /chat/stream` -순수 Django async 뷰(ASGI). 흐름: -1. owner 확인, `is_generating` 이면 409 `CHAT_GENERATION_IN_PROGRESS`. -2. 프로세스당 하나인 **OpenCode `/event` 구독 태스크**(기존 `iter_event_jsons` 재사용)에서 세션별 `asyncio.Queue` 로 fan-out. -3. OpenCode 에 메시지 전송은 **블록 안 되게** 스레드/태스크로 (`sync_to_async(send_message)` 또는 OpenCode 의 비동기 프롬프트 엔드포인트 — docs-lib 에서 버전 확인 필요). `agent: "codeassist"`, 모델은 opencode.json 기본. -4. 큐에서 `message.part.delta`(text) → `event: token {delta}`, `session.updated` 의 title 바뀜 → `event: title`, `session.idle` → usage 계산 → `event: usage` → `event: done`. 에러 이벤트 → `event: error`. -5. 이미지: OpenCode 메시지 parts 의 file 파트(mime + data URL)로 전달. **구현 전 docs-lib 로 파트 스키마 확인** (추측 금지). -6. 취소 `POST /chat/sessions/{id}/cancel` → OpenCode `abort` + `is_generating=false`. -7. 프론트가 스트림을 잃어도 `isGenerating` 폴링이 미러 DB 를 보니 답변 복구됨 — 기존 동작 그대로. +순수 Django async 뷰(ASGI): +1. owner 확인, `is_generating` 이면 409. +2. 프로세스당 하나인 OpenCode `/event` 구독 태스크(복사한 `iter_event_jsons`)에서 세션별 `asyncio.Queue` 로 fan-out. +3. 메시지 전송은 블록 안 되게 태스크로(`sync_to_async(send_message)` 또는 OpenCode 비동기 프롬프트 엔드포인트 — **docs-lib 로 버전 확인**). `agent: "codeassist"`. +4. 큐: `message.part.delta`(text) → `event: token`, `session.updated` title → `event: title`, `session.idle` → `usage` → `done`. 에러 → `event: error`. +5. 이미지: OpenCode file 파트(mime + data URL). **파트 스키마 docs-lib 확인 후** 구현. +6. cancel → OpenCode `abort` + `is_generating=false`. +7. 프론트가 스트림 잃어도 `isGenerating` 폴링이 미러를 보니 답변 복구 — 기존 동작 그대로. -### 5.5 OpenCode workspace (`web/opencode-ca/` 제안) -- `AGENTS.md`: 한국어, ABAP/SAP 코드 즉답, 코드펜스 언어 태그, 불필요한 절차 질문 금지, `explain` 플래그면 배경 설명. -- `opencode.json`: provider = 고객사면 `/api/ito` 게이트웨이(OpenAI 호환), 개발이면 OpenRouter. `tools.question=false`(웹과 같은 이유 — 답할 UI 없음). MCP `sap-icf` 연결. -- `.opencode/agent/codeassist.md`: 시스템 프롬프트. `forcedSkill` 은 1차 무시. +### 5.5 OpenCode workspace `../ABAP_CODE_ASSISTANT_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차 무시. ### 5.6 배포 -- compose: `opencode-ca` 서비스 추가(이미지 `Dockerfile.opencode` 재사용, 볼륨 `../opencode-ca:/workspace`, `oc_ca_data`), `be` 에 `OPENCODE_CA_BASE_URL=http://opencode-ca:4096`. nginx 에 `/ca-api/` location. bare `run.sh` 에 프로세스 하나 추가. -- CodeAssist 빌드: `2_frontend/.env.production` 에 `VITE_API_BASE_URL=https://<서버>/ca-api/v1`. CORS 는 Tauri origin(`tauri://localhost`, `http://tauri.localhost`) 허용 — 서버 `CORS_ORIGINS` 에 추가. +- compose: `be`·`opencode-ca`·(옵션)`mcp` 3개. 고객사 bare 는 `run.sh` 복사해 프로세스 2개. +- 프론트 `.env.production`: `VITE_API_BASE_URL=http(s)://<서버>/api/v1`. 서버 `CORS_ORIGINS` 에 Tauri origin(`tauri://localhost`, `http://tauri.localhost`) 추가, `CORS_ALLOW_HEADERS` 에 Authorization. -## 6. 남는 결정 (사용자가 정할 것) +## 6. 결정 (2026-09-16 확정) -1. **Django 코드 위치** — ABAP_OPENCODE `web/BE` 에 앱 추가(추천, 인프라 공유) vs CodeAssist 용 Django 별도 repo. 별도면 accounts·ITO·배포를 복제해야 함. -2. **인증 저장** — 1차 localStorage(빠름) vs 처음부터 Tauri 저장소(안전). 토큰이 PC 에 남는 건 둘 다 같고, 보안 차이는 다른 프로세스가 읽기 쉬운가 정도. -3. **Entra 로그인** — 1차 501 로 끄기(추천) vs 바로 구현. 고객사 폐쇄망이면 어차피 못 씀. -4. **OpenCode 분리** — 컨테이너 분리(추천) vs 하나에 agent 만 나눔. 후자는 AGENTS.md 충돌을 프롬프트로 덮어야 해서 불안정. -5. **스니펫 서버 동기화** — 지금은 로컬 SQLite. 팀 공유 원하면 2차 (`ca_snippet` 테이블 + 동기화). +| # | 항목 | 결정 | 영향 | +|---|---|---|---| +| 1 | Django 위치 | **새로 만듦** (ABAP_OPENCODE 에 안 붙임) | accounts·opencode_service·deploy 복사. 게이트웨이(FabriX)는 고객사 갈 때 복사 | +| 2 | 토큰 저장 | **localStorage** (테스트 단계) | 프론트 2줄. 고객사 배포 전 Tauri 저장소로 옮길지 그때 결정 | +| 3 | Entra | **안 함** | `entra/config` 501 고정, 다른 Entra 코드 안 짬 | +| 4 | OpenCode | **분리** 인스턴스 | 전용 workspace·AGENTS.md·포트 | +| 5 | 스니펫 | **로컬 SQLite 유지** | 백엔드 범위 밖 | -## 7. 단계 (spec 파면 이 순서로 tasks 뽑을 것) +## 7. 단계 (spec 파면 이 순서로 tasks) | 단계 | 내용 | 검증 | |---|---|---| -| 1 | `apps/codeassist` 뼈대 + envelope renderer + `/ca-api/v1` 배선 + auth 4개(login/refresh/logout/me) + entra 501 | 프론트 로그인 화면이 실제로 통과 | +| 0 | `../ABAP_CODE_ASSISTANT_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 + Django `OPENCODE_CA_BASE_URL` | curl 로 세션 생성·프롬프트 | -| 4 | 스트림 어댑터(token/title/usage/done/error) + cancel + isGenerating | Snap 채팅 스트리밍, 중단, 재진입 복구 | +| 3 | opencode-ca workspace + compose + `OPENCODE_BASE_URL` | curl 로 세션 생성·프롬프트 | +| 4 | 스트림 어댑터 + cancel + isGenerating | Snap 스트리밍·중단·재진입 복구 | | 5 | 이미지 파트 + `explain` | 캡처→질문 | -| 6 | 프론트 토큰 저장 2줄 + `.env.production` + CORS + 배포 스크립트 | Tauri 빌드에서 end-to-end | - -각 단계 전 `docs-lib/` 에 OpenCode 서버 API 문서(세션·메시지 parts·event 스키마) 떨어뜨리고 보면서 짬. 지금 두 repo 다 그 문서 없음 — 1단계 준비물. +| 6 | 프론트 토큰 2줄 + `.env.production` + CORS | Tauri 빌드 end-to-end | diff --git a/specs/006-opencode-django-backend/stage-1.md b/specs/006-opencode-django-backend/stage-1.md new file mode 100644 index 0000000..6753f82 --- /dev/null +++ b/specs/006-opencode-django-backend/stage-1.md @@ -0,0 +1,32 @@ +# stage-1 — 백엔드 OpenCode + Django 전환 (2026-09-16) + +분석·결정은 `docs/tech/opencode-django-backend.md`. 배포는 사용자 지시로 이번 범위 밖. + +## 한 일 + +| # | 내용 | 검증 | +|---|---|---| +| 1 | `../ABAP_CODE_ASSISTANT_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 | +| 5 | `opencode/` workspace (AGENTS.md·codeassist 에이전트·opencode.json) | 실서버에서 agent 로드 확인 | +| 6 | `/event` 버스 + `POST /chat/stream` 어댑터 (prompt_async → token/title/usage/done/error, 클라 끊겨도 DB 마무리, 첫 이벤트 60초 타임아웃, 기본 제목 무시) | test_stream 9 + 실서버 error 경로 | +| 7 | 프론트 Bearer 모드 — tokenProvider 영속, refresh body, 로그인 저장/로그아웃 삭제 | vitest 225, build | + +백엔드 pytest 30 / 프론트 vitest 225 / `npm run build` 통과. + +## 실서버 스모크에서 나온 것 + +- OpenCode 1.18 은 멀티 프로젝트 서버 → 모든 요청에 `?directory=` 필요 (`OPENCODE_DIRECTORY`). 안 붙이면 세션이 딴 프로젝트에 생기고 prompt 가 조용히 실패. +- 이 Mac 의 `OPENCODE_CONFIG_DIR`(orca) 가 workspace config 를 덮음 → `opencode/README.md` gotcha. +- `session.error` → `error` 프레임 1초 내 도착, `isGenerating` 해제 확인. +- ABAP_OPENCODE `web/BE/code/.env` 의 OpenRouter 키로 실제 답변 확인: 글자 단위는 `message.part.delta`(SDK 타입엔 없음) 로 옴 → 어댑터 반영. 제목은 idle 뒤에 오기도 함 → done 은 먼저 보내고 20초 더 기다려 미러에만 반영. token 90·title·usage·done 확인. +- 맥에서 Tauri 앱 기동: `windows` crate 를 `cfg(windows)` 로, paste/capture 는 맥용 `_stub.rs`. cargo test 8 통과, `tauri dev` 로 창 뜸. + +## 다음 + +- [x] 실제 답변 스트림 확인 (token/title/usage/done, 미러 저장). 이미지 파트는 실서버 미확인 +- [ ] 앱에서 손으로 확인 — 로그인·채팅·재진입 복구·중단·이미지 첨부 +- [ ] Tauri 빌드에서 end-to-end (풀 URL `VITE_API_BASE_URL`, CORS) +- [ ] 배포 (compose/bare) — 사용자 지시로 보류 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 1b7a1c2..98b0fac 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 @@ -3,3 +3,17 @@ | 시간 | 내용 | |------|------| | 17:21 | CodeAssist 백엔드를 OpenCode+Django 로 가는 분석 — 프론트 계약(envelope·쿠키·/chat/stream SSE) vs ABAP_OPENCODE Django(JWT·전역 SSE·OpenCode 미러 없음) 차이표 뽑고, Django 에 어댑터 앱 + OpenCode 컨테이너 분리 안으로 docs/tech/opencode-django-backend.md 작성 | +| 17:29 | OpenCode+Django 결정 5개 확정 반영 — Django 새로(5_django_backend 제안, web/BE 에서 accounts·opencode_service·deploy 복사), 토큰 localStorage, Entra 501 고정, OpenCode 분리 인스턴스, 스니펫 로컬 유지. 단계표 0~6 갱신 | +| 17:38 | 5_django_backend 뼈대 — ABAP_OPENCODE web/BE 에서 settings·accounts·opencode_service 복사해 CodeAssist 용으로 다듬음. envelope renderer/예외 핸들러(CommonResponse), 이메일 로그인·refresh(body)·logout·me·entra 501. pytest 13개 통과 | +| 17:44 | 세션 미러(ChatSession/ChatMessage) + 목록·생성·검색·메시지·취소 API, OpenCode /event 버스, POST /chat/stream 어댑터(prompt_async → part delta→token, session.updated→title, idle 때 tokens/cost→usage→done, 에러 시 부분답변 저장·isGenerating 해제, 클라 끊겨도 턴 감시 태스크가 DB 마무리). opencode/ workspace(AGENTS.md·agent·opencode.json). pytest 29개 통과 | +| 17:55 | 프론트 Bearer 모드 — tokenProvider 에 refresh 토큰·localStorage 영속, client/sse refresh 를 body {refreshToken} 로, 로그인 저장·로그아웃 삭제. vitest 225 통과·build OK | +| 17:55 | 진짜 OpenCode 1.18.6 붙여 스모크 — (1) 서버가 멀티 프로젝트라 요청마다 ?directory= 안 붙이면 세션이 ABAP_OPENCODE 프로젝트에 생기고 prompt 가 조용히 실패 → OPENCODE_DIRECTORY 전 요청에 부착. (2) 이 Mac 의 OPENCODE_CONFIG_DIR(orca) 가 workspace config 덮음 → README gotcha. (3) 첫 이벤트 60초 무응답이면 error 로 끝냄. 기본 제목 'New session -' 무시. session.error→error 프레임 1초 내 확인, isGenerating 해제 확인. 실제 답변은 유효한 LLM 키 없어 미확인 | +| 18:05 | Tauri 앱 맥에서 돌리기 — rustup 설치, windows crate 를 cfg(windows) 타깃 의존성으로, paste/capture 를 cfg 분기 + 맥용 _stub 껍데기(붙여넣기·캡처는 Windows 전용 안내), hwnd 접근을 window::native_handle 로 모음 | +| 18:08 | 맥에서 cargo check·test 통과(8개), tauri dev 로 앱 실제 기동 확인(프로세스·Vite·Django 헬스). 핫키 3개 등록 에러 없음 | +| 18:13 | ABAP_OPENCODE 의 OpenRouter 키로 OpenCode 띄워 실제 답변 스트림 확인 — 실서버는 글자 단위가 message.part.delta(SDK 타입엔 없음, 파트 타입은 part.updated 로 알아둠) 로 오고 제목은 idle 뒤에 오기도 해서 둘 다 어댑터에 반영. token 90개·title·usage·done, 미러에 토큰·비용·제목 저장 확인. pytest 32 | +| 19:53 | 프론트를 ABAP_OPENCODE 디자인(Dark Workspace)에 맞춤 — DESIGN_SYSTEM.md 라이트/다크 토큰을 shadcn 토큰으로 이식(다크 기본, radius 8px, success/warning/shadow-float 추가), 시스템 산세리프로 서체 통일, PwC 로고(LogoMark) 로그인·헤더·목록·히어로에, 세리프 이탤릭·S 타일·Snap Mate 문구 제거, 세션 카드에 항상 border, 스크롤바·선택색 토큰화. detect 0건 | +| 20:04 | 백엔드 폴더가 repo 밖 ABAP_CODE_ASSISTANT_BACKEND/ 로 옮겨져 있어 거기서 venv 마저 설치·migrate 후 Django·OpenCode 재기동 — 앱 500 원인은 Django 다운(Vite 프록시 ECONNREFUSED). 문서 경로 갱신 | +| 20:10 | 대화 목록 화면 재구성 — 검색줄을 명령줄처럼(오른쪽에 새 대화), 목록은 테두리 블록 하나에 40px 행·옅은 구분선, 테마·로그아웃은 떠 있는 상자 대신 상단 바(웹 헤더/Tauri 제목줄)로, 키 안내 한 줄. 검색 결과에서 코드펜스 기호 제거. Playwright 로 다크/라이트/검색 스크린샷 확인 | +| 20:23 | 로그인(Tauri 중복 헤더 제거·가운데 정렬)과 새 대화 화면 재구성 — 목록과 같은 760px 열, 빈 레일 제거, 로고·제목·한 줄 설명·예시 질문 칩 3개(누르면 바로 시작), 입력창을 테두리 상자 하나에 도구줄(고스트 버튼)로 축소, 대화 헤더 뒤로가기도 같은 스타일 | +| 20:27 | 대화 화면 입력창을 본문 열 안으로 — 레일 오른쪽 열에 메시지·입력창을 같이 묶어 같은 폭(760px)·같은 왼쪽 끝. 전체 폭 기준 가운데라 본문과 어긋나던 것 해소 | +| 20:43 | 대화 말풍선 흰 배경 제거 — 메시지 영역의 theme-light 강제 해제, AI 답변은 상자 없이 본문, 질문은 연한 칩. ABAP 흰 에디터 룩 코드블럭은 라이트 모드에서만(themeStore.resolved), 다크는 어두운 코드 테마. 스니펫 미리보기도 같은 규칙 |