From 8f0016560e3d416eefd061678e38ef7921101c3a Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 12:58:12 -0700 Subject: [PATCH 1/6] feat: add answer feedback buttons with BAD-feedback regeneration --- src/api/types.ts | 25 ++++ src/api/widgetChat.ts | 107 ++++++++++++++--- src/components/Icons.tsx | 10 ++ src/widget/ChatWidget.tsx | 236 +++++++++++++++++++++++++++++++++++++- src/widget/types.ts | 6 + 5 files changed, 362 insertions(+), 22 deletions(-) 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..b130e3e 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,31 @@ 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", + ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}), + }, + ...(body !== undefined ? { body: JSON.stringify(body) } : {}), + signal: options?.signal, + }); if (!response.ok) { const errorText = await response.text(); @@ -267,6 +268,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..a0c46d0 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,143 @@ export default function ChatWidget({ return messages.every((m) => m.role === "assistant"); }, [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) => + msg.id === originalMsg.id ? { ...msg, regenerationUsed: true } : msg, + ) + .concat([ + { + id: regenMsgId, + role: "assistant", + text: "", + sources: [], + regeneratedAnswer: true, + }, + ]), + ); + 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 existingMsg = prev.find((msg) => msg.id === regenMsgId); + if (existingMsg && existingMsg.text) { + return prev; + } + return prev.map((msg) => + msg.id === regenMsgId + ? { + ...msg, + text: "죄송합니다. 답변을 다시 생성하는 중 오류가 발생했습니다.", + } + : msg, + ); + }); + } finally { + regeneratingRef.current = false; + } + }; + + // 답변 피드백 처리 (GOOD: 해결됨 / BAD: 해결 안 됨 + 1회 재생성) + const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { + if (!msg.serverId || loading || feedbackBusyId || isPreviewMode) return; + if (msg.feedback === rating) return; + + setFeedbackBusyId(msg.id); + try { + await submitMessageFeedback(msg.serverId, rating); + setMessages((prev) => + prev.map((m) => (m.id === msg.id ? { ...m, feedback: rating } : m)), + ); + if ( + rating === "BAD" && + !msg.regeneratedAnswer && + !msg.regenerationUsed + ) { + 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 +639,9 @@ export default function ChatWidget({ }); setLoading(false); + // 서버 메시지 ID 연결 (피드백 버튼 활성화) + void attachServerId(assistantMsgId); + // 메시지 수신 이벤트 전달 (iframe 환경에서만) if (isInIframe) { setMessages((prev) => { @@ -773,6 +977,32 @@ export default function ChatWidget({ + {/* 답변 피드백 버튼 (서버에 저장된 assistant 답변에만 표시) */} + {m.role === "assistant" && m.serverId && ( +
+ void handleFeedback(m, "GOOD")} + /> + void handleFeedback(m, "BAD")} + /> + {m.regeneratedAnswer && ( + + 다시 생성된 답변 + + )} +
+ )} + {/* 첫 번째 메시지 뒤에 자주 묻는 질문 표시 */} {msgIdx === 0 && showFrequentQuestions && diff --git a/src/widget/types.ts b/src/widget/types.ts index 775ec00..c0e13e5 100644 --- a/src/widget/types.ts +++ b/src/widget/types.ts @@ -10,11 +10,17 @@ 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; // 재생성으로 만들어진 답변 (다시 재생성 불가) + regenerationUsed?: boolean; // 이 답변에 대한 재생성이 이미 수행됨 } export interface ColorTheme { From 140a38f28f31ec7bf4fc08b041f3e2e507ed5040 Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 13:30:53 -0700 Subject: [PATCH 2/6] fix: omit Content-Type on bodyless regenerate request and allow regeneration retry --- src/api/widgetChat.ts | 3 ++- src/widget/ChatWidget.tsx | 32 +++++++++++++++++++------------- 2 files changed, 21 insertions(+), 14 deletions(-) diff --git a/src/api/widgetChat.ts b/src/api/widgetChat.ts index b130e3e..117ac1a 100644 --- a/src/api/widgetChat.ts +++ b/src/api/widgetChat.ts @@ -57,7 +57,8 @@ async function streamWidgetChatResponse( const response = await fetch(`${API_BASE_URL}${path}`, { method: "POST", headers: { - "Content-Type": "application/json", + // 본문 없이 Content-Type: application/json을 보내면 Fastify가 400을 반환함 + ...(body !== undefined ? { "Content-Type": "application/json" } : {}), ...(sessionToken ? { Authorization: `Bearer ${sessionToken}` } : {}), }, ...(body !== undefined ? { body: JSON.stringify(body) } : {}), diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index a0c46d0..91a155b 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -462,13 +462,17 @@ export default function ChatWidget({ } console.error("Failed to regenerate answer:", error); + // 재시도할 수 있도록 재생성 사용 표시를 되돌리고, // 이미 받은 텍스트가 있으면 유지, 없으면 에러 메시지로 교체 setMessages((prev) => { - const existingMsg = prev.find((msg) => msg.id === regenMsgId); + const reverted = prev.map((msg) => + msg.id === originalMsg.id ? { ...msg, regenerationUsed: false } : msg, + ); + const existingMsg = reverted.find((msg) => msg.id === regenMsgId); if (existingMsg && existingMsg.text) { - return prev; + return reverted; } - return prev.map((msg) => + return reverted.map((msg) => msg.id === regenMsgId ? { ...msg, @@ -485,19 +489,21 @@ export default function ChatWidget({ // 답변 피드백 처리 (GOOD: 해결됨 / BAD: 해결 안 됨 + 1회 재생성) const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { if (!msg.serverId || loading || feedbackBusyId || isPreviewMode) return; - if (msg.feedback === rating) return; + + const canRegenerate = + rating === "BAD" && !msg.regeneratedAnswer && !msg.regenerationUsed; + // 같은 피드백 재클릭: 재생성이 남아있는 BAD가 아니면 무시 (재생성 실패 후 재시도 허용) + if (msg.feedback === rating && !canRegenerate) return; setFeedbackBusyId(msg.id); try { - await submitMessageFeedback(msg.serverId, rating); - setMessages((prev) => - prev.map((m) => (m.id === msg.id ? { ...m, feedback: rating } : m)), - ); - if ( - rating === "BAD" && - !msg.regeneratedAnswer && - !msg.regenerationUsed - ) { + 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) { From 90168820ee435d76e2111eae902c921c273d400d Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 13:36:37 -0700 Subject: [PATCH 3/6] feat: add feedback prompt text next to thumbs buttons --- src/widget/ChatWidget.tsx | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index 91a155b..01c2ffd 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -998,13 +998,24 @@ export default function ChatWidget({ disabled={loading || feedbackBusyId !== null} onClick={() => void handleFeedback(m, "BAD")} /> - {m.regeneratedAnswer && ( + {m.regeneratedAnswer ? ( 다시 생성된 답변 + ) : ( + !m.feedback && ( + + 답변이 도움이 되었나요? + + ) )} )} From 21614da2eea280d6d282183b1afaa0af7ec19af7 Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 14:22:16 -0700 Subject: [PATCH 4/6] feat: replace original answer with regenerated one on BAD feedback --- src/widget/ChatWidget.tsx | 50 ++++++++++++++++----------------------- src/widget/types.ts | 1 - 2 files changed, 20 insertions(+), 31 deletions(-) diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index 01c2ffd..8c389c4 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -395,21 +395,20 @@ export default function ChatWidget({ const regenerateAnswer = async (originalMsg: ChatMessage) => { if (!originalMsg.serverId || loading) return; + // 이전(원본) 답변을 제거하고 그 자리에서 재생성 답변을 스트리밍 const regenMsgId = uid(); setMessages((prev) => - prev - .map((msg) => - msg.id === originalMsg.id ? { ...msg, regenerationUsed: true } : msg, - ) - .concat([ - { - id: regenMsgId, - role: "assistant", - text: "", - sources: [], - regeneratedAnswer: true, - }, - ]), + prev.map((msg): ChatMessage => + msg.id === originalMsg.id + ? { + id: regenMsgId, + role: "assistant", + text: "", + sources: [], + regeneratedAnswer: true, + } + : msg, + ), ); regeneratingRef.current = true; setLoading(true); @@ -462,23 +461,15 @@ export default function ChatWidget({ } console.error("Failed to regenerate answer:", error); - // 재시도할 수 있도록 재생성 사용 표시를 되돌리고, - // 이미 받은 텍스트가 있으면 유지, 없으면 에러 메시지로 교체 + // 이미 받은 텍스트가 있으면 그대로 유지, + // 아무것도 못 받았으면 원본 답변을 복원 (다시 👎로 재시도 가능) setMessages((prev) => { - const reverted = prev.map((msg) => - msg.id === originalMsg.id ? { ...msg, regenerationUsed: false } : msg, - ); - const existingMsg = reverted.find((msg) => msg.id === regenMsgId); - if (existingMsg && existingMsg.text) { - return reverted; + const regenMsg = prev.find((msg) => msg.id === regenMsgId); + if (regenMsg && regenMsg.text) { + return prev; } - return reverted.map((msg) => - msg.id === regenMsgId - ? { - ...msg, - text: "죄송합니다. 답변을 다시 생성하는 중 오류가 발생했습니다.", - } - : msg, + return prev.map((msg) => + msg.id === regenMsgId ? { ...originalMsg } : msg, ); }); } finally { @@ -490,8 +481,7 @@ export default function ChatWidget({ const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { if (!msg.serverId || loading || feedbackBusyId || isPreviewMode) return; - const canRegenerate = - rating === "BAD" && !msg.regeneratedAnswer && !msg.regenerationUsed; + const canRegenerate = rating === "BAD" && !msg.regeneratedAnswer; // 같은 피드백 재클릭: 재생성이 남아있는 BAD가 아니면 무시 (재생성 실패 후 재시도 허용) if (msg.feedback === rating && !canRegenerate) return; diff --git a/src/widget/types.ts b/src/widget/types.ts index c0e13e5..41d19a0 100644 --- a/src/widget/types.ts +++ b/src/widget/types.ts @@ -20,7 +20,6 @@ export interface ChatMessage { serverId?: string; // 백엔드에 저장된 메시지 ID (피드백/재생성 API 호출용) feedback?: FeedbackRating | null; // 현재 저장된 피드백 regeneratedAnswer?: boolean; // 재생성으로 만들어진 답변 (다시 재생성 불가) - regenerationUsed?: boolean; // 이 답변에 대한 재생성이 이미 수행됨 } export interface ColorTheme { From 1ac7e1a8751c1e949241b3f59919f1b9eb98d7db Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 16:13:58 -0700 Subject: [PATCH 5/6] fix: disable regenerate action for previous assistant messages --- src/widget/ChatWidget.tsx | 29 ++++++++++++++++++++++++++--- 1 file changed, 26 insertions(+), 3 deletions(-) diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index 8c389c4..4b94d8d 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -370,6 +370,11 @@ 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, @@ -479,7 +484,15 @@ export default function ChatWidget({ // 답변 피드백 처리 (GOOD: 해결됨 / BAD: 해결 안 됨 + 1회 재생성) const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { - if (!msg.serverId || loading || feedbackBusyId || isPreviewMode) return; + if ( + !msg.serverId || + msg.id !== latestFeedbackMessageId || + loading || + feedbackBusyId || + isPreviewMode + ) { + return; + } const canRegenerate = rating === "BAD" && !msg.regeneratedAnswer; // 같은 피드백 재클릭: 재생성이 남아있는 BAD가 아니면 무시 (재생성 실패 후 재시도 허용) @@ -979,13 +992,23 @@ export default function ChatWidget({ void handleFeedback(m, "GOOD")} /> void handleFeedback(m, "BAD")} /> {m.regeneratedAnswer ? ( From c315abadd1094b4c985a8a49204b9cfb3e460742 Mon Sep 17 00:00:00 2001 From: Junseong Lee Date: Thu, 9 Jul 2026 19:58:10 -0700 Subject: [PATCH 6/6] fix: prevent only regenerate for past answer --- src/widget/ChatWidget.tsx | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/widget/ChatWidget.tsx b/src/widget/ChatWidget.tsx index 4b94d8d..d2ced31 100644 --- a/src/widget/ChatWidget.tsx +++ b/src/widget/ChatWidget.tsx @@ -486,7 +486,6 @@ export default function ChatWidget({ const handleFeedback = async (msg: ChatMessage, rating: FeedbackRating) => { if ( !msg.serverId || - msg.id !== latestFeedbackMessageId || loading || feedbackBusyId || isPreviewMode @@ -494,7 +493,10 @@ export default function ChatWidget({ return; } - const canRegenerate = rating === "BAD" && !msg.regeneratedAnswer; + const canRegenerate = + msg.id === latestFeedbackMessageId && + rating === "BAD" && + !msg.regeneratedAnswer; // 같은 피드백 재클릭: 재생성이 남아있는 BAD가 아니면 무시 (재생성 실패 후 재시도 허용) if (msg.feedback === rating && !canRegenerate) return; @@ -995,7 +997,6 @@ export default function ChatWidget({ disabled={ loading || feedbackBusyId !== null || - m.id !== latestFeedbackMessageId || isPreviewMode } onClick={() => void handleFeedback(m, "GOOD")} @@ -1006,7 +1007,6 @@ export default function ChatWidget({ disabled={ loading || feedbackBusyId !== null || - m.id !== latestFeedbackMessageId || isPreviewMode } onClick={() => void handleFeedback(m, "BAD")}