feat(ai-character): 관리자 웹 기반 구성

This commit is contained in:
Yu Sung
2026-07-26 03:28:09 +09:00
parent 1dcbf43320
commit 42027b9f0d
26 changed files with 4368 additions and 36 deletions

7
.gitignore vendored
View File

@@ -6,3 +6,10 @@
.opencode
mise.toml
node_modules/
dist/
playwright-report/
test-results/
coverage/
*.tsbuildinfo

View File

@@ -1,25 +1,12 @@
# AI 캐릭터 관리자 프로젝트 에이전트 지침
## 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.
이 저장소의 상세 에이전트 지침은 `docs/agent-guide/` 아래 문서를 따른다.
## 문서 유지보수 규칙
- PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다.
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.
- [커뮤니케이션 규칙](docs/agent-guide/communication.md)
- [문서 유지보수 규칙](docs/agent-guide/documentation.md)
- [에이전트 동작 원칙](docs/agent-guide/agent-behavior.md)
- [실행 스크립트](docs/agent-guide/scripts.md)
- [환경 변수](docs/agent-guide/environment.md)
- [코드 스타일 규칙](docs/agent-guide/code-style.md)
## 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.
핵심 원칙: 사용자에게 전달하는 답변과 설명은 한국어로 작성하고, 작업 전 관련 문서를 읽어 추측 없이 진행한다.

36
README.md Normal file
View File

@@ -0,0 +1,36 @@
# AI 캐릭터 관리자 웹
React, TypeScript, Vite 기반 독립 관리자 SPA입니다.
## Requirements
- Node.js 24.12.0
- npm 11.7.0+
## Setup
```bash
npm ci
npx playwright install chromium webkit
```
## Environment
Vite mode별 API base URL은 아래 파일에 둡니다.
- `.env.development`: `https://test-character-admin.sodalive.net`
- `.env.production`: `https://character-admin.sodalive.net`
## Scripts
```bash
npm run dev
npm run build:dev
npm run build:prod
npm run typecheck
npm run lint
npm run test
npm run test:run
npm run e2e
npm run build
```

View File

