diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c7b51a22..95e3aaac 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -62,3 +62,34 @@ jobs: update-comment: true min-coverage-overall: 0 min-coverage-changed-files: 0 + + # 백오피스(admin-web) 린트·타입체크·빌드. 이게 없으면 프론트 타입 에러가 + # dev 머지 후 이미지 빌드(build-admin)에서야 드러난다. 그때는 백엔드 이미지만 + # GHCR에 올라간 채 deploy 가 통째로 막혀서, 백엔드만 고친 사람의 변경도 같이 묶인다. + # 경로 필터 없이 항상 돌린다 - 1분 남짓으로 저렴하고, + # 조건부 스킵은 브랜치 보호 필수 체크와의 조합을 복잡하게 만든다. + admin-web: + runs-on: ubuntu-latest + defaults: + run: + working-directory: admin-web + steps: + - name: Checkout + uses: actions/checkout@v5 + + - name: Set up Node 22 + uses: actions/setup-node@v5 + with: + # admin-web/Dockerfile(node:22-alpine)과 맞춘다. + node-version: "22" + cache: npm + cache-dependency-path: admin-web/package-lock.json + + - name: Install + run: npm ci + + - name: Lint (oxlint) + run: npm run lint + + - name: Typecheck & Build + run: npm run build diff --git a/admin-web/src/App.tsx b/admin-web/src/App.tsx index 10154c7b..4f1770d5 100644 --- a/admin-web/src/App.tsx +++ b/admin-web/src/App.tsx @@ -11,6 +11,7 @@ import { MemberList } from '@/pages/members/list' import { MemberShow } from '@/pages/members/show' import { AdminAccountList } from '@/pages/admin-accounts/list' import { LlmSettingsPage } from '@/pages/llm-settings' +import { PromptPlaygroundPage } from '@/pages/prompt-playground' import { TokenUsagePage } from '@/pages/token-usage' // mock(로컬) 모드에서는 Firebase 없이 dev-login 을 쓴다. @@ -44,6 +45,7 @@ export default function App() { } /> } /> } /> + } /> } /> diff --git a/admin-web/src/components/layout/AdminLayout.tsx b/admin-web/src/components/layout/AdminLayout.tsx index d8b03c88..8c943f27 100644 --- a/admin-web/src/components/layout/AdminLayout.tsx +++ b/admin-web/src/components/layout/AdminLayout.tsx @@ -1,6 +1,6 @@ import { useGetIdentity, useLogout } from '@refinedev/core' import { Link, Outlet, useLocation } from 'react-router-dom' -import { Coins, LayoutDashboard, LogOut, ShieldCheck, Sparkles, Users } from 'lucide-react' +import { Coins, FlaskConical, LayoutDashboard, LogOut, ShieldCheck, Sparkles, Users } from 'lucide-react' import { Avatar } from '@/components/ui/avatar' import { Button } from '@/components/ui/button' import { cn } from '@/lib/utils' @@ -20,6 +20,7 @@ const NAV: NavItem[] = [ { label: '관리자 관리', to: '/admin-accounts', icon: ShieldCheck }, { label: '토큰 사용량', to: '/token-usage', icon: Coins }, { label: 'LLM 설정', to: '/llm-settings', icon: Sparkles }, + { label: '프롬프트 실험실', to: '/playground', icon: FlaskConical }, ] interface Identity { @@ -44,6 +45,9 @@ function sectionOf(pathname: string): string { if (pathname.startsWith('/token-usage')) { return '토큰 사용량' } + if (pathname.startsWith('/playground')) { + return '프롬프트 실험실' + } return '' } diff --git a/admin-web/src/lib/api.ts b/admin-web/src/lib/api.ts index af6eb4cd..173067e2 100644 --- a/admin-web/src/lib/api.ts +++ b/admin-web/src/lib/api.ts @@ -8,6 +8,22 @@ const TOKEN_KEY = 'gamss-admin-token' const MOCK_MODE = import.meta.env.VITE_AUTH_MODE === 'mock' const DEV_ADMIN_EMAIL: string = import.meta.env.VITE_DEV_ADMIN_EMAIL ?? '' +/** + * API 실패 응답. message는 기존 호출부와의 호환을 위해 분기용 코드를 유지하고, + * 사람이 읽을 서버 메시지는 detail에 담는다. + */ +export class ApiError extends Error { + readonly code: string + readonly detail?: string + + constructor(code: string, detail?: string) { + super(code) + this.name = 'ApiError' + this.code = code + this.detail = detail + } +} + export function getStoredToken(): string | null { return localStorage.getItem(TOKEN_KEY) } @@ -96,7 +112,7 @@ export async function apiFetch(path: string, init: RequestInit = {}): Promise const body = await res.json().catch(() => null) if (!res.ok || !body?.success) { - throw new Error(body?.error?.code ?? `HTTP_${res.status}`) + throw new ApiError(body?.error?.code ?? `HTTP_${res.status}`, body?.error?.message) } return body.data as T } diff --git a/admin-web/src/pages/llm-settings.tsx b/admin-web/src/pages/llm-settings.tsx index 17e90646..776557d2 100644 --- a/admin-web/src/pages/llm-settings.tsx +++ b/admin-web/src/pages/llm-settings.tsx @@ -7,15 +7,17 @@ import { Select } from '@/components/ui/select' import { Skeleton } from '@/components/ui/skeleton' import { Textarea } from '@/components/ui/textarea' import { PageHeader } from '@/components/page-header' +import { PromptRevisionHistory } from '@/pages/prompt-revision-history' import { cn } from '@/lib/utils' -type PromptType = 'COMMON' | 'COMMENT' | 'REPLY' | 'CARD' +type PromptType = 'COMMON' | 'COMMENT' | 'REPLY' | 'CARD' | 'EONGTTUNG_TOPIC' const TABS: { value: PromptType; label: string; hint: string }[] = [ { value: 'COMMON', label: '공통', hint: '세 타입이 공유하는 톤·경계·말맛지침·보이스카드. 여기를 바꾸면 댓글·답글·카드에 모두 반영됩니다.' }, { value: 'COMMENT', label: '댓글', hint: '여러 감정 캐릭터가 일기에 코멘트를 달고 서로 티키타카하는 생성.' }, { value: 'REPLY', label: '답글', hint: '유저가 캐릭터 댓글에 답글을 달면 그 캐릭터 1명이 재응답하는 생성.' }, { value: 'CARD', label: '카드', hint: '대화 종료 시 대표 캐릭터가 유저를 대신해 남기는 한 줄 카드 대사.' }, + { value: 'EONGTTUNG_TOPIC', label: '엉뚱이 소재', hint: '엉뚱이가 꺼낼 소재 목록. 한 줄에 하나씩 적으면 생성 시 서버가 무작위로 한 줄을 고릅니다.' }, ] function SavedFlash({ show }: { show: boolean }) { @@ -140,7 +142,6 @@ function ModelSection() { interface PromptSetting { promptType: PromptType systemPrompt: string - defaultSystemPrompt: string } function PromptSection() { @@ -192,14 +193,16 @@ function PromptSection() { return (
-
- - - 실제 시스템 프롬프트는 공통 +{' '} - 타입(댓글·답글·카드) 으로 조립됩니다. 캐릭터 성격 등 - 공통 부분은 공통 탭에서 한 번에 바꾸세요. - -
+ {type !== 'EONGTTUNG_TOPIC' && ( +
+ + + 실제 시스템 프롬프트는 공통 +{' '} + 타입(댓글·답글·카드) 으로 조립됩니다. 캐릭터 성격 등 + 공통 부분은 공통 탭에서 한 번에 바꾸세요. + +
+ )}
{TABS.map((tab) => ( @@ -237,7 +240,7 @@ function PromptSection() {
{prompt.length.toLocaleString()}자
@@ -252,18 +255,11 @@ function PromptSection() {

{type === 'COMMON' ? '캐릭터 보이스카드·말맛지침 등 세 타입이 공유하는 부분입니다. 신중히 수정하세요.' - : '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.'} + : type === 'EONGTTUNG_TOPIC' + ? '빈 줄은 무시됩니다. 목록을 전부 비우면 저장할 수 없습니다.' + : '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.'}

-
)} + + {ready && ( + setPrompt(content)} + onRestored={(content) => { + setPrompt(content) + setSavedPrompt(content) + }} + /> + )}
) } diff --git a/admin-web/src/pages/prompt-playground.tsx b/admin-web/src/pages/prompt-playground.tsx new file mode 100644 index 00000000..f695ed65 --- /dev/null +++ b/admin-web/src/pages/prompt-playground.tsx @@ -0,0 +1,756 @@ +import { useRef, useState } from 'react' +import { useCustom, useCustomMutation } from '@refinedev/core' +import { + AlertTriangle, + ChevronDown, + ChevronRight, + CircleDollarSign, + Clock, + CornerDownRight, + Cpu, + Download, + FlaskConical, + Play, + Reply, + Send, + Sigma, + X, +} from 'lucide-react' +import type { PreviewResult, ReplyPreviewResult } from '@/types/promptPreview' +import { ApiError } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Card } from '@/components/ui/card' +import { Input } from '@/components/ui/input' +import { Select } from '@/components/ui/select' +import { Textarea } from '@/components/ui/textarea' +import { PageHeader } from '@/components/page-header' +import { cn } from '@/lib/utils' + +type Emotion = 'JOY' | 'WARM' | 'ANGER' | 'ANXIETY' | 'GRUMPY' | 'QUIRKY' + +const CHARACTERS: { value: Emotion; label: string; emoji: string }[] = [ + { value: 'JOY', label: '기쁨', emoji: '😊' }, + { value: 'WARM', label: '다정', emoji: '🥰' }, + { value: 'ANGER', label: '분노', emoji: '😡' }, + { value: 'ANXIETY', label: '불안', emoji: '😰' }, + { value: 'GRUMPY', label: '까칠', emoji: '😤' }, + { value: 'QUIRKY', label: '엉뚱', emoji: '🤪' }, +] + +const characterOf = (value: string) => CHARACTERS.find((c) => c.value === value) +const labelOf = (value: string) => characterOf(value)?.label ?? value + +// 서버 DTO의 @Size 제한과 같은 값. 서버가 2000자, 요약 근사는 여유를 둔 1800자 예산을 쓴다. +const DIARY_MAX_LENGTH = 2000 +const SUMMARY_MAX_LENGTH = 1800 + +// 400(INVALID_INPUT)은 순수한 입력 오류라 서버가 내려준 사유를 그대로 보여준다. +const errorMessageOf = (error: unknown, fallback: string) => + error instanceof ApiError && error.detail ? error.detail : fallback + +/** 세션에 쌓이는 말풍선 하나. 유저 메시지 또는 캐릭터 메시지. */ +interface SessionItem { + id: number + kind: 'user' | 'character' + text: string + characterId?: string + /** 캐릭터 티키타카·재응답의 답장 대상, 또는 유저 답장의 대상 캐릭터. */ + replyTo?: string + /** + * 캐릭터 말풍선이 속한 일기. 답장 시 이 일기를 맥락으로 보낸다 - 프로덕션이 rootMessageId로 + * 그 댓글이 달린 일기를 찾는 것과 같은 동작. + */ + diary?: string +} + +interface LastMeta { + model: string + latencyMs: number + usedTokens: number + cachedTokens: number + inputTokens: number + outputTokens: number + estimatedCostUsd: number +} + +interface LastRun { + meta: LastMeta + systemPrompt: string + userContent: string + validationError: string | null + generationError: string | null + conditions: { characters: string[]; tikitakaCount: number; eongttungTopic: string | null } | null +} + +/** 접이식 프롬프트 오버라이드 에디터. 비워두면 저장된 현재값으로 생성된다. */ +function PromptOverrideEditor({ + type, + title, + value, + onChange, +}: { + type: 'COMMON' | 'COMMENT' | 'REPLY' + title: string + value: string + onChange: (value: string) => void +}) { + const [open, setOpen] = useState(false) + const { refetch } = useCustom<{ systemPrompt: string }>({ + url: `/api/admin/llm-settings/prompt?promptType=${type}`, + method: 'get', + queryOptions: { enabled: false }, + }) + + const loadSaved = async () => { + const result = await refetch() + const saved = result.data?.data?.systemPrompt + if (saved !== undefined) { + onChange(saved) + } + } + + return ( +
+ + {open && ( +
+