Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
2 changes: 2 additions & 0 deletions admin-web/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 을 쓴다.
Expand Down Expand Up @@ -44,6 +45,7 @@ export default function App() {
<Route path="/members/:id" element={<MemberShow />} />
<Route path="/admin-accounts" element={<AdminAccountList />} />
<Route path="/llm-settings" element={<LlmSettingsPage />} />
<Route path="/playground" element={<PromptPlaygroundPage />} />
<Route path="/token-usage" element={<TokenUsagePage />} />
</Route>

Expand Down
6 changes: 5 additions & 1 deletion admin-web/src/components/layout/AdminLayout.tsx
Original file line number Diff line number Diff line change
@@ -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'
Expand All @@ -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 {
Expand All @@ -44,6 +45,9 @@ function sectionOf(pathname: string): string {
if (pathname.startsWith('/token-usage')) {
return '토큰 사용량'
}
if (pathname.startsWith('/playground')) {
return '프롬프트 실험실'
}
return ''
}

Expand Down
18 changes: 17 additions & 1 deletion admin-web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
}
Expand Down Expand Up @@ -96,7 +112,7 @@ export async function apiFetch<T>(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
}
51 changes: 30 additions & 21 deletions admin-web/src/pages/llm-settings.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 }) {
Expand Down Expand Up @@ -140,7 +142,6 @@ function ModelSection() {
interface PromptSetting {
promptType: PromptType
systemPrompt: string
defaultSystemPrompt: string
}

function PromptSection() {
Expand Down Expand Up @@ -192,14 +193,16 @@ function PromptSection() {

return (
<div className="space-y-4">
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 text-xs text-muted-foreground">
<Layers className="size-4 shrink-0 text-muted-foreground/70" />
<span>
실제 시스템 프롬프트는 <span className="font-medium text-foreground">공통</span> +{' '}
<span className="font-medium text-foreground">타입(댓글·답글·카드)</span> 으로 조립됩니다. 캐릭터 성격 등
공통 부분은 <span className="font-medium text-foreground">공통</span> 탭에서 한 번에 바꾸세요.
</span>
</div>
{type !== 'EONGTTUNG_TOPIC' && (
<div className="flex items-center gap-2 rounded-lg border bg-muted/40 p-2.5 text-xs text-muted-foreground">
<Layers className="size-4 shrink-0 text-muted-foreground/70" />
<span>
실제 시스템 프롬프트는 <span className="font-medium text-foreground">공통</span> +{' '}
<span className="font-medium text-foreground">타입(댓글·답글·카드)</span> 으로 조립됩니다. 캐릭터 성격 등
공통 부분은 <span className="font-medium text-foreground">공통</span> 탭에서 한 번에 바꾸세요.
</span>
</div>
)}

<div className="inline-flex items-center rounded-lg border bg-muted/40 p-1">
{TABS.map((tab) => (
Expand Down Expand Up @@ -237,7 +240,7 @@ function PromptSection() {
<Card className="space-y-3 p-6">
<div className="flex items-baseline justify-between">
<label htmlFor="prompt" className="text-sm font-medium">
{type === 'COMMON' ? '공통 프롬프트' : '타입 프롬프트'}
{type === 'COMMON' ? '공통 프롬프트' : type === 'EONGTTUNG_TOPIC' ? '소재 목록 (한 줄에 하나)' : '타입 프롬프트'}
</label>
<span className="text-xs tabular-nums text-muted-foreground">{prompt.length.toLocaleString()}자</span>
</div>
Expand All @@ -252,18 +255,11 @@ function PromptSection() {
<p className="mr-auto text-xs text-muted-foreground">
{type === 'COMMON'
? '캐릭터 보이스카드·말맛지침 등 세 타입이 공유하는 부분입니다. 신중히 수정하세요.'
: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.'}
: type === 'EONGTTUNG_TOPIC'
? '빈 줄은 무시됩니다. 목록을 전부 비우면 저장할 수 없습니다.'
: '이 타입의 역할·규칙·출력형식입니다. 생성 시 공통 프롬프트 뒤에 붙습니다.'}
</p>
<SavedFlash show={flash} />
<Button
variant="outline"
size="sm"
onClick={() => setPrompt(settings.defaultSystemPrompt)}
disabled={saving || prompt.trim() === settings.defaultSystemPrompt.trim()}
>
<RotateCcw className="size-4" />
기본값 변경
</Button>
<Button size="sm" onClick={onSave} disabled={!dirty || saving || !prompt.trim()}>
<Save className="size-4" />
저장
Expand All @@ -272,6 +268,19 @@ function PromptSection() {
</div>
</Card>
)}

{ready && (
<PromptRevisionHistory
key={type}
type={type}
currentPrompt={savedPrompt}
onLoadToEditor={(content) => setPrompt(content)}
onRestored={(content) => {
setPrompt(content)
setSavedPrompt(content)
}}
/>
)}
</div>
)
}
Expand Down
Loading
Loading