diff --git a/src/api/types.ts b/src/api/types.ts index d8d0023..0e2844d 100644 --- a/src/api/types.ts +++ b/src/api/types.ts @@ -108,6 +108,31 @@ export interface SendChatResponse { }>; } +// 답변 피드백 관련 타입 +export type FeedbackRating = "GOOD" | "BAD"; + +export interface MessageFeedbackResponse { + messageId: string; + rating: FeedbackRating; + createdAt: string; + updatedAt: string; +} + +// GET /api/v1/widget/messages 응답 메시지 +export interface WidgetMessageResponse { + id: string; + role: "user" | "assistant"; + content: string; + metadata?: Record; + feedback: FeedbackRating | null; + createdAt: string; +} + +export interface PaginatedWidgetMessagesResponse { + messages: WidgetMessageResponse[]; + nextCursor: string | null; +} + // OAuth2/OIDC 타입 정의 export interface OAuth2TokenResponse { access_token: string; diff --git a/src/api/widgetChat.ts b/src/api/widgetChat.ts index 72db90b..117ac1a 100644 --- a/src/api/widgetChat.ts +++ b/src/api/widgetChat.ts @@ -4,8 +4,12 @@ import axios from "axios"; import type { CreateSessionRequest, CreateSessionResponse, + FeedbackRating, + MessageFeedbackResponse, + PaginatedWidgetMessagesResponse, SendChatRequest, SendChatResponse, + WidgetMessageResponse, } from "./types"; const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; @@ -34,34 +38,32 @@ widgetApiClient.interceptors.request.use( ); /** - * 채팅 메시지 전송 (스트리밍) - * POST /api/v1/widget/messages/chat/stream - * @param request 요청 데이터 + * 위젯 SSE 채팅 스트림 공통 처리 + * @param path API 경로 (API_BASE_URL 기준 상대 경로) + * @param body 요청 본문 (undefined면 본문 없이 전송) * @param onChunk 스트림 청크를 받을 때마다 호출되는 콜백 (텍스트, 완료 여부) * @param onComplete 전체 응답이 완료되었을 때 호출되는 콜백 (최종 응답) * @param options.signal 중지 시 사용할 AbortSignal (중지 버튼 등) */ -export async function sendWidgetChatMessage( - request: SendChatRequest, +async function streamWidgetChatResponse( + path: string, + body: unknown, onChunk?: (text: string, isComplete: boolean) => void, onComplete?: (response: SendChatResponse) => void, options?: { signal?: AbortSignal } ): Promise { const sessionToken = getSessionToken(); - const API_BASE_URL = import.meta.env.VITE_API_BASE_URL || "/api"; - - const response = await fetch( - `${API_BASE_URL}/v1/widget/messages/chat/stream`, - { - method: "POST", - headers: { - "Content-Type": "application/json", - ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}), - }, - body: JSON.stringify(request), - signal: options?.signal, - } - ); + + const response = await fetch(`${API_BASE_URL}${path}`, { + method: "POST", + headers: { + // 본문 없이 Content-Type: application/json을 보내면 Fastify가 400을 반환함 + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), + ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: options?.signal, + }); if (!response.ok) { const errorText = await response.text(); @@ -267,6 +269,74 @@ export async function sendWidgetChatMessage( } } +/** + * 채팅 메시지 전송 (스트리밍) + * POST /api/v1/widget/messages/chat/stream + */ +export async function sendWidgetChatMessage( + request: SendChatRequest, + onChunk?: (text: string, isComplete: boolean) => void, + onComplete?: (response: SendChatResponse) => void, + options?: { signal?: AbortSignal } +): Promise { + return streamWidgetChatResponse( + "/v1/widget/messages/chat/stream", + request, + onChunk, + onComplete, + options + ); +} + +/** + * BAD 피드백 답변 1회 재생성 (스트리밍) + * POST /api/v1/widget/messages/:messageId/regenerate/stream + */ +export async function regenerateWidgetAnswer( + messageId: string, + onChunk?: (text: string, isComplete: boolean) => void, + onComplete?: (response: SendChatResponse) => void, + options?: { signal?: AbortSignal } +): Promise { + return streamWidgetChatResponse( + `/v1/widget/messages/${encodeURIComponent(messageId)}/regenerate/stream`, + undefined, + onChunk, + onComplete, + options + ); +} + +/** + * assistant 답변 피드백 등록/변경 (문제 해결 여부) + * PUT /api/v1/widget/messages/:messageId/feedback + */ +export async function submitMessageFeedback( + messageId: string, + rating: FeedbackRating +): Promise { + const response = await widgetApiClient.put( + `/v1/widget/messages/${encodeURIComponent(messageId)}/feedback`, + { rating } + ); + return response.data; +} + +/** + * 가장 최근에 저장된 assistant 메시지 조회 + * GET /api/v1/widget/messages?limit=1 + * 스트리밍 응답에는 메시지 ID가 포함되지 않으므로, + * 스트림 완료 직후 이 API로 서버 메시지 ID를 얻어 피드백/재생성에 사용한다. + */ +export async function fetchLatestAssistantMessage(): Promise { + const response = await widgetApiClient.get( + "/v1/widget/messages", + { params: { limit: 1 } } + ); + const latest = response.data.messages?.[0]; + return latest && latest.role === "assistant" ? latest : null; +} + /** * 세션 발급 * POST /api/v1/widget/auth/session diff --git a/src/components/Icons.tsx b/src/components/Icons.tsx index 3e2800c..b2d1699 100644 --- a/src/components/Icons.tsx +++ b/src/components/Icons.tsx @@ -294,3 +294,13 @@ export const FlagIcon = ({ className = "w-4 h-4" }: IconProps) => ( /> ); + +// 답변 피드백 (도움이 됐어요) +export const ThumbsUpIcon = createIcon("0 0 24 24", [ + "M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3", +]); + +// 답변 피드백 (도움이 안 됐어요) +export const ThumbsDownIcon = createIcon("0 0 24 24", [ + "M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17", +]); diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index 9a3ed2a..d2ced31 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -10,7 +10,13 @@ import { } from "react"; import { Streamdown } from "streamdown"; import { code } from "@streamdown/code"; -import type { ChatMessage, ColorTheme, WidgetContext, Source } from "./types"; +import type { + ChatMessage, + ColorTheme, + WidgetContext, + Source, + FeedbackRating, +} from "./types"; import { uid, applyColorTheme } from "./utils"; import { LinkIcon, @@ -19,10 +25,15 @@ import { StopIcon, InfoIcon, FlagIcon, + ThumbsUpIcon, + ThumbsDownIcon, } from "../components/Icons"; import { createWidgetSession, sendWidgetChatMessage, + regenerateWidgetAnswer, + submitMessageFeedback, + fetchLatestAssistantMessage, saveSessionToken, getSessionToken, getSessionExpiresAt, @@ -116,6 +127,53 @@ function SourceImage({ source }: { source: Source }) { ); } +// 답변 피드백 버튼 (도움이 됐어요/안 됐어요) +function FeedbackButton({ + rating, + selected, + disabled, + onClick, +}: { + rating: FeedbackRating; + selected: boolean; + disabled: boolean; + onClick: () => void; +}) { + const label = rating === "GOOD" ? "도움이 됐어요" : "도움이 안 됐어요"; + const Icon = rating === "GOOD" ? ThumbsUpIcon : ThumbsDownIcon; + + return ( + + ); +} + interface ChatWidgetProps { onClose?: () => void; className?: string; @@ -142,6 +200,10 @@ export default function ChatWidget({ const isComposingRef = useRef(false); const abortControllerRef = useRef(null); const streamingMessageIdRef = useRef(null); + // 피드백 전송 중인 메시지 ID (중복 클릭 방지) + const [feedbackBusyId, setFeedbackBusyId] = useState(null); + // 재생성 스트리밍 중 여부 (로딩 문구 구분용) + const regeneratingRef = useRef(false); // iframe 환경인지 확인 (React 앱 내부에서는 false) const isInIframe = typeof window !== "undefined" && window.parent !== window; @@ -279,8 +341,10 @@ export default function ChatWidget({ if (!loading) { return; } - // 새 질문 시작 시 항상 첫 메시지로 초기화 - setLoadingMessage("자료를 찾아보는 중"); + // 새 질문 시작 시 항상 첫 메시지로 초기화 (재생성 시에는 재생성 문구) + setLoadingMessage( + regeneratingRef.current ? "답변을 다시 생성하는 중" : "자료를 찾아보는 중", + ); const timeout1 = setTimeout(() => { setLoadingMessage("파일을 읽어보는 중"); @@ -306,6 +370,154 @@ export default function ChatWidget({ return messages.every((m) => m.role === "assistant"); }, [messages]); + const latestFeedbackMessageId = useMemo(() => { + const latestMessage = messages[messages.length - 1]; + return latestMessage?.role === "assistant" ? latestMessage.id : null; + }, [messages]); + + // 스트림 완료 후 서버에 저장된 assistant 메시지 ID를 로컬 메시지에 연결 (피드백/재생성 API용) + const attachServerId = async ( + localId: string, + extra?: Partial, + ) => { + try { + const latest = await fetchLatestAssistantMessage(); + if (!latest) return; + setMessages((prev) => + prev.map((msg) => + msg.id === localId + ? { ...msg, serverId: latest.id, feedback: latest.feedback, ...extra } + : msg, + ), + ); + } catch (error) { + // 실패 시 해당 답변의 피드백 버튼만 표시되지 않음 (채팅에는 영향 없음) + console.error("Failed to attach server message id:", error); + } + }; + + // BAD 피드백 답변 1회 재생성 + const regenerateAnswer = async (originalMsg: ChatMessage) => { + if (!originalMsg.serverId || loading) return; + + // 이전(원본) 답변을 제거하고 그 자리에서 재생성 답변을 스트리밍 + const regenMsgId = uid(); + setMessages((prev) => + prev.map((msg): ChatMessage => + msg.id === originalMsg.id + ? { + id: regenMsgId, + role: "assistant", + text: "", + sources: [], + regeneratedAnswer: true, + } + : msg, + ), + ); + regeneratingRef.current = true; + setLoading(true); + streamingMessageIdRef.current = regenMsgId; + + const controller = new AbortController(); + abortControllerRef.current = controller; + + try { + await regenerateWidgetAnswer( + originalMsg.serverId, + (streamedText) => { + setMessages((prev) => + prev.map((msg) => + msg.id === regenMsgId ? { ...msg, text: streamedText } : msg, + ), + ); + }, + (finalResponse) => { + abortControllerRef.current = null; + streamingMessageIdRef.current = null; + setMessages((prev) => + prev.map((msg) => + msg.id === regenMsgId + ? { + ...msg, + text: finalResponse.answer, + sources: finalResponse.sources, + } + : msg, + ), + ); + setLoading(false); + void attachServerId(regenMsgId, { regeneratedAnswer: true }); + }, + { signal: controller.signal }, + ); + } catch (error) { + abortControllerRef.current = null; + streamingMessageIdRef.current = null; + setLoading(false); + + const isAbortError = + (typeof DOMException !== "undefined" && + error instanceof DOMException && + error.name === "AbortError") || + (error instanceof Error && error.name === "AbortError"); + if (isAbortError) { + return; + } + + console.error("Failed to regenerate answer:", error); + // 이미 받은 텍스트가 있으면 그대로 유지, + // 아무것도 못 받았으면 원본 답변을 복원 (다시 👎로 재시도 가능) + setMessages((prev) => { + const regenMsg = prev.find((msg) => msg.id === regenMsgId); + if (regenMsg && regenMsg.text) { + return prev; + } + return prev.map((msg) => + msg.id === regenMsgId ? { ...originalMsg } : msg, + ); + }); + } finally { + regeneratingRef.current = false; + } + }; + + // 답변 피드백 처리 (GOOD: 해결됨 / BAD: 해결 안 됨 + 1회 재생성) + const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { + if ( + !msg.serverId || + loading || + feedbackBusyId || + isPreviewMode + ) { + return; + } + + const canRegenerate = + msg.id === latestFeedbackMessageId && + rating === "BAD" && + !msg.regeneratedAnswer; + // 같은 피드백 재클릭: 재생성이 남아있는 BAD가 아니면 무시 (재생성 실패 후 재시도 허용) + if (msg.feedback === rating && !canRegenerate) return; + + setFeedbackBusyId(msg.id); + try { + if (msg.feedback !== rating) { + await submitMessageFeedback(msg.serverId, rating); + setMessages((prev) => + prev.map((m) => (m.id === msg.id ? { ...m, feedback: rating } : m)), + ); + } + if (canRegenerate) { + await regenerateAnswer(msg); + } + } catch (error) { + console.error("Failed to submit feedback:", error); + } finally { + setFeedbackBusyId(null); + } + }; + const send = async (overrideText?: string) => { const text = (overrideText ?? input).trim(); if (!text || loading) return; @@ -438,6 +650,9 @@ export default function ChatWidget({ }); setLoading(false); + // 서버 메시지 ID 연결 (피드백 버튼 활성화) + void attachServerId(assistantMsgId); + // 메시지 수신 이벤트 전달 (iframe 환경에서만) if (isInIframe) { setMessages((prev) => { @@ -773,6 +988,51 @@ export default function ChatWidget({ + {/* 답변 피드백 버튼 (서버에 저장된 assistant 답변에만 표시) */} + {m.role === "assistant" && m.serverId && ( +
+ void handleFeedback(m, "GOOD")} + /> + void handleFeedback(m, "BAD")} + /> + {m.regeneratedAnswer ? ( + + 다시 생성된 답변 + + ) : ( + !m.feedback && ( + + 답변이 도움이 되었나요? + + ) + )} +
+ )} + {/* 첫 번째 메시지 뒤에 자주 묻는 질문 표시 */} {msgIdx === 0 && showFrequentQuestions && diff --git a/src/widget/types.ts b/src/widget/types.ts index 775ec00..41d19a0 100644 --- a/src/widget/types.ts +++ b/src/widget/types.ts @@ -10,11 +10,16 @@ export interface Source { title?: string; // 출처 제목 } +export type FeedbackRating = "GOOD" | "BAD"; + export interface ChatMessage { id: string; role: Role; text: string; sources?: Source[]; // 출처 정보 + serverId?: string; // 백엔드에 저장된 메시지 ID (피드백/재생성 API 호출용) + feedback?: FeedbackRating | null; // 현재 저장된 피드백 + regeneratedAnswer?: boolean; // 재생성으로 만들어진 답변 (다시 재생성 불가) } export interface ColorTheme {