From 1281a040ef0cf5668c3d661a3b40cd0b88c05b22 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 30 Aug 2026 06:09:38 +0530 Subject: [PATCH 1/2] fix(react-widget): integrate with current /stream contract --- .../src/components/DocsGPTWidget.tsx | 887 +++++++++++++----- .../react-widget/src/requests/streamingApi.ts | 67 +- extensions/react-widget/src/types/index.ts | 11 + .../react-widget/src/utils/streamEvents.ts | 50 + 4 files changed, 753 insertions(+), 262 deletions(-) create mode 100644 extensions/react-widget/src/utils/streamEvents.ts diff --git a/extensions/react-widget/src/components/DocsGPTWidget.tsx b/extensions/react-widget/src/components/DocsGPTWidget.tsx index ad7075ce..392f8e70 100644 --- a/extensions/react-widget/src/components/DocsGPTWidget.tsx +++ b/extensions/react-widget/src/components/DocsGPTWidget.tsx @@ -1,5 +1,5 @@ 'use client'; -import React, { useRef, useState } from 'react'; +import React, { useRef } from 'react'; import DOMPurify from 'dompurify'; import styled, { keyframes, css } from 'styled-components'; import { @@ -19,6 +19,12 @@ import { import { fetchAnswerStreaming, sendFeedback } from '../requests/streamingApi'; import { ThemeProvider } from 'styled-components'; import MarkdownIt from 'markdown-it'; +import { + prettifyName, + toolNames, + workflowStepLabel, + type StreamEvent, +} from '../utils/streamEvents'; const LikeIcon = (props: React.SVGProps) => ( ) => ( ); +const CopyIcon = (props: React.SVGProps) => ( + + + + +); + +const CheckIcon = (props: React.SVGProps) => ( + + + +); + +const RetryIcon = (props: React.SVGProps) => ( + + + + +); + +const StopIcon = (props: React.SVGProps) => ( + + + +); + +const ArrowDownIcon = (props: React.SVGProps) => ( + + + +); + const themes = { dark: { bg: '#222327', @@ -74,6 +163,10 @@ const themes = { text: '#A1A1AA', bg: '#38383b', }, + shimmer: { + base: '#A1A1AA', + highlight: '#FAFAFA', + }, }, light: { bg: '#fff', @@ -86,6 +179,10 @@ const themes = { text: '#A1A1AA', bg: '#F6F6F6', }, + shimmer: { + base: '#71717A', + highlight: '#D4D4D8', + }, }, }; @@ -186,6 +283,16 @@ const scaleAnimation = keyframes` transform: scale(1); } `; +const settleIn = keyframes` + from { + opacity: 0; + transform: translateY(6px); + } + to { + opacity: 1; + transform: none; + } +`; const Overlay = styled.div` position: fixed; top: 0; @@ -344,20 +451,47 @@ const Description = styled.p` `; const Conversation = styled.div` - height: 70%; + height: 100%; border-radius: 6px; text-align: left; overflow-y: auto; scrollbar-width: thin; scrollbar-color: ${(props) => props.theme.secondary.bg} transparent; /* thumb color track color */ `; -const Feedback = styled.div` - background-color: transparent; - font-weight: normal; - gap: 12px; +const ActionsRow = styled.div` display: flex; - padding: 6px; + align-items: center; + gap: 2px; clear: both; + padding: 0 2px 2px 2px; +`; +const ActionButton = styled.button` + display: inline-flex; + align-items: center; + justify-content: center; + gap: 4px; + height: 24px; + padding: 0 6px; + border: none; + border-radius: 6px; + background-color: transparent; + color: ${(props) => props.theme.secondary.text}; + font-size: 11px; + font-family: sans-serif; + cursor: pointer; + transition: + background-color 0.15s ease, + color 0.15s ease; + + &:hover { + background-color: ${(props) => props.theme.secondary.bg}; + color: ${(props) => props.theme.primary.text}; + } + + &:focus-visible { + outline: 2px solid #8860db; + outline-offset: 1px; + } `; const MessageBubble = styled.div<{ $type: MESSAGE_TYPE }>` display: block; @@ -366,8 +500,16 @@ const MessageBubble = styled.div<{ $type: MESSAGE_TYPE }>` width: 100%; float: right; margin: 0px; - &:hover ${Feedback} * { - visibility: visible; + animation: ${settleIn} 0.22s ease-out; + + @media (prefers-reduced-motion: reduce) { + animation: none; + } + /* Contains the floated Message so the bubble keeps its height. */ + &::after { + content: ''; + display: table; + clear: both; } `; const Message = styled.div<{ $type: MESSAGE_TYPE }>` @@ -389,6 +531,16 @@ const Message = styled.div<{ $type: MESSAGE_TYPE }>` overflow-wrap: break-word; `; const Markdown = styled.div` + a { + color: #9971ec; + text-decoration: underline; + text-underline-offset: 2px; + } + + a:hover { + color: #8860db; + } + pre { padding: 8px; width: 90%; @@ -503,37 +655,144 @@ const ErrorAlert = styled.div` border-radius: 6px; justify-content: space-evenly; `; -//dot loading animation -const dotBounce = keyframes` - 0%, 80%, 100% { - transform: translateY(0); - } - 40% { - transform: translateY(-5px); +const shimmerSweep = keyframes` + to { + background-position: -200% 0; } `; +const statusPulse = keyframes` + 0%, 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +`; +const StatusLine = styled.div` + display: flex; + align-items: center; + gap: 8px; + min-width: 0; + /* Message floats; without this the row wraps beside it. */ + clear: both; + margin: 4px; + font-size: 12px; + font-family: sans-serif; + color: ${(props) => props.theme.secondary.text}; +`; +const StatusDot = styled.span` + flex-shrink: 0; + width: 6px; + height: 6px; + border-radius: 9999px; + background-color: ${(props) => props.theme.secondary.text}; + opacity: 0.5; + animation: ${statusPulse} 2s cubic-bezier(0.4, 0, 0.6, 1) infinite; -const DotAnimation = styled.div` - display: inline-block; - animation: ${dotBounce} 1s infinite ease-in-out; + @media (prefers-reduced-motion: reduce) { + animation: none; + } `; -// delay classes as styled components -const Delay = styled(DotAnimation)<{ $delay: number }>` - animation-delay: ${(props) => props.$delay + 'ms'}; +// Gradient clipped to the glyphs and swept across them. +const ShimmerText = styled.span` + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + background-image: ${(props) => { + const { base, highlight } = props.theme.shimmer!; + return `linear-gradient(90deg, ${base} 0%, ${base} 40%, ${highlight} 50%, ${base} 60%, ${base} 100%)`; + }}; + background-size: 200% 100%; + -webkit-background-clip: text; + background-clip: text; + color: transparent; + animation: ${shimmerSweep} 2s linear infinite; + + @media (prefers-reduced-motion: reduce) { + animation: none; + background-image: none; + color: ${(props) => props.theme.secondary.text}; + } +`; +// Reasoning trace from `thought` events. +const Thought = styled.div` + clear: both; + margin: 4px; + padding-left: 8px; + border-left: 2px solid ${(props) => props.theme.secondary.bg}; + font-size: 12px; + font-family: sans-serif; + font-style: italic; + line-height: 1.5; + white-space: pre-wrap; + color: ${(props) => props.theme.secondary.text}; +`; +const RetryButton = styled.button` + display: inline-flex; + align-items: center; + gap: 6px; + margin-top: 8px; + padding: 6px 10px; + border: 1px solid #b91c1c33; + border-radius: 8px; + background-color: transparent; + color: #b91c1c; + font-size: 12px; + font-family: sans-serif; + cursor: pointer; + transition: background-color 0.15s ease; + + &:hover { + background-color: #b91c1c14; + } +`; +// Shown only while scrolled away from the latest turn. +const ScrollToLatest = styled.button` + position: absolute; + /* Auto-margin centring, not translateX: settleIn owns transform. */ + left: 0; + right: 0; + bottom: 12px; + margin: 0 auto; + width: 30px; + height: 30px; + padding: 0; + display: flex; + align-items: center; + justify-content: center; + border: none; + border-radius: 9999px; + background-color: #8860db; + color: #fff; + line-height: 0; + cursor: pointer; + box-shadow: 0 4px 14px rgba(0, 0, 0, 0.22); + animation: ${settleIn} 0.18s ease-out; + z-index: 2; +`; +const ConversationArea = styled.div` + position: relative; + flex: 1; + min-height: 0; `; const PromptContainer = styled.form` - background-color: transparent; + /* Sole source of the gap above the composer. */ + margin: 16px 12px 0 12px; + padding: 5px 5px 5px 4px; + background-color: ${(props) => props.theme.secondary.bg}; + border: none; + border-radius: 14px; min-height: ${(props) => props.theme.dimensions!.size == 'large' ? '40px' : '23px'}; max-height: 150px; display: flex; align-items: end; - justify-content: space-evenly; `; const StyledTextarea = styled.textarea` box-sizing: border-box; width: 100%; - border: 1px solid #686877; + border: none; padding: ${(props) => props.theme.dimensions!.size === 'large' ? '18px 12px 14px 12px' @@ -792,7 +1051,7 @@ export const WidgetCore = ({ description = "DocsGPT's AI Chatbot is here to help", heroTitle = 'Welcome to DocsGPT !', heroDescription = 'This chatbot is built with DocsGPT and utilises GenAI, please review important information using sources.', - size = 'small', + size = 'medium', theme = 'dark', collectFeedback = true, isOpen = false, @@ -807,8 +1066,10 @@ export const WidgetCore = ({ const [conversationId, setConversationId] = React.useState( null, ); - const [eventInterrupt, setEventInterrupt] = React.useState(false); //click or scroll by user while autoScrolling - const [, setHasScrolledToLast] = useState(true); + // Auto-follow the stream only while already near the bottom. + const [isPinnedToLatest, setIsPinnedToLatest] = React.useState(true); + const [copiedIndex, setCopiedIndex] = React.useState(null); + const abortRef = useRef(null); const isBubbleHovered = useRef(false); const conversationRef = useRef(null); @@ -837,43 +1098,38 @@ export const WidgetCore = ({ } }, [isOpen]); - const handleUserInterrupt = () => { - if (!eventInterrupt && status === 'loading') setEventInterrupt(true); - }; + // Beyond this the reader is deliberately looking away. + const STICK_THRESHOLD_PX = 48; - const scrollIntoView = () => { - if (!conversationRef?.current || eventInterrupt) return; + const distanceFromBottom = (el: HTMLDivElement) => + el.scrollHeight - el.scrollTop - el.clientHeight; - if ( - status === 'idle' || - !queries.length || - !queries[queries.length - 1].response - ) { - conversationRef.current.scrollTo({ - behavior: 'smooth', - top: conversationRef.current.scrollHeight, - }); - } else { - conversationRef.current.scrollTop = conversationRef.current.scrollHeight; - } - setHasScrolledToLast(true); - }; - - const checkScroll = () => { + const scrollToLatest = (smooth = true) => { const el = conversationRef.current; if (!el) return; - const isBottom = el.scrollHeight - el.scrollTop - el.clientHeight < 10; - setHasScrolledToLast(isBottom); + el.scrollTo({ + top: el.scrollHeight, + behavior: smooth ? 'smooth' : 'auto', + }); + setIsPinnedToLatest(true); + }; + + // Explicit request: land immediately rather than easing. + const jumpToLatest = () => scrollToLatest(false); + + const handleConversationScroll = () => { + const el = conversationRef.current; + if (!el) return; + setIsPinnedToLatest(distanceFromBottom(el) < STICK_THRESHOLD_PX); }; React.useEffect(() => { - if (!eventInterrupt) scrollIntoView(); - - conversationRef.current?.addEventListener('scroll', checkScroll); - return () => { - conversationRef.current?.removeEventListener('scroll', checkScroll); - }; - }, [queries.length, queries[queries.length - 1]?.response]); + const el = conversationRef.current; + if (!el || !isPinnedToLatest) return; + // Easing per token never catches up, so jump while streaming. + if (status === 'loading') el.scrollTop = el.scrollHeight; + else scrollToLatest(); + }, [queries.length, queries[queries.length - 1]?.response, status]); async function handleFeedback(feedback: FEEDBACK, index: number) { let query = queries[index]; @@ -940,41 +1196,112 @@ export const WidgetCore = ({ } } + const stopGenerating = () => { + abortRef.current?.abort(); + abortRef.current = null; + setStatus('idle'); + }; + async function stream(question: string) { setStatus('loading'); + const controller = new AbortController(); + abortRef.current = controller; try { await fetchAnswerStreaming({ + signal: controller.signal, question: question, apiKey: apiKey, apiHost: apiHost, history: queries, conversationId: conversationId, onEvent: (event: MessageEvent) => { - const data = JSON.parse(event.data); - // check if the 'end' event has been received - if (data.type === 'end') { - setStatus('idle'); - } else if (data.type === 'id') { - setConversationId(data.id); - } else if (data.type === 'error') { - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].error = data.error; - setQueries(updatedQueries); - setStatus('idle'); - } else if (data.type === 'source' && showSources) { - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].sources = data.source; - setQueries(updatedQueries); - } else { - const result = data.answer ? data.answer : ''; //Fallback to an empty string if data.answer is undefined - const streamingResponse = queries[queries.length - 1].response - ? queries[queries.length - 1].response - : ''; - const updatedQueries = [...queries]; - updatedQueries[updatedQueries.length - 1].response = - streamingResponse + result; - setQueries(updatedQueries); + let data: StreamEvent; + try { + data = JSON.parse(event.data); + } catch { + // One malformed frame must not fail the whole turn. + return; } + + const patch = (change: (query: Query) => void) => { + setQueries((prev: Query[]) => { + if (prev.length === 0) return prev; + const updated = [...prev]; + const current = { ...updated[updated.length - 1] }; + change(current); + updated[updated.length - 1] = current; + return updated; + }); + }; + + const appendAnswer = (d: StreamEvent) => { + if (typeof d.answer !== 'string') return; + patch((query) => { + query.response = (query.response ?? '') + d.answer; + }); + }; + + const noteToolCalls = (calls: unknown) => { + const names = toolNames(calls); + if (names.length === 0) return; + patch((query) => { + query.toolCalls = [ + ...new Set([...(query.toolCalls ?? []), ...names]), + ]; + }); + }; + + const handlers: Record void> = { + answer: appendAnswer, + end: () => setStatus('idle'), + id: (d) => setConversationId(d.id as string), + error: (d) => { + patch((query) => { + query.error = d.error as string; + }); + setStatus('idle'); + }, + source: (d) => { + if (!showSources) return; + patch((query) => { + query.sources = d.source as Query['sources']; + }); + }, + thought: (d) => + patch((query) => { + query.thought = + (query.thought ?? '') + ((d.thought as string) ?? ''); + }), + notice: (d) => + patch((query) => { + query.notice = (d.notice as string) ?? ''; + }), + // Workflow agents report progress per node instead of `notice`. + workflow_step: (d) => { + const label = workflowStepLabel(d); + if (label) { + patch((query) => { + query.notice = label; + }); + } + }, + tool_calls: (d) => noteToolCalls(d.tool_calls), + tool_call: (d) => noteToolCalls([d.data]), + // Blocked content must leave the screen, reasoning included. + guardrail: (d) => { + if (!d.retract) return; + patch((query) => { + query.response = ''; + query.thought = ''; + }); + }, + // Recorded server-side; nothing to render. + message_id: () => undefined, + }; + + // Unknown types are ignored; untyped frames are answer deltas. + const handle = (data.type && handlers[data.type]) || appendAnswer; + handle(data); }, }); } catch { @@ -990,11 +1317,38 @@ export const WidgetCore = ({ const appendQuery = async (userQuery: string) => { if (!userQuery) return; - setEventInterrupt(false); + setIsPinnedToLatest(true); queries.push({ prompt: userQuery }); setPrompt(''); await stream(userQuery); }; + const handleCopy = async (text: string, index: number) => { + try { + await navigator.clipboard.writeText(text); + setCopiedIndex(index); + setTimeout( + () => setCopiedIndex((cur) => (cur === index ? null : cur)), + 1600, + ); + } catch (err) { + console.error('Copy failed:', err); + } + }; + + // Re-runs the turn in place instead of appending a duplicate prompt. + const handleRetry = async (index: number) => { + if (status === 'loading') return; + const prompt = queries[index]?.prompt; + if (!prompt) return; + setQueries((prev: Query[]) => { + const updated = [...prev]; + updated[index] = { prompt }; + return updated.slice(0, index + 1); + }); + setIsPinnedToLatest(true); + await stream(prompt); + }; + // submit handler const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); @@ -1053,6 +1407,21 @@ export const WidgetCore = ({ 'https://d3dg1063dc54p9.cloudfront.net/cute-docsgpt.png'; }; + const renderStatusLine = (query: Query, index: number) => { + if (status !== 'loading' || index !== queries.length - 1) return null; + // A notice/node title is more specific than "Thinking", but goes stale + // once tokens arrive. + const label = query.response + ? 'Generating\u2026' + : (query.notice ?? 'Thinking\u2026'); + return ( + + + {label} + + ); + }; + const dimensions = typeof size === 'object' && 'custom' in size ? sizesConfig.getCustom(size.custom) @@ -1093,162 +1462,195 @@ export const WidgetCore = ({ - - {queries.length > 0 ? ( - queries?.map((query, index) => { - return ( - - {query.prompt && ( - - - {query.prompt} - - - )} - {query.response ? ( - { - isBubbleHovered.current = true; - }} - $type="ANSWER" - > - {showSources && - query.sources && - query.sources.length > 0 && - query.sources.some( - (source) => source.source !== 'local', - ) && ( - source.source !== 'local', - )} - /> - )} - + + {queries.length > 0 ? ( + queries?.map((query, index) => { + return ( + + {query.prompt && ( + + + {query.prompt} + + + )} + {query.response ? ( + { + isBubbleHovered.current = true; + }} $type="ANSWER" - ref={ - index === queries.length - 1 - ? endMessageRef - : null - } > - - - - {collectFeedback && ( - - - - - )} - - ) : ( -
- {query.error ? ( - - -
-
Network Error
- - {query.error} - -
-
- ) : ( - - + {renderStatusLine(query, index)} + + + { + e.stopPropagation(); + handleCopy(query.response ?? '', index); + }} + aria-label={ + copiedIndex === index + ? 'Copied' + : 'Copy answer' + } > - . - . - . - - - )} -
- )} -
- ); - }) - ) : ( - + {copiedIndex === index ? ( + + ) : ( + + )} + + + {collectFeedback && ( + <> + { + e.stopPropagation(); + handleFeedback('LIKE', index); + }} + aria-label="Good response" + aria-pressed={query.feedback === 'LIKE'} + > + + + { + e.stopPropagation(); + handleFeedback('DISLIKE', index); + }} + aria-label="Bad response" + aria-pressed={query.feedback === 'DISLIKE'} + > + + + + )} + + + ) : ( +
+ {query.error ? ( + + +
+
Network Error
+ + {query.error} + + handleRetry(index)} + disabled={status === 'loading'} + > + + Try again + +
+
+ ) : ( + + {query.thought && ( + {query.thought} + )} + {renderStatusLine(query, index)} + + )} +
+ )} + + ); + }) + ) : ( + + )} +
+ {!isPinnedToLatest && queries.length > 0 && ( + + + )} - +
- - - + {status === 'loading' ? ( + + + + ) : ( + + + + )} Powered by  diff --git a/extensions/react-widget/src/requests/streamingApi.ts b/extensions/react-widget/src/requests/streamingApi.ts index e27eaa3f..b3c39105 100644 --- a/extensions/react-widget/src/requests/streamingApi.ts +++ b/extensions/react-widget/src/requests/streamingApi.ts @@ -11,6 +11,7 @@ interface FetchAnswerStreamingProps { conversationId?: string | null; apiHost?: string; onEvent?: (event: MessageEvent) => void; + signal?: AbortSignal; } export interface FeedbackPayload { @@ -31,13 +32,17 @@ export function fetchAnswerStreaming({ onEvent = () => { console.log('Event triggered, but no handler provided.'); }, + signal, }: FetchAnswerStreamingProps): Promise { return new Promise((resolve, reject) => { const body = { question: question, - history: JSON.stringify(history), + history: JSON.stringify( + history + .filter((item) => item.prompt && item.response) + .map(({ prompt, response }) => ({ prompt, response })), + ), conversation_id: conversationId, - model: 'default', api_key: apiKey, }; fetch(apiHost + '/stream', { @@ -46,49 +51,61 @@ export function fetchAnswerStreaming({ 'Content-Type': 'application/json', }, body: JSON.stringify(body), + signal, }) .then((response) => { if (!response.body) throw Error('No response body'); const reader = response.body.getReader(); const decoder = new TextDecoder('utf-8'); - let counter = 0; // eslint-disable-line @typescript-eslint/no-unused-vars + + let buffer = ''; + + const emit = (rawLine: string) => { + const line = rawLine.trim(); + if (line === '') return; + if (!line.startsWith('data:')) return; + + const payload = line.substring(5).trim(); + if (payload === '' || payload === '[DONE]') return; + + onEvent(new MessageEvent('message', { data: payload })); + }; + const processStream = ({ done, value, }: ReadableStreamReadResult) => { if (done) { + if (buffer.trim() !== '') emit(buffer); + buffer = ''; resolve(); return; } + buffer += decoder.decode(value, { stream: true }); - counter += 1; + const lines = buffer.split('\n'); + buffer = lines.pop() ?? ''; + for (const line of lines) emit(line); - const chunk = decoder.decode(value); - - const lines = chunk.split('\n'); - - for (let line of lines) { - if (line.trim() == '') { - continue; - } - if (line.startsWith('data:')) { - line = line.substring(5); - } - - const messageEvent = new MessageEvent('message', { - data: line, - }); - - onEvent(messageEvent); // handle each message - } - - reader.read().then(processStream).catch(reject); + reader.read().then(processStream).catch(onReadError); }; - reader.read().then(processStream).catch(reject); + const onReadError = (error: unknown) => { + if (signal?.aborted || (error as Error)?.name === 'AbortError') { + resolve(); + return; + } + reject(error); + }; + + reader.read().then(processStream).catch(onReadError); }) .catch((error) => { + if (signal?.aborted || (error as Error)?.name === 'AbortError') { + resolve(); + return; + } console.error('Connection failed:', error); reject(error); }); diff --git a/extensions/react-widget/src/types/index.ts b/extensions/react-widget/src/types/index.ts index 9fb60fff..b0e7e0db 100644 --- a/extensions/react-widget/src/types/index.ts +++ b/extensions/react-widget/src/types/index.ts @@ -14,6 +14,11 @@ declare module 'styled-components' { }; /** Present only in SearchBar theme */ name?: string; + /** Gradient stops for the swept status text. */ + shimmer?: { + base: string; + highlight: string; + }; /** Present only in DocsGPTWidget theme (always provided when these styled components render) */ dimensions?: { size: string; @@ -41,6 +46,12 @@ export interface Query { sources?: { title: string; text: string; source: string }[]; conversationId?: string | null; title?: string | null; + /** Accumulated from thought events. */ + thought?: string; + /** Latest notice or running workflow node; drives the status line. */ + notice?: string; + /** Tool names from tool_calls / tool_call. */ + toolCalls?: string[]; } export interface WidgetProps { diff --git a/extensions/react-widget/src/utils/streamEvents.ts b/extensions/react-widget/src/utils/streamEvents.ts new file mode 100644 index 00000000..ee24885f --- /dev/null +++ b/extensions/react-widget/src/utils/streamEvents.ts @@ -0,0 +1,50 @@ +export interface StreamEvent { + type?: string; + [key: string]: unknown; +} + +export interface ToolCallEvent { + action_name?: string; + tool_name?: string; +} + +/** internal_search -> Internal Search */ +export const prettifyName = (name: string): string => + name + .split(/[_\s]+/) + .filter(Boolean) + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(' '); + +/** + * Status-line text for a running workflow node, or null when the node is + * scaffolding the user gains nothing from seeing. Keyed on node_type (a + * closed enum) rather than node_title, which is free text like "agent node". + */ +export const workflowStepLabel = (event: StreamEvent): string | null => { + if (event.status !== 'running') return null; + switch (event.node_type) { + case 'agent': + return 'Thinking…'; + case 'code': + return 'Running code…'; + case 'condition': + return 'Deciding next step…'; + case 'start': + case 'end': + case 'state': + case 'note': + return null; + default: + return typeof event.node_title === 'string' && event.node_title + ? `${prettifyName(event.node_title)}…` + : null; + } +}; + +export const toolNames = (calls: unknown): string[] => { + if (!Array.isArray(calls)) return []; + return calls + .map((call: ToolCallEvent | null) => call?.action_name ?? call?.tool_name) + .filter((name): name is string => Boolean(name)); +}; From 57e6836ec08551e398ecb2e007c12b22af3944a0 Mon Sep 17 00:00:00 2001 From: ManishMadan2882 Date: Sun, 30 Aug 2026 06:22:20 +0530 Subject: [PATCH 2/2] fix(feedback): accept widget api_key on /api/feedback --- application/api/user/conversations/routes.py | 20 ++++- tests/api/user/test_conversations.py | 85 ++++++++++++++++++++ 2 files changed, 102 insertions(+), 3 deletions(-) diff --git a/application/api/user/conversations/routes.py b/application/api/user/conversations/routes.py index 5e474538..d690dcec 100644 --- a/application/api/user/conversations/routes.py +++ b/application/api/user/conversations/routes.py @@ -11,6 +11,7 @@ from application.api.answer.services.conversation_service import ( TERMINATED_RESPONSE_PLACEHOLDER, ) from application.storage.db.base_repository import looks_like_uuid, row_to_dict +from application.storage.db.repositories.agents import AgentsRepository from application.storage.db.repositories.attachments import AttachmentsRepository from application.storage.db.repositories.conversations import ConversationsRepository from application.storage.db.repositories.message_events import MessageEventsRepository @@ -357,10 +358,20 @@ class SubmitFeedback(Resource): description="Submit feedback for a conversation", ) def post(self): + data = request.get_json() or {} decoded_token = request.decoded_token + # api_key callers (widgets) carry no JWT; resolve the key to its owner. + scoped_api_key = None if not decoded_token: - return make_response(jsonify({"success": False}), 401) - data = request.get_json() + api_key = data.get("api_key") + agent = None + if api_key: + with db_readonly() as conn: + agent = AgentsRepository(conn).find_by_key(api_key) + if not agent: + return make_response(jsonify({"success": False}), 401) + decoded_token = {"sub": agent.get("user_id")} + scoped_api_key = api_key required_fields = ["feedback", "conversation_id", "question_index"] missing_fields = check_required_fields(data, required_fields) if missing_fields: @@ -387,7 +398,10 @@ class SubmitFeedback(Resource): with db_session() as conn: repo = ConversationsRepository(conn) conv = repo.get_any(data["conversation_id"], user_id) - if conv is None: + # A key may only rate conversations it created. + if conv is None or ( + scoped_api_key and conv.get("api_key") != scoped_api_key + ): return make_response( jsonify({"success": False, "message": "Not found"}), 404 ) diff --git a/tests/api/user/test_conversations.py b/tests/api/user/test_conversations.py index 8667f6eb..32506df4 100644 --- a/tests/api/user/test_conversations.py +++ b/tests/api/user/test_conversations.py @@ -769,3 +769,88 @@ class TestSubmitFeedbackHappy: response = SubmitFeedback().post() assert response.status_code == 400 + + +@pytest.mark.unit +class TestSubmitFeedbackWithApiKey: + """api_key callers carry no JWT.""" + + def _seed_agent_with_key(self, pg_conn, owner, key): + from application.storage.db.repositories.agents import AgentsRepository + + return AgentsRepository(pg_conn).create(owner, "widget", "published", key=key) + + def _post(self, app, pg_conn, payload): + from application.api.user.conversations.routes import SubmitFeedback + + with _patch_conversations_db(pg_conn), app.test_request_context("/api/feedback", method="POST", json=payload): + from flask import request + + request.decoded_token = None + return SubmitFeedback().post() + + def test_valid_key_rates_its_own_conversation(self, app, pg_conn): + from application.storage.db.repositories.conversations import ( + ConversationsRepository, + ) + + owner, key = "owner-fb-key", "agent-key-ok" + self._seed_agent_with_key(pg_conn, owner, key) + repo = ConversationsRepository(pg_conn) + conv_id = str(repo.create(owner, name="via widget", api_key=key)["id"]) + repo.append_message(conv_id, {"prompt": "p", "response": "r"}) + + response = self._post( + app, + pg_conn, + { + "feedback": "LIKE", + "question_index": 0, + "conversation_id": conv_id, + "api_key": key, + }, + ) + + assert response.status_code == 200 + fb = repo.get_messages(conv_id)[0].get("feedback") + assert fb and fb.get("text") == "like" + + def test_key_cannot_rate_owner_conversation_it_did_not_create(self, app, pg_conn): + from application.storage.db.repositories.conversations import ( + ConversationsRepository, + ) + + owner, key = "owner-fb-scope", "agent-key-scope" + self._seed_agent_with_key(pg_conn, owner, key) + # Owner's conversation, not created with the key. + conv_id = _seed_conversation(pg_conn, owner, name="owner private") + ConversationsRepository(pg_conn).append_message(conv_id, {"prompt": "p", "response": "r"}) + + response = self._post( + app, + pg_conn, + { + "feedback": "LIKE", + "question_index": 0, + "conversation_id": conv_id, + "api_key": key, + }, + ) + + assert response.status_code == 404 + + def test_unknown_key_is_unauthorized(self, app, pg_conn): + conv_id = _seed_conversation(pg_conn, "owner-fb-bad") + + response = self._post( + app, + pg_conn, + { + "feedback": "LIKE", + "question_index": 0, + "conversation_id": conv_id, + "api_key": "no-such-key", + }, + ) + + assert response.status_code == 401