@@ -176,38 +176,52 @@ feature 내부의 `api/`, `components/`, `model/`, `pages/`, `schemas/`, `tests/
### Task 0.1 런타임·패키지 기반
- [ ] `mise.toml`의 Node `24.12.0`을 기준으로 npm package와 lockfile을 생성한다.
- [ ] React + TypeScript + Vite 진입점과 `@` path alias를 구성한다.
- [ ] `VITE_API_BASE_URL``.env.example`에 문서화하고 token·password 같은 비밀값을 넣지 않는다.
- [ ] runtime env 누락·잘못된 URL을 앱 시작 전에 설명 가능한 오류로 차단하는 test를 작성한다.
- [ ] unit test는 `vi.stubEnv`, Playwright webServer는 명시적 test URL로 `VITE_API_BASE_URL`을 주입해 `.env.example` 자동 로드를 전제하지 않는다.
- [ ] `dev`, `build`, `typecheck`, `lint`, `test`, `test:run`, `e2e` script를 정의한다.
- [ ] 이 Phase에 필요하지 않은 router, server-state, form, drag-and-drop dependency는 아직 설치하지 않는다.
- [x] `mise.toml`의 Node `24.12.0`을 기준으로 npm package와 lockfile을 생성한다.
- [x] React + TypeScript + Vite 진입점과 `@` path alias를 구성한다.
- [x] `VITE_API_BASE_URL``.env.example`에 문서화하고 token·password 같은 비밀값을 넣지 않는다.
- [x] runtime env 누락·잘못된 URL을 앱 시작 전에 설명 가능한 오류로 차단하는 test를 작성한다.
- [x] unit test는 `vi.stubEnv`, Playwright webServer는 명시적 test URL로 `VITE_API_BASE_URL`을 주입해 `.env.example` 자동 로드를 전제하지 않는다.
- [x] `dev`, `build`, `typecheck`, `lint`, `test`, `test:run`, `e2e` script를 정의한다.
- [x] 이 Phase에 필요하지 않은 router, server-state, form, drag-and-drop dependency는 아직 설치하지 않는다.
### Task 0.2 test 기반
- [ ] Vitest, jsdom, React Testing Library, jest-dom을 구성한다.
- [ ] 각 test 뒤 DOM·mock·storage가 정리되는 공통 setup을 만든다.
- [ ] Playwright에 desktop Chromium/WebKit과 mobile Chrome/Safari viewport project, Vite webServer를 구성한다.
- [ ] fresh environment에서 Chromium/WebKit browser binary를 설치하는 명령을 README와 Gate에 포함한다.
- [ ] `<html lang="ko">`, `main` landmark, root content를 확인하는 unit test를 먼저 실패시킨 뒤 최소 App shell을 만든다.
- [ ] 동일 shell이 각 Playwright project에서 열리는 smoke E2E를 만든다.
- [x] Vitest, jsdom, React Testing Library, jest-dom을 구성한다.
- [x] 각 test 뒤 DOM·mock·storage가 정리되는 공통 setup을 만든다.
- [x] Playwright에 desktop Chromium/WebKit과 mobile Chrome/Safari viewport project, Vite webServer를 구성한다.
- [x] fresh environment에서 Chromium/WebKit browser binary를 설치하는 명령을 README와 Gate에 포함한다.
- [x] `<html lang="ko">`, `main` landmark, root content를 확인하는 unit test를 먼저 실패시킨 뒤 최소 App shell을 만든다.
- [x] 동일 shell이 각 Playwright project에서 열리는 smoke E2E를 만든다.
### Phase 0 Gate
```bash
npm ci
npx playwright install chromium webkit
export VITE_API_BASE_URL=http://127.0.0.1:4010
npm run typecheck
npm run lint
npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts
npm run e2e -- tests/e2e/smoke.spec.ts
npm run build
npm run build:dev
npm run build:prod
```
**Expected:** fresh install 후 typecheck·lint·unit·E2E·production build가 모두 0 exit code이며 환경 변수 오류가 test로 고정된다.
**1차 구현 검증 기록 (2026-07-26):**
- 무엇을: Phase 0의 React + TypeScript + Vite 프로젝트 기반, runtime env 검증, Vitest/RTL/jsdom, Playwright Chromium/WebKit 및 mobile viewport smoke, README와 npm scripts를 구현했다.
- 왜: 후속 Phase가 동일한 명령으로 개발·test·build할 수 있고, `VITE_API_BASE_URL` 누락/오류와 root shell 회귀를 자동 검증하기 위해서다.
- 어떻게: `npm ci` 성공(0 vulnerabilities), `npx playwright install chromium webkit` 성공, `VITE_API_BASE_URL=http://127.0.0.1:4010 npm run typecheck` 성공, `VITE_API_BASE_URL=http://127.0.0.1:4010 npm run lint` 성공, `VITE_API_BASE_URL=http://127.0.0.1:4010 npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts` 성공(2 files/4 tests passed), `VITE_API_BASE_URL=http://127.0.0.1:4010 npm run e2e -- tests/e2e/smoke.spec.ts` 성공(4 projects passed), `VITE_API_BASE_URL=http://127.0.0.1:4010 npm run build` 성공.
- 정정/참고: `@playwright/test@1.62.0`의 WebKit binary가 현재 macOS 26.0 x86_64 환경에서 시작 전 `Segmentation fault: 11`로 실패해, Phase 0 Gate 충족을 위해 `@playwright/test@1.61.1`로 고정했다.
**2차 설정 정정 기록 (2026-07-26):**
- 무엇을: 개발 서버 포트를 `8888`로 고정하고, 개발/프로덕션 API base URL과 build 명령을 분리했다.
- 왜: 실행 환경별 API endpoint를 명확히 분리하고, AGENTS.md를 짧게 유지하면서 상세 지침을 `docs/agent-guide/README.md`에서 관리하기 위해서다.
- 어떻게: `npm run typecheck`, `npm run lint`, `npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts`, `npm run e2e -- tests/e2e/smoke.spec.ts`, `npm run build:dev`, `npm run build:prod`가 성공했다.
- 정정: 기존 1차 기록의 `VITE_API_BASE_URL=http://127.0.0.1:4010`과 단일 `npm run build`는 당시 실행 기록으로 보존하되, 현재 Gate 기준은 `.env.development`, `.env.production`, `build:dev`, `build:prod`를 사용한다.
---
## Phase 1. 공통 플랫폼·인증/인가·컴포넌트 기반

