표현 아키텍처: agent 스크립트는 의미(텍스트)만 소유, 표현(굵기·색)은 프론트 소유.
Slate 는 negodata 에디터 내부에만 두고, 전송/저장은 마커 문자열 한 벌(구버전의 리치텍스트 이중관리 폐기).
- 트랙 A (negodata): serializeToMarker 추가 — Slate 마크(bold/underline/color)를 **/__/{{토큰}} 로
인코딩해 nego_cards.script 저장. edit_script(Slate 원본)는 재편집 전용. 고정 3색 → 시맨틱 토큰(강조/안내).
- 트랙 B (agent): 카드 멘트 DB 소스 — ICardScriptRepository/CardScriptDbRepository(port+adapter),
ScriptRepository.resolve_card_script 가 cards.source_type=backoffice_db 면 card.nego_cards.script 우선,
없으면 파일 폴백. action_id→card_id→nego_cards.number 매칭.
- 트랙 C (양 프론트): renderEmphasis 재귀 파서 — **굵게**·__밑줄__·{{강조|빨강}}·{{안내|파랑}} 중첩 렌더.
색은 시맨틱 토큰→디자인 토큰 클래스(다크모드 안전). negodata tokens.css 에 --info 신설. CardTable 미리보기 적용.
- supplier_items 연동: 유통코드=supplier_items.supply_type(→quotations.supplier_type 폴백),
파트너유형=상품별 매핑 협력사 수(→세션 이력 폴백).
- 가격 수용률: 기존 공급가(item_price) 기준 양보율로 정정 — 첫 라운드부터 실값(첫 제시가 기준 0 아님).
테스트: agent 86/86, 공급사 frontend·negodata front tsc 통과.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
140 lines
5.6 KiB
TypeScript
140 lines
5.6 KiB
TypeScript
import { useEffect, useRef, memo } from 'react'
|
|
import { cn } from '@/lib'
|
|
import { useChatStore } from '@/features/chat/stores/useChatStore'
|
|
import { useChatInitStore } from '@/features/chat/stores/useChatInitStore'
|
|
import { SessionStatus } from '@/apis/negotiation/negotiation.type'
|
|
import type { ChatMessage as ChatMessageType } from '@/features/chat/types'
|
|
import { renderEmphasis } from '@/features/chat/lib/emphasis'
|
|
import { Indicator } from '@/features/chat/components/templates/Indicator'
|
|
import { Summary } from '@/features/chat/components/templates/Summary'
|
|
import { BidSummary } from '@/features/chat/components/templates/BidSummary'
|
|
import { RejectRSP } from '@/features/chat/components/templates/RejectRSP'
|
|
import { RejectCM } from '@/features/chat/components/templates/RejectCM'
|
|
|
|
export function ChatMessage() {
|
|
return (
|
|
<div className="flex-1 w-full min-h-0 pr-[16px]">
|
|
{/* pt-[64px]: 첫 메시지를 우측 협상절차 카드 상단과 맞추되, 스크롤 시 여백도 함께 밀려 올라가도록 스크롤 컨테이너 안쪽에 둔다 */}
|
|
<div className="chat-scroll h-full overflow-y-auto flex flex-col pt-[64px] pl-[140px] pr-[126px] max-[1350px]:pl-[80px] max-[1350px]:pr-[72px] max-[1180px]:pl-[48px] max-[1180px]:pr-[40px]">
|
|
<ChatList />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ChatList() {
|
|
const bottomRef = useRef<HTMLDivElement | null>(null)
|
|
const chats = useChatStore((s) => s.messages)
|
|
const isLoading = useChatStore((s) => s.isLoading)
|
|
|
|
// 메시지 추가/타이핑 표시 시 항상 맨 아래로 스크롤
|
|
useEffect(() => {
|
|
bottomRef.current?.scrollIntoView({ behavior: 'smooth' })
|
|
}, [chats, isLoading])
|
|
|
|
if (!chats || chats.length === 0) {
|
|
return (
|
|
<div className="flex items-center justify-center h-full">
|
|
<p className="body-1 text-neutral-70">채팅 내역이 없습니다.</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-col w-full">
|
|
{chats.map((message, index) => (
|
|
<MessageItem key={message.chat_id || index} message={message} isFirst={index === 0} messages={chats} currentIndex={index} />
|
|
))}
|
|
{isLoading && <TypingBubble />}
|
|
<div ref={bottomRef} />
|
|
</div>
|
|
)
|
|
}
|
|
|
|
// agent 응답을 기다리는 동안(협상 중) 대화 흐름에 표시하는 타이핑 인디케이터.
|
|
function TypingBubble() {
|
|
return (
|
|
<div className="mb-[56px] pt-[36px]" aria-label="협상 중" role="status">
|
|
<div className="inline-flex items-center gap-[6px]">
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.3s]" />
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce [animation-delay:-0.15s]" />
|
|
<span className="size-[8px] rounded-full bg-neutral-50 animate-bounce" />
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
const MessageItem = memo(function MessageItem({
|
|
message,
|
|
isFirst,
|
|
messages,
|
|
currentIndex,
|
|
}: {
|
|
message: ChatMessageType
|
|
isFirst: boolean
|
|
messages: ChatMessageType[]
|
|
currentIndex: number
|
|
}) {
|
|
const isBot = message.sender === 'bot'
|
|
|
|
// 직전이 reject 폼이면 사용자 답변은 숨기고 구분선만 표시
|
|
if (!isBot && currentIndex > 0) {
|
|
const prev = messages[currentIndex - 1]
|
|
if (prev?.bot_chat_type === 'rejectRSP' || prev?.bot_chat_type === 'rejectCM') {
|
|
return (
|
|
<>
|
|
<div className="text-right mb-[56px]" />
|
|
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
|
</>
|
|
)
|
|
}
|
|
}
|
|
|
|
return <div>{isBot ? <BotMessage message={message} isFirst={isFirst} /> : <UserMessage text={message.script || ''} />}</div>
|
|
})
|
|
|
|
const BotMessage = memo(function BotMessage({ message, isFirst }: { message: ChatMessageType; isFirst?: boolean }) {
|
|
// 인디케이터는 진행 중인 협상에서만 표시 — 완료/거부 등 결과 열람 재진입 시에는 값이 와도 숨긴다
|
|
const sessionStatus = useChatInitStore((s) => s.session_status)
|
|
const showIndicator = sessionStatus === SessionStatus.IN_PROGRESS
|
|
return (
|
|
<div className="mb-[56px]">
|
|
<div className={cn('flex flex-col', !isFirst && 'pt-[36px]')}>
|
|
{/* 봇 스크립트의 `**굵게**` 경량 마크업 해석 (표현 규칙은 프론트 소유 — emphasis.tsx) */}
|
|
<div className="body-1-read-r">{renderEmphasis(message.script || '')}</div>
|
|
</div>
|
|
|
|
<div className="flex flex-col gap-4 w-full mt-[32px]">
|
|
{showIndicator && message.bot_chat_type === 'indicator' && message.indicator_value != null && (
|
|
<Indicator number={message.indicator_value} />
|
|
)}
|
|
{message.bot_chat_type === 'summaryRSP' && message.summary && <Summary data={message.summary} />}
|
|
{message.bot_chat_type === 'summaryCM' && message.summary && (
|
|
<BidSummary
|
|
itemName={message.summary.item_name}
|
|
itemCode={message.summary.item_code}
|
|
bidPrice={message.summary.final_price}
|
|
deliveryType={message.summary.delivery_type || ''}
|
|
isVAT={message.summary.item_isVAT}
|
|
/>
|
|
)}
|
|
{message.bot_chat_type === 'rejectRSP' && <RejectRSP />}
|
|
{message.bot_chat_type === 'rejectCM' && <RejectCM />}
|
|
</div>
|
|
</div>
|
|
)
|
|
})
|
|
|
|
const UserMessage = memo(function UserMessage({ text }: { text: string }) {
|
|
return (
|
|
<>
|
|
<div className="text-right mb-[56px]">
|
|
<div className="inline-block max-w-[87%] bg-neutral-40 text-neutral-90 text-lg leading-[150%] tracking-[-0.18px] px-[20px] py-[10px] rounded-full break-keep">
|
|
{text}
|
|
</div>
|
|
</div>
|
|
<div className="flex w-full bg-neutral-30 h-[1px] mb-[20px]" />
|
|
</>
|
|
)
|
|
})
|