Skip to content

Commit 9047030

Browse files
authored
feat(docs): user feedback + surfaced error frames in docs ask widget (#23288)
## Summary Two small changes to the `AztecDocsWidget` (the in-docs "Ask Aztec" panel that talks to honk-ai): ### 1. User feedback (thumbs up / down on assistant replies) After an assistant message finishes streaming, render thumbs-up / thumbs-down buttons. Clicking one POSTs to `${apiHost}/api/feedback` with `{ conversation_id, question_index, feedback: "like" | "dislike", api_key }` (matching honk-ai's existing `SubmitFeedback` route in `application/api/answer/routes/feedback.py`). The choice is locked client-side (`feedbackByIndex` per-message-index) so a user can vote once; both buttons become disabled after the first vote, with the active choice highlighted. Files: - `docs/src/components/AztecDocsWidget/Icons.jsx` — `thumbUp`/`thumbDown` SVGs. - `docs/src/components/AztecDocsWidget/Message.jsx` — feedback row, only for assistant messages with a non-empty `response`, hidden while streaming. - `docs/src/components/AztecDocsWidget/Panel.jsx` — passes the `onFeedback` callback down. - `docs/src/components/AztecDocsWidget/index.jsx` — `handleFeedback`, marks the message as voted optimistically and short-circuits repeat clicks. On POST failure, the optimistic mark is reverted and an inline "Couldn't save feedback." note is shown so the user can retry. - `docs/src/components/AztecDocsWidget/sendFeedback.js` — small fetch helper that throws on non-2xx so the caller can surface the failure. ### 2. Surface backend errors to the user honk-ai PR #129 changed `complete_stream` to emit a `type: "error"` SSE frame with the sanitized real cause (timeout / 429 / 503 / …) instead of the generic "Please try again later". The docs widget previously consumed only `answer` / `source` / `id` / `end` frames and silently dropped `error` frames. This PR adds an `onError` path: - `streamAnswer.js` — branch on `type === "error"` and invoke a new `onError(message)` callback, reading `parsed.error` to match honk-ai's frame shape (`{"type": "error", "error": "..."}`). - `index.jsx` — store the error on the active assistant message as `error: <text>`, stop the streaming spinner. - `Message.jsx` — when `message.error` is set, render the body inside a vermillion `[error]` alert box so the user actually sees what went wrong (rate limit, upstream timeout, etc.) instead of a stalled empty bubble. Together with honk-ai #129 this means: backend failures now reach the docs widget user with a real, actionable cause; and we start collecting per-message feedback signal from the docs surface. ## Related - honk-ai #129 (server-side error relay + per-message logging) — required for the error path to deliver useful messages. - Thread context: docs-widget gap identified in the activity report (https://gist.github.com/AztecBot/4932e495f8d622cc3c82c1776e37f891). ## Test plan - [ ] `yarn workspace docs start` and exercise the widget: trigger a normal answer, click thumbs up/down, verify a POST to `/api/feedback` with the expected payload and that both buttons lock after one click. - [ ] Click the opposite thumb after voting; verify nothing happens (no second POST, state unchanged). - [ ] Simulate a backend error frame (e.g. point widget at a stub that emits `{"type":"error","error":"upstream timeout"}`); verify the vermillion alert renders with the real text and the spinner stops. - [ ] Simulate a feedback POST failure (e.g. 500); verify the "Couldn't save feedback." note renders and the user can re-vote. --- *Created by [claudebox](https://claudebox.work/v2/sessions/0334e56dbe6330cd) · group: `slackbot`*
2 parents 455fe06 + eac0cd5 commit 9047030

6 files changed

Lines changed: 221 additions & 4 deletions

File tree

docs/src/components/AztecDocsWidget/Icons.jsx

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,4 +154,34 @@ export const Icons = {
154154
<path d="M3 21l7-7" />
155155
</svg>
156156
),
157+
thumbUp: (p) => (
158+
<svg
159+
viewBox="0 0 24 24"
160+
width={p.size || 13}
161+
height={p.size || 13}
162+
fill={p.filled ? "currentColor" : "none"}
163+
stroke="currentColor"
164+
strokeWidth="2"
165+
strokeLinecap="round"
166+
strokeLinejoin="round"
167+
>
168+
<path d="M7 11v9H4a1 1 0 0 1-1-1v-7a1 1 0 0 1 1-1h3z" />
169+
<path d="M7 11l5-8a2 2 0 0 1 2 2v4h5a2 2 0 0 1 2 2l-2 7a2 2 0 0 1-2 1H7" />
170+
</svg>
171+
),
172+
thumbDown: (p) => (
173+
<svg
174+
viewBox="0 0 24 24"
175+
width={p.size || 13}
176+
height={p.size || 13}
177+
fill={p.filled ? "currentColor" : "none"}
178+
stroke="currentColor"
179+
strokeWidth="2"
180+
strokeLinecap="round"
181+
strokeLinejoin="round"
182+
>
183+
<path d="M17 13V4h3a1 1 0 0 1 1 1v7a1 1 0 0 1-1 1h-3z" />
184+
<path d="M17 13l-5 8a2 2 0 0 1-2-2v-4H5a2 2 0 0 1-2-2l2-7a2 2 0 0 1 2-1h10" />
185+
</svg>
186+
),
157187
};