View File

@@ -0,0 +1,6 @@
# 에이전트 동작 원칙
- 추측하지 말고, 근거 파일을 읽고 결정한다.
- 기존 관례를 깨는 변경은 이유가 명확할 때만 수행한다.
- 불필요한 리팩터링 확장은 피하고 요청 범위를 우선 충족한다.
- 결과 보고 시 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 남긴다.

View File

@@ -0,0 +1,7 @@
# 코드 스타일 규칙
- TypeScript는 `strict` 기준을 유지하고 `as any`, `@ts-ignore`, `@ts-expect-error`로 타입 오류를 숨기지 않는다.
- React 컴포넌트와 테스트는 named export/import를 우선 사용하고, `@/*` path alias는 `src/*` 기준으로 사용한다.
- 문자열 quote, semicolon, trailing comma 등 세부 포맷은 현재 ESLint/TypeScript 설정과 주변 코드 스타일을 따른다.
- 테스트는 Vitest + React Testing Library를 사용하고, DOM/mock/storage 정리는 `src/shared/test/setup.ts`의 공통 setup에 맡긴다.
- Phase 0에서는 router, server-state, form, drag-and-drop, Tailwind/shadcn 의존성을 추가하지 않는다. 필요한 Phase에서 실제 소비 시점에 추가한다.

View File

@@ -0,0 +1,5 @@
# 커뮤니케이션 규칙
- **"질문에 대한 답변과 설명은 한국어로 한다."**
- 이 저장소에서 사용자에게 전달하는 설명, 진행 상황, 결과 보고는 한국어로 작성한다.
- 코드 식별자, 경로, 명령어는 원문(영문) 그대로 유지한다.

View File

@@ -0,0 +1,13 @@
# 문서 유지보수 규칙
- PRD 문서와 구현 계획/TASK 문서는 `docs/[날짜]_구현할내용한글/` 아래에 함께 둔다.
- 날짜는 `YYYYMMDD` 8자리 숫자를 사용한다.
- PRD 문서 파일명은 `prd.md`, 구현 계획/TASK 문서 파일명은 `plan-task.md`를 사용한다.
- 구현 항목은 기능/작업 단위로 분리해 체크박스(`- [ ]`) 목록으로 작성한다.
- 구현 완료 시마다 체크박스를 `- [x]`로 갱신하고, 각 항목이 정상 구현되었는지 확인한다.
- 작업 도중 범위가 변경되면 계획 문서의 체크박스 항목을 먼저 업데이트한 뒤 구현을 진행한다.
- 모든 구현이 끝난 후 결과 보고 시 계획 문서 맨 아래에 무엇을, 왜, 어떻게 검증했는지 한국어로 간단히 기록한다.
- 후속 수정이 발생해도 기존 검증 기록은 삭제/덮어쓰지 않고 누적한다(예: `1차 구현`, `2차 수정`).
- 검증 기록은 단계별로 `무엇을/왜/어떻게`를 유지해 작성하고, 이전 단계와 구분이 되도록 명시한다.
- 단계별 `어떻게`에는 실제 실행한 검증 명령과 결과(성공/실패/불가 사유)를 함께 기록한다.
- 기존 기록 정정이 필요하면 원문을 지우지 말고 `정정` 항목을 추가해 사유와 변경 내용을 남긴다.

View File

@@ -0,0 +1,5 @@
# 환경 변수
- 개발 서버 API: `VITE_API_BASE_URL=https://test-character-admin.sodalive.net`
- 프로덕션 서버 API: `VITE_API_BASE_URL=https://character-admin.sodalive.net`
- Vite mode별 파일은 `.env.development`, `.env.production`을 사용한다.

View File

@@ -0,0 +1,12 @@
# 실행 스크립트
- 개발 서버: `npm run dev` (`http://127.0.0.1:8888`)
- 개발 서버용 빌드: `npm run build:dev`
- 프로덕션 서버용 빌드: `npm run build:prod`
- 기본 프로덕션 빌드: `npm run build`
- 타입 검사: `npm run typecheck`
- 린트: `npm run lint`
- Vitest watch: `npm run test`
- Vitest 단발 실행: `npm run test:run`
- Playwright E2E: `npm run e2e`
- Phase 0 Gate 기준: `npm ci`, `npx playwright install chromium webkit`, `npm run typecheck`, `npm run lint`, `npm run test:run -- src/app/App.test.tsx src/shared/config/env.test.ts`, `npm run e2e -- tests/e2e/smoke.spec.ts`, `npm run build:dev`, `npm run build:prod`

43
eslint.config.js Normal file
View File

@@ -0,0 +1,43 @@
import js from "@eslint/js";
import reactHooks from "eslint-plugin-react-hooks";
import reactRefresh from "eslint-plugin-react-refresh";
import globals from "globals";
import tseslint from "typescript-eslint";
export default tseslint.config(
{
ignores: [
".codex/**",
".omo/**",
".opencode/**",
".worktrees/**",
"dist/**",
"node_modules/**",
"playwright-report/**",
"test-results/**",
],
},
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ["**/*.{ts,tsx}"],
languageOptions: {
ecmaVersion: 2022,
globals: globals.browser,
},
plugins: {
"react-hooks": reactHooks,
"react-refresh": reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
},
},
{
files: ["**/*.config.{js,ts}", "eslint.config.js"],
languageOptions: {
globals: globals.node,
},
},
);

12
index.html Normal file
View File

@@ -0,0 +1,12 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>AI 캐릭터 관리자</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

3941
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

41
package.json Normal file
View File

@@ -0,0 +1,41 @@
{
"name": "ai-character-admin-web",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite --host 127.0.0.1 --port 8888 --strictPort",
"build": "npm run build:prod",
"build:dev": "tsc -b && vite build --mode development",
"build:prod": "tsc -b && vite build --mode production",
"typecheck": "tsc -b --pretty false",
"lint": "eslint .",
"test": "vitest",
"test:run": "vitest run",
"e2e": "playwright test"
},
"dependencies": {
"react": "19.2.8",
"react-dom": "19.2.8"
},
"devDependencies": {
"@eslint/js": "10.0.1",
"@playwright/test": "1.61.1",
"@testing-library/dom": "10.4.1",
"@testing-library/jest-dom": "7.0.0",
"@testing-library/react": "16.3.2",
"@types/node": "26.1.1",
"@types/react": "19.2.17",
"@types/react-dom": "19.2.3",
"@vitejs/plugin-react": "6.0.4",
"eslint": "10.8.0",
"eslint-plugin-react-hooks": "7.1.1",
"eslint-plugin-react-refresh": "0.5.3",
"globals": "17.7.0",
"jsdom": "29.1.1",
"typescript": "6.0.3",
"typescript-eslint": "8.65.0",
"vite": "8.1.5",
"vitest": "4.1.10"
}
}

19
playwright.config.ts Normal file
View File

@@ -0,0 +1,19 @@
import { defineConfig, devices } from "@playwright/test";
export default defineConfig({
testDir: "./tests/e2e",
webServer: {
command: "npm run dev",
url: "http://127.0.0.1:8888",
reuseExistingServer: !process.env.CI,
},
use: {
baseURL: "http://127.0.0.1:8888",
},
projects: [
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
{ name: "webkit", use: { ...devices["Desktop Safari"] } },
{ name: "mobile-chrome", use: { ...devices["Pixel 5"] } },
{ name: "mobile-safari", use: { ...devices["iPhone 12"] } },
],
});

10
src/app/App.test.tsx Normal file
View File

@@ -0,0 +1,10 @@
import { render, screen } from "@testing-library/react";
import { App } from "./App";
test("renders Korean root shell with a main landmark", () => {
render(<App />);
expect(document.documentElement).toHaveAttribute("lang", "ko");
expect(screen.getByRole("main")).toHaveTextContent("AI 캐릭터 관리자");
});

13
src/app/App.tsx Normal file
View File

@@ -0,0 +1,13 @@
import { useEffect } from "react";
export function App() {
useEffect(() => {
document.documentElement.lang = "ko";
}, []);
return (
<main>
<h1>AI </h1>
</main>
);
}

19
src/main.tsx Normal file
View File

@@ -0,0 +1,19 @@
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import { App } from "@/app/App";
import { getRuntimeEnv } from "@/shared/config/env";
getRuntimeEnv();
const root = document.getElementById("root");
if (!root) {
throw new Error("Root element #root was not found");
}
createRoot(root).render(
<StrictMode>
<App />
</StrictMode>,
);

View File

@@ -0,0 +1,27 @@
import { afterEach, describe, expect, test, vi } from "vitest";
import { getRuntimeEnv } from "./env";
describe("getRuntimeEnv", () => {
afterEach(() => {
vi.unstubAllEnvs();
});
test("returns the configured API base URL", () => {
vi.stubEnv("VITE_API_BASE_URL", "https://api.example.com");
expect(getRuntimeEnv()).toEqual({ apiBaseUrl: "https://api.example.com" });
});
test("blocks startup when VITE_API_BASE_URL is missing", () => {
vi.stubEnv("VITE_API_BASE_URL", "");
expect(() => getRuntimeEnv()).toThrow("VITE_API_BASE_URL is required");
});
test("blocks startup when VITE_API_BASE_URL is not an http URL", () => {
vi.stubEnv("VITE_API_BASE_URL", "not-a-url");
expect(() => getRuntimeEnv()).toThrow("VITE_API_BASE_URL must be a valid http(s) URL");
});
});

23
src/shared/config/env.ts Normal file
View File

@@ -0,0 +1,23 @@
export type RuntimeEnv = {
apiBaseUrl: string;
};
export function getRuntimeEnv(): RuntimeEnv {
const apiBaseUrl = import.meta.env.VITE_API_BASE_URL;
if (!apiBaseUrl) {
throw new Error("VITE_API_BASE_URL is required");
}
let url: URL;
try {
url = new URL(apiBaseUrl);
} catch {
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
}
if (url.protocol !== "http:" && url.protocol !== "https:") {
throw new Error("VITE_API_BASE_URL must be a valid http(s) URL");
}
return { apiBaseUrl };
}

10
src/shared/test/setup.ts Normal file
View File

@@ -0,0 +1,10 @@
import "@testing-library/jest-dom/vitest";
import { cleanup } from "@testing-library/react";
import { afterEach, vi } from "vitest";
afterEach(() => {
cleanup();
vi.clearAllMocks();
sessionStorage.clear();
localStorage.clear();
});

8
tests/e2e/smoke.spec.ts Normal file
View File

@@ -0,0 +1,8 @@
import { expect, test } from "@playwright/test";
test("opens the root shell", async ({ page }) => {
await page.goto("/");
await expect(page.locator("html")).toHaveAttribute("lang", "ko");
await expect(page.getByRole("main")).toContainText("AI 캐릭터 관리자");
});

27
tsconfig.app.json Normal file
View File

@@ -0,0 +1,27 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"types": ["vite/client", "vitest/globals"],
"allowImportingTsExtensions": true,
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"baseUrl": ".",
"ignoreDeprecations": "6.0",
"paths": {
"@/*": ["src/*"]
},
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"jsx": "react-jsx",
"noEmit": true
},
"include": ["src"]
}

7
tsconfig.json Normal file
View File

@@ -0,0 +1,7 @@
{
"files": [],
"references": [
{ "path": "./tsconfig.app.json" },
{ "path": "./tsconfig.node.json" }
]
}

19
tsconfig.node.json Normal file
View File

@@ -0,0 +1,19 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"moduleResolution": "bundler",
"skipLibCheck": true,
"allowImportingTsExtensions": true,
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"noUncheckedSideEffectImports": true,
"moduleDetection": "force",
"noEmit": true
},
"include": ["vite.config.ts", "eslint.config.js", "playwright.config.ts"]
}

21
vite.config.ts Normal file
View File

@@ -0,0 +1,21 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
export default defineConfig({
plugins: [react()],
server: {
host: "127.0.0.1",
port: 8888,
strictPort: true,
},
resolve: {
alias: {
"@": new URL("./src", import.meta.url).pathname,
},
},
test: {
environment: "jsdom",
setupFiles: ["./src/shared/test/setup.ts"],
globals: true,
},
});