From c1d29aca3186b676e2a504ca2169d6c83663f2e9 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Sat, 20 Jun 2026 12:40:00 +0900 Subject: [PATCH 1/9] =?UTF-8?q?refactor(ai):=20=EB=A9=94=ED=83=80=20?= =?UTF-8?q?=EC=B6=94=EC=B6=9C=20=EB=AA=A8=EB=8D=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=94=84=EB=A1=AC=ED=94=84=ED=8A=B8=20=EC=B6=9C?= =?UTF-8?q?=EB=A0=A5=20=EA=B8=B8=EC=9D=B4=20=EC=B6=95=EC=86=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractDisputeMeta 전용 모델을 gemini-2.0-flash-lite로 분리 - moderation 모델도 gemini-2.0-flash-lite로 변경 - 제목 30자→20자, 요약 100자→50자로 축소 Co-Authored-By: Claude Sonnet 4.6 --- src/lib/ai/judgment.ts | 14 ++++++++------ src/lib/ai/moderation.ts | 2 +- 2 files changed, 9 insertions(+), 7 deletions(-) diff --git a/src/lib/ai/judgment.ts b/src/lib/ai/judgment.ts index 894f47c..3677fd0 100644 --- a/src/lib/ai/judgment.ts +++ b/src/lib/ai/judgment.ts @@ -48,6 +48,8 @@ export interface JudgmentResult { // Step 1: 진술 저장 시 — title / summary 추출 // ============================================================ +const META_MODEL = 'gemini-2.0-flash-lite' + const META_PROMPT = `당신은 한국어 갈등 조정 서비스 TALKY-OWL의 AI입니다. 사용자가 작성한 갈등 진술을 바탕으로 사건 제목과 요약을 추출하세요. @@ -59,8 +61,8 @@ const META_PROMPT = `당신은 한국어 갈등 조정 서비스 TALKY-OWL의 AI 아래 JSON만 반환하세요. 설명이나 마크다운 없이 JSON만 출력하세요. { - "title": "사건을 한 줄로 표현하는 제목 (30자 이내)", - "summary": "갈등 상황을 중립적으로 요약한 문장 (100자 이내, 당사자를 '상대방'으로 지칭)" + "title": "사건을 한 줄로 표현하는 제목 (20자 이내)", + "summary": "갈등 상황을 한 줄로 중립적으로 요약 (50자 이내, 당사자를 '상대방'으로 지칭)" }` export async function extractDisputeMeta(statement: string): Promise { @@ -68,7 +70,7 @@ export async function extractDisputeMeta(statement: string): Promise Date: Sat, 20 Jun 2026 12:40:09 +0900 Subject: [PATCH 2/9] =?UTF-8?q?feat(dispute):=20=EC=82=AC=EA=B1=B4?= =?UTF-8?q?=EC=A2=85=EB=A3=8C=20API=20=EB=B0=8F=20=ED=9B=85=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PATCH /api/disputes/[id]/status 엔드포인트 추가 - closeDispute API 함수 및 useCloseDispute 훅 추가 - DisputePage 사건종료 버튼에 훅 연결 및 로딩/에러 처리 Co-Authored-By: Claude Sonnet 4.6 --- src/app/(page)/disputes/[id]/page.tsx | 13 ++- src/app/api/disputes/[id]/status/route.ts | 98 +++++++++++++++++++++++ src/domains/dispute/dispute.api.ts | 10 +++ src/domains/dispute/dispute.hooks.ts | 12 ++- 4 files changed, 130 insertions(+), 3 deletions(-) create mode 100644 src/app/api/disputes/[id]/status/route.ts diff --git a/src/app/(page)/disputes/[id]/page.tsx b/src/app/(page)/disputes/[id]/page.tsx index 346e89b..0d8e133 100644 --- a/src/app/(page)/disputes/[id]/page.tsx +++ b/src/app/(page)/disputes/[id]/page.tsx @@ -10,7 +10,7 @@ import Spinner from '@/components/ui/Spinner' import StatusBadge from '@/components/ui/StatusBadge' import CategoryIcon from '@/components/ui/CategoryIcon' import InviteChoiceModal from '@/components/room/InviteChoiceModal' -import { useDispute, useRequestJudgment } from '@/domains/dispute/dispute.hooks' +import { useDispute, useRequestJudgment, useCloseDispute } from '@/domains/dispute/dispute.hooks' import { useToastStore } from '@/stores/toastStore' import styles from './DisputePage.module.scss' @@ -32,6 +32,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } const { data: dispute, isLoading: fetchLoading } = useDispute(id) const { mutate: requestJudgment, isPending: isJudging } = useRequestJudgment(id) + const { mutate: closeDispute, isPending: isClosing } = useCloseDispute(id) const showToast = useToastStore((s) => s.show) const [activeTab, setActiveTab] = React.useState('statement') @@ -190,7 +191,15 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } {!isCompleted && (
- +
{dispute.description && ( From 6ccfc94c55c35dc65dd5d034261ce2dd4fabc957 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Sat, 20 Jun 2026 15:06:17 +0900 Subject: [PATCH 8/9] =?UTF-8?q?fix(statement):=20=EC=A7=84=EC=88=A0=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=20=EC=A4=91=20=EC=8A=A4=ED=94=BC=EB=84=88=20?= =?UTF-8?q?=ED=91=9C=EC=8B=9C=20=EB=B0=8F=20meta=20=EC=B6=94=EC=B6=9C=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=ED=9B=84=20=EC=9D=B4=EB=8F=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - extractDisputeMeta fire-and-forget → await으로 변경 (title/summary 추출 완료 후 응답) - 저장 중 fullscreen 스피너 화면 추가 - 중복 PATCH /status 호출 제거 (statements route에서 이미 처리) - next/image 적용 및 카카오 이미지 도메인 허용 (next.config.ts) - avatarFallback → 기본 이미지(thumbnail-default.png) 폴백 - categoryChip inline background-color 제거 Co-Authored-By: Claude Sonnet 4.6 --- next.config.ts | 6 ++++ .../disputes/[id]/DisputePage.module.scss | 1 + src/app/(page)/disputes/[id]/page.tsx | 33 ++++++++----------- .../[id]/statement/StatementPage.module.scss | 28 ++++++++++++++++ .../(page)/disputes/[id]/statement/page.tsx | 18 ++++++---- src/app/api/disputes/[id]/statements/route.ts | 17 +++++----- 6 files changed, 70 insertions(+), 33 deletions(-) diff --git a/next.config.ts b/next.config.ts index 1222840..1643065 100644 --- a/next.config.ts +++ b/next.config.ts @@ -5,6 +5,12 @@ const nextConfig: NextConfig = { sassOptions: { includePaths: [path.join(process.cwd(), 'src')], }, + images: { + remotePatterns: [ + { protocol: 'https', hostname: 'k.kakaocdn.net' }, + { protocol: 'https', hostname: '*.kakaocdn.net' }, + ], + }, }; export default nextConfig; diff --git a/src/app/(page)/disputes/[id]/DisputePage.module.scss b/src/app/(page)/disputes/[id]/DisputePage.module.scss index a56eefc..b5d6b6d 100644 --- a/src/app/(page)/disputes/[id]/DisputePage.module.scss +++ b/src/app/(page)/disputes/[id]/DisputePage.module.scss @@ -114,6 +114,7 @@ } .avatar { + position: relative; width: fn.r(20); height: fn.r(20); border-radius: 50%; diff --git a/src/app/(page)/disputes/[id]/page.tsx b/src/app/(page)/disputes/[id]/page.tsx index 7d297ba..0c78cc2 100644 --- a/src/app/(page)/disputes/[id]/page.tsx +++ b/src/app/(page)/disputes/[id]/page.tsx @@ -1,6 +1,7 @@ 'use client' import React from 'react' +import Image from 'next/image' import { useRouter } from 'next/navigation' import { useQueryClient } from '@tanstack/react-query' import AutorenewRoundedIcon from '@mui/icons-material/AutorenewRounded' @@ -12,6 +13,7 @@ import StatusBadge from '@/components/ui/StatusBadge' import CategoryIcon from '@/components/ui/CategoryIcon' import InviteChoiceModal from '@/components/room/InviteChoiceModal' import { useDispute, useRequestJudgment, useCloseDispute, disputeKeys } from '@/domains/dispute/dispute.hooks' +import { useUserMe } from '@/domains/user/hooks' import { useToastStore } from '@/stores/toastStore' import styles from './DisputePage.module.scss' @@ -20,12 +22,6 @@ const TABS = [ { id: 'statement', label: '진술' }, { id: 'judgement', label: '판결' }, ] -const CATEGORY_BG: Record = { - romance: 'var(--category-love-bg)', - work: 'var(--category-work-bg)', - friend: 'var(--category-friend-bg)', - family: 'var(--category-family-bg)', -} export default function DisputePage({ params }: { params: Promise<{ id: string }> }) { const { id } = React.use(params) @@ -35,6 +31,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string } const { data: dispute, isLoading: fetchLoading } = useDispute(id) const { mutate: requestJudgment, isPending: isJudging } = useRequestJudgment(id) const { mutate: closeDispute, isPending: isClosing } = useCloseDispute(id) + const { data: userMe } = useUserMe() const showToast = useToastStore((s) => s.show) const [activeTab, setActiveTab] = React.useState('statement') @@ -106,10 +103,7 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
-
+
{dispute.title} @@ -127,15 +121,16 @@ export default function DisputePage({ params }: { params: Promise<{ id: string }
- {dispute.participants.slice(0, 2).map((p) => ( -
- {p.profileImageUrl ? ( - - ) : ( -
- )} -
- ))} + {dispute.participants.slice(0, 2).map((p) => { + const imgSrc = p.profileImageUrl + ?? (p.userId === userMe?.id ? userMe?.profileImageUrl : null) + ?? '/images/common/thumbnail-default.png' + return ( +
+ +
+ ) + })}
{formattedDate}
diff --git a/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss b/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss index 5bc3d3f..51611d4 100644 --- a/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss +++ b/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss @@ -122,3 +122,31 @@ flex-direction: column; gap: fn.r(8); } + +.savingScreen { + position: fixed; + inset: 0; + background: linear-gradient(180deg, #{v.$color-primary-200} 0%, #{v.$color-white} 50%); + display: flex; + align-items: center; + justify-content: center; +} + +.savingContent { + display: flex; + flex-direction: column; + align-items: center; + gap: fn.r(20); + width: fn.r(362); +} + +.savingText { + font-family: v.$font-family-base; + font-size: v.$font-size-title-m; + font-weight: v.$font-weight-semibold; + line-height: v.$line-height-title-m; + color: var(--text-primary); + text-align: center; + white-space: pre-line; +} + diff --git a/src/app/(page)/disputes/[id]/statement/page.tsx b/src/app/(page)/disputes/[id]/statement/page.tsx index 3bfd35e..7f447fd 100644 --- a/src/app/(page)/disputes/[id]/statement/page.tsx +++ b/src/app/(page)/disputes/[id]/statement/page.tsx @@ -5,6 +5,7 @@ import { useRouter } from 'next/navigation' import Header from '@/components/layout/Header' import Button from '@/components/ui/Button' import Select from '@/components/ui/Select' +import Spinner from '@/components/ui/Spinner' import Textarea from '@/components/ui/Textarea' import { CATEGORY_ICON_MAP, CATEGORY_LABEL_MAP } from '@/components/ui/CategoryIcon' import type { CategoryGroup } from '@/types/common' @@ -63,12 +64,6 @@ export default function StatementPage({ return } - await fetch(`/api/disputes/${id}/status`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ status: 'waiting_opponent' }), - }) - router.push(`/disputes/${id}`) } catch { setFilterMessage('네트워크 오류가 발생했습니다. 다시 시도해주세요.') @@ -77,6 +72,17 @@ export default function StatementPage({ } } + if (isLoading) { + return ( +
+
+ +

{'사건 정보를 분석하고 있어요\n잠시만 기다려주세요'}

+
+
+ ) + } + if (!category) { return (
diff --git a/src/app/api/disputes/[id]/statements/route.ts b/src/app/api/disputes/[id]/statements/route.ts index 5dca3ad..2a0ddac 100644 --- a/src/app/api/disputes/[id]/statements/route.ts +++ b/src/app/api/disputes/[id]/statements/route.ts @@ -313,14 +313,15 @@ export async function POST( // AI 2: title/description 추출 — ROLE_A 최초 저장 시에만 실행 (ROLE_B가 덮어쓰지 않도록) if (participant.role === 'ROLE_A' && isNew) { - extractDisputeMeta(content) - .then((meta) => - prisma.dispute.update({ - where: { id: disputeId }, - data: { title: meta.title, description: meta.summary }, - }), - ) - .catch((err) => console.error('[statements] extractDisputeMeta failed:', err)) + try { + const meta = await extractDisputeMeta(content) + await prisma.dispute.update({ + where: { id: disputeId }, + data: { title: meta.title, description: meta.summary }, + }) + } catch (err) { + console.error('[statements] extractDisputeMeta failed:', err) + } } return NextResponse.json>( From cb209ca1e604119d5048d912c380385d12cc0aa9 Mon Sep 17 00:00:00 2001 From: juahcheon <132863519+juahcheon@users.noreply.github.com> Date: Sat, 20 Jun 2026 17:15:33 +0900 Subject: [PATCH 9/9] =?UTF-8?q?fix:=20=EC=BD=94=EB=93=9C=EB=A6=AC=EB=B7=B0?= =?UTF-8?q?=20=EB=B0=98=EC=98=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - StatementPage: savingContent 폭 반응형 처리 (min(100%, 362px)) - statements/route: extractDisputeMeta에 10초 타임아웃 추가 - status/route: 동일 상태 재요청 시 멱등 성공 반환 - judgment.ts: Array.from 기반 유니코드 안전 문자열 절단 Co-Authored-By: Claude Sonnet 4.6 --- .../disputes/[id]/statement/StatementPage.module.scss | 3 ++- src/app/api/disputes/[id]/statements/route.ts | 8 +++++++- src/app/api/disputes/[id]/status/route.ts | 8 ++++++++ src/lib/ai/judgment.ts | 4 ++-- 4 files changed, 19 insertions(+), 4 deletions(-) diff --git a/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss b/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss index 51611d4..0feb759 100644 --- a/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss +++ b/src/app/(page)/disputes/[id]/statement/StatementPage.module.scss @@ -137,7 +137,8 @@ flex-direction: column; align-items: center; gap: fn.r(20); - width: fn.r(362); + width: min(100%, fn.r(362)); + padding: 0 fn.r(16); } .savingText { diff --git a/src/app/api/disputes/[id]/statements/route.ts b/src/app/api/disputes/[id]/statements/route.ts index 2a0ddac..7f36be3 100644 --- a/src/app/api/disputes/[id]/statements/route.ts +++ b/src/app/api/disputes/[id]/statements/route.ts @@ -313,8 +313,14 @@ export async function POST( // AI 2: title/description 추출 — ROLE_A 최초 저장 시에만 실행 (ROLE_B가 덮어쓰지 않도록) if (participant.role === 'ROLE_A' && isNew) { + const META_TIMEOUT_MS = 10000 try { - const meta = await extractDisputeMeta(content) + const meta = await Promise.race([ + extractDisputeMeta(content), + new Promise((_, reject) => + setTimeout(() => reject(new Error('extractDisputeMeta timeout')), META_TIMEOUT_MS), + ), + ]) await prisma.dispute.update({ where: { id: disputeId }, data: { title: meta.title, description: meta.summary }, diff --git a/src/app/api/disputes/[id]/status/route.ts b/src/app/api/disputes/[id]/status/route.ts index 9a43d41..1c3da4b 100644 --- a/src/app/api/disputes/[id]/status/route.ts +++ b/src/app/api/disputes/[id]/status/route.ts @@ -72,6 +72,14 @@ export async function PATCH( ) } + // 이미 목표 상태면 멱등 성공 반환 + if (dispute.status === newStatus.toUpperCase()) { + return NextResponse.json>({ + success: true, + data: { status: dispute.status.toLowerCase() as DisputeStatus }, + }) + } + const terminalStatuses = ['JUDGED', 'CLOSED', 'DELETED', 'EXPIRED'] if (terminalStatuses.includes(dispute.status)) { return NextResponse.json( diff --git a/src/lib/ai/judgment.ts b/src/lib/ai/judgment.ts index 3677fd0..86bb4aa 100644 --- a/src/lib/ai/judgment.ts +++ b/src/lib/ai/judgment.ts @@ -91,8 +91,8 @@ export async function extractDisputeMeta(statement: string): Promise