docs/src/components/AztecDocsWidget/Message.jsx

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,10 +33,46 @@ export function AssistantBody({
3333
text,
3434
sources,
3535
thinking,
36+
error,
3637
tokens,
3738
mdComponents,
39+
feedback,
40+
feedbackError,
41+
onFeedback,
42+
showFeedback,
3843
}) {
3944
const { isInk, accentColor, panelFg, panelFg2 } = tokens;
45+
const feedbackBtn = (kind) => {
46+
const Icon = kind === "like" ? Icons.thumbUp : Icons.thumbDown;
47+
const active = feedback === kind;
48+
const dimmed = feedback && !active;
49+
const label = kind === "like" ? "Helpful" : "Not helpful";
50+
return (
51+
<button
52+
type="button"
53+
onClick={() => onFeedback?.(kind)}
54+
disabled={!onFeedback || !!feedback}
55+
title={label}
56+
aria-label={label}
57+
aria-pressed={active}
58+
style={{
59+
width: 26,
60+
height: 26,
61+
padding: 0,
62+
border: `1px solid ${isInk ? "rgba(242,238,225,0.2)" : "var(--azw-ink-tint-1)"}`,
63+
background: active ? accentColor : "transparent",
64+
color: active ? "var(--azw-ink)" : dimmed ? panelFg2 : panelFg,
65+
cursor: onFeedback && !feedback ? "pointer" : "default",
66+
display: "flex",
67+
alignItems: "center",
68+
justifyContent: "center",
69+
opacity: dimmed ? 0.5 : 1,
70+
}}
71+
>
72+
<Icon size={12} filled={active} />
73+
</button>
74+
);
75+
};
4076
return (
4177
<div
4278
style={{
@@ -107,6 +143,26 @@ export function AssistantBody({
107143
</ReactMarkdown>
108144
) : null}
109145
</div>
146+
{error && (
147+
<div
148+
role="alert"
149+
style={{
150+
marginTop: text ? 10 : 0,
151+
padding: "8px 10px",
152+
border: `1px solid var(--azw-vermillion, ${accentColor})`,
153+
background: isInk
154+
? "rgba(217, 74, 58, 0.12)"
155+
: "rgba(217, 74, 58, 0.08)",
156+
color: "var(--azw-vermillion, #d94a3a)",
157+
fontFamily: "var(--azw-font-sans)",
158+
fontSize: 12.5,
159+
lineHeight: 1.45,
160+
letterSpacing: "-0.01em",
161+
}}
162+
>
163+
{error}
164+
</div>
165+
)}
110166
{sources?.length > 0 && (
111167
<div style={{ marginTop: 10 }}>
112168
<div
@@ -153,6 +209,38 @@ export function AssistantBody({
153209
</div>
154210
</div>
155211
)}
212+
{showFeedback && (
213+
<div
214+
style={{
215+
marginTop: 10,
216+
display: "flex",
217+
alignItems: "center",
218+
gap: 8,
219+
fontFamily: "var(--azw-font-mono)",
220+
fontSize: 10,
221+
letterSpacing: "0.1em",
222+
textTransform: "uppercase",
223+
color: panelFg2,
224+
}}
225+
>
226+
<span>Was this helpful?</span>
227+
<div style={{ display: "flex", gap: 6 }}>
228+
{feedbackBtn("like")}
229+
{feedbackBtn("dislike")}
230+
</div>
231+
{feedback && !feedbackError && (
232+
<span style={{ color: panelFg2 }}>Thanks for the feedback.</span>
233+
)}
234+
{feedbackError && (
235+
<span
236+
style={{ color: "var(--azw-vermillion, #d94a3a)" }}
237+
role="alert"
238+
>
239+
Couldn't save feedback.
240+
</span>
241+
)}
242+
</div>
243+
)}
156244
</div>
157245
</div>
158246
);

docs/src/components/AztecDocsWidget/Panel.jsx

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,10 @@ export default function Panel({
2626
expanded,
2727
onToggleExpanded,
2828
scrollRef,
29+
feedbackByIndex,
30+
feedbackErrorsByIndex,
31+
onFeedback,
32+
conversationId,
2933
}) {
3034
const {
3135
isInk,
@@ -224,16 +228,28 @@ export default function Panel({
224228
const text = isLast && streaming ? streamText : m.response;
225229
const sources = isLast && streaming ? streamSources : m.sources;
226230
const isStreamingLast = isLast && streaming;
231+
const error = isStreamingLast ? null : m.error;
232+
const showFeedback =
233+
!isStreamingLast && !!m.response && !!conversationId;
227234
return (
228235
<React.Fragment key={i}>
229236
<UserBubble text={m.prompt} tokens={tokens} />
230-
{(text || isStreamingLast) && (
237+
{(text || isStreamingLast || error) && (
231238
<AssistantBody
232239
text={text}
233240
sources={sources}
234241
thinking={isStreamingLast}
242+
error={error}
235243
tokens={tokens}
236244
mdComponents={mdComponents}
245+
showFeedback={showFeedback}
246+
feedback={feedbackByIndex?.[i]}
247+
feedbackError={feedbackErrorsByIndex?.[i]}
248+
onFeedback={
249+
showFeedback
250+
? (kind) => onFeedback?.(i, kind)
251+
: undefined
252+
}
237253
/>
238254
)}
239255
</React.Fragment>

docs/src/components/AztecDocsWidget/index.jsx

Lines changed: 50 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import "./styles.css";
33
import { DEFAULT_SUGGESTED, getTheme } from "./theme";
44
import { makeMarkdownComponents } from "./markdown";
55
import { streamAnswer } from "./streamAnswer";
6+
import { sendFeedback } from "./sendFeedback";
67
import LauncherButton from "./LauncherButton";
78
import Panel from "./Panel";
89

@@ -28,6 +29,8 @@ export default function AztecDocsWidget({
2829
const [streamText, setStreamText] = useState("");
2930
const [streamSources, setStreamSources] = useState([]);
3031
const [conversationId, setConversationId] = useState(null);
32+
const [feedbackByIndex, setFeedbackByIndex] = useState({});
33+
const [feedbackErrorsByIndex, setFeedbackErrorsByIndex] = useState({});
3134
const scrollRef = useRef(null);
3235
const abortRef = useRef(null);
3336

@@ -70,6 +73,7 @@ export default function AztecDocsWidget({
7073

7174
let acc = "";
7275
let sources = [];
76+
let errorMessage = null;
7377
try {
7478
await streamAnswer({
7579
apiHost,
@@ -87,18 +91,27 @@ export default function AztecDocsWidget({
8791
setStreamSources(sources);
8892
},
8993
onConversationId: (id) => setConversationId(id),
94+
onError: (message) => {
95+
errorMessage = message;
96+
},
9097
onDone: () => {},
9198
});
9299
} catch (err) {
93100
if (err.name !== "AbortError") {
94-
acc =
95-
acc || "Something went wrong fetching an answer. Please try again.";
101+
errorMessage =
102+
errorMessage ||
103+
"Something went wrong fetching an answer. Please try again.";
96104
}
97105
}
98106

99107
setMessages((prev) => {
100108
const copy = [...prev];
101-
copy[copy.length - 1] = { prompt: question, response: acc, sources };
109+
copy[copy.length - 1] = {
110+
prompt: question,
111+
response: acc,
112+
sources,
113+
error: errorMessage,
114+
};
102115
return copy;
103116
});
104117
setStreaming(false);
@@ -113,6 +126,36 @@ export default function AztecDocsWidget({
113126
setStreamText("");
114127
setStreamSources([]);
115128
setConversationId(null);
129+
setFeedbackByIndex({});
130+
setFeedbackErrorsByIndex({});
131+
}
132+
133+
async function handleFeedback(messageIndex, kind) {
134+
if (!conversationId) return;
135+
if (feedbackByIndex[messageIndex]) return;
136+
setFeedbackByIndex((prev) => ({ ...prev, [messageIndex]: kind }));
137+
setFeedbackErrorsByIndex((prev) => {
138+
if (!prev[messageIndex]) return prev;
139+
const copy = { ...prev };
140+
delete copy[messageIndex];
141+
return copy;
142+
});
143+
try {
144+
await sendFeedback({
145+
apiHost,
146+
apiKey,
147+
conversationId,
148+
questionIndex: messageIndex,
149+
feedback: kind,
150+
});
151+
} catch (err) {
152+
setFeedbackByIndex((prev) => {
153+
const copy = { ...prev };
154+
delete copy[messageIndex];
155+
return copy;
156+
});
157+
setFeedbackErrorsByIndex((prev) => ({ ...prev, [messageIndex]: true }));
158+
}
116159
}
117160

118161
return (
@@ -148,6 +191,10 @@ export default function AztecDocsWidget({
148191
expanded={expanded}
149192
onToggleExpanded={() => setExpanded((v) => !v)}
150193
scrollRef={scrollRef}
194+
conversationId={conversationId}
195+
feedbackByIndex={feedbackByIndex}
196+
feedbackErrorsByIndex={feedbackErrorsByIndex}
197+
onFeedback={handleFeedback}
151198
/>
152199
)}
153200
</div>
Lines changed: 27 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,27 @@
1+
export async function sendFeedback({
2+
apiHost,
3+
apiKey,
4+
conversationId,
5+
questionIndex,
6+
feedback,
7+
signal,
8+
}) {
9+
if (!conversationId || questionIndex == null) {
10+
throw new Error("Feedback requires conversationId and questionIndex");
11+
}
12+
const res = await fetch(`${apiHost.replace(/\/$/, "")}/api/feedback`, {
13+
method: "POST",
14+
headers: { "Content-Type": "application/json" },
15+
body: JSON.stringify({
16+
conversation_id: conversationId,
17+
question_index: questionIndex,
18+
feedback,
19+
api_key: apiKey,
20+
}),
21+
signal,
22+
});
23+
if (!res.ok) {
24+
throw new Error(`DocsGPT feedback failed: ${res.status}`);
25+
}
26+
return res.json().catch(() => ({}));
27+
}

docs/src/components/AztecDocsWidget/streamAnswer.js

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ export async function streamAnswer({
77
onToken,
88
onSource,
99
onConversationId,
10+
onError,
1011
onDone,
1112
signal,
1213
}) {
@@ -56,6 +57,14 @@ export async function streamAnswer({
5657
onSource(sources);
5758
} else if (parsed.type === "id" && parsed.id) {
5859
onConversationId(parsed.id);
60+
} else if (parsed.type === "error") {
61+
const message =
62+
typeof parsed.error === "string" && parsed.error.trim()
63+
? parsed.error
64+
: "Something went wrong generating an answer.";
65+
onError?.(message);
66+
onDone();
67+
return;
5968
} else if (parsed.type === "end") {
6069
onDone();
6170
return;

0 commit comments

Comments
 (0)