@stainless-api/docs-ai-chat 0.1.0-beta.7 → 0.1.0-beta.9

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,23 @@
1
1
  # @stainless-api/docs-ai-chat
2
2
 
3
+ ## 0.1.0-beta.9
4
+
5
+ ### Patch Changes
6
+
7
+ - 827e203: allow http as fallback now that API supports it
8
+ - 6340cae: fix steelie http fallback
9
+
10
+ ## 0.1.0-beta.8
11
+
12
+ ### Minor Changes
13
+
14
+ - 883ad46: Add UI for providing feedback on chat responses
15
+
16
+ ### Patch Changes
17
+
18
+ - Updated dependencies [af54129]
19
+ - @stainless-api/docs@0.1.0-beta.66
20
+
3
21
  ## 0.1.0-beta.7
4
22
 
5
23
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stainless-api/docs-ai-chat",
3
- "version": "0.1.0-beta.7",
3
+ "version": "0.1.0-beta.9",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,20 +8,20 @@
8
8
  "peerDependencies": {
9
9
  "react": ">=19.0.0",
10
10
  "react-dom": ">=19.0.0",
11
- "@stainless-api/docs": "0.1.0-beta.65",
12
11
  "@stainless-api/docs-ui": "0.1.0-beta.52",
13
- "@stainless-api/ui-primitives": "0.1.0-beta.39"
12
+ "@stainless-api/ui-primitives": "0.1.0-beta.39",
13
+ "@stainless-api/docs": "0.1.0-beta.66"
14
14
  },
15
15
  "dependencies": {
16
16
  "@streamparser/json-whatwg": "^0.0.22",
17
17
  "clsx": "^2.1.1",
18
- "lucide-react": "^0.561.0",
19
- "motion": "^12.23.25",
18
+ "lucide-react": "^0.562.0",
19
+ "motion": "^12.24.7",
20
20
  "react": "^19.2.3",
21
21
  "react-markdown": "^10.1.0",
22
22
  "react-syntax-highlighter": "^16.1.0",
23
23
  "remend": "^1.0.1",
24
- "zod": "^4.1.13"
24
+ "zod": "^4.3.5"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/react": "19.2.7",
@@ -219,3 +219,21 @@
219
219
  }
220
220
  }
221
221
  }
222
+
223
+ .feedback-buttons {
224
+ display: flex;
225
+ gap: 0.15rem;
226
+ margin-top: -0.25rem;
227
+
228
+ button:global(.stl-ui-button.stl-ui-button--ghost) {
229
+ svg {
230
+ opacity: var(--stl-opacity-level-040);
231
+ }
232
+ &:hover,
233
+ &.active {
234
+ svg {
235
+ opacity: var(--stl-opacity-level-080);
236
+ }
237
+ }
238
+ }
239
+ }
package/src/AiChat.tsx CHANGED
@@ -17,7 +17,10 @@ export default function DocsChat({
17
17
  projectId: string;
18
18
  language: DocsLanguage | undefined;
19
19
  }) {
20
- const { chatMessages, sendMessage } = useChat({ projectId, language: language ?? 'typescript' });
20
+ const { chatMessages, sendMessage } = useChat({
21
+ projectId,
22
+ language: language ?? 'http',
23
+ });
21
24
 
22
25
  const baseRef = useRef<HTMLDivElement>(null);
23
26
  const inputRef = useRef<HTMLTextAreaElement>(null);
@@ -30,9 +33,8 @@ export default function DocsChat({
30
33
  const ac = new AbortController();
31
34
  // “focus” in/out with click
32
35
  window.addEventListener('click', (e) => {
33
- if (!(e.target instanceof Element)) return;
34
- if (baseRef.current?.contains(e.target)) { setFocused(true); }
35
- else { setFocused(false); }
36
+ if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
37
+ setFocused(baseRef.current.contains(e.target));
36
38
  }, { signal: ac.signal });
37
39
  // leave with escape
38
40
  document.addEventListener('keydown', (e) => {
@@ -45,8 +47,8 @@ export default function DocsChat({
45
47
  // record focus state when our chat elements receive focus
46
48
  // unfocus when another element outside of our component gets focus (incl. by keyboard)
47
49
  document.addEventListener('focusin', (e) => {
48
- if (!(e.target instanceof HTMLElement) || !baseRef.current) return;
49
- setFocused(baseRef.current.contains(e.target) ?? false);
50
+ if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
51
+ setFocused(baseRef.current.contains(e.target));
50
52
  }, { signal: ac.signal });
51
53
  return () => ac.abort();
52
54
  }, []);
package/src/api/index.ts CHANGED
@@ -1,9 +1,12 @@
1
1
  import type { DocsLanguage } from '@stainless-api/docs-ui/routing';
2
2
  import { JSONParser } from '@streamparser/json-whatwg';
3
- import { type RequestBody, responseChunk } from './schemas';
3
+ import { type RequestBody, responseChunk, type FeedbackRequestBody, feedbackResponseBody } from './schemas';
4
4
  import { streamAsyncIterator } from './util';
5
5
 
6
- export const CHAT_ENDPOINT = 'https://app.stainless.com/api/ai/get-agentic-help';
6
+ export const API_URL = new URL('https://app.stainless.com/api/');
7
+ export const CHAT_ENDPOINT = new URL('ai/get-agentic-help', API_URL);
8
+
9
+ export const FEEDBACK_ENDPOINT = (spanId: string) => new URL(`ai/agentic-help/${spanId}/score`, API_URL);
7
10
 
8
11
  /**
9
12
  * Stream chat response from the server
@@ -22,7 +25,7 @@ export async function* getChatResponse(
22
25
  },
23
26
  abortSignal: AbortSignal,
24
27
  ) {
25
- const req = await fetch(CHAT_ENDPOINT, {
28
+ const res = await fetch(CHAT_ENDPOINT, {
26
29
  method: 'POST',
27
30
  headers: {
28
31
  'Content-Type': 'application/json',
@@ -37,11 +40,27 @@ export async function* getChatResponse(
37
40
  signal: abortSignal,
38
41
  });
39
42
 
40
- if (!req.ok || !req.body) throw new Error(`Chat request failed with status ${req.status}`);
43
+ if (!res.ok || !res.body) throw new Error(`Chat request failed with status ${res.status}`);
41
44
 
42
45
  const parser = new JSONParser({ separator: '\n', paths: ['$'] });
43
- for await (const chunk of streamAsyncIterator(req.body.pipeThrough(parser))) {
46
+ for await (const chunk of streamAsyncIterator(res.body.pipeThrough(parser))) {
44
47
  const chunkParsed = responseChunk.safeParse(chunk.value);
45
48
  if (chunkParsed.success) yield chunkParsed.data;
46
49
  }
47
50
  }
51
+
52
+ /**
53
+ * Attach a score to a response
54
+ */
55
+ export async function submitResponseFeedback(spanId: string, score: 0 | 1) {
56
+ const res = await fetch(FEEDBACK_ENDPOINT(spanId), {
57
+ method: 'PUT',
58
+ headers: {
59
+ 'Content-Type': 'application/json',
60
+ },
61
+ body: JSON.stringify({ score } satisfies FeedbackRequestBody),
62
+ });
63
+
64
+ if (!res.ok) throw new Error(`Feedback request failed with status ${res.status}`);
65
+ return feedbackResponseBody.parse(await res.json());
66
+ }
@@ -48,6 +48,11 @@ export const responseChunk = z.discriminatedUnion('type', [
48
48
  }),
49
49
  z.object({
50
50
  type: z.literal('done'),
51
+ span_id: z.string(),
51
52
  }),
52
53
  ]);
53
54
  export type ResponseChunk = z.infer<typeof responseChunk>;
55
+
56
+ export const feedbackRequestBody = z.object({ score: z.number().min(0).max(1) });
57
+ export type FeedbackRequestBody = z.infer<typeof feedbackRequestBody>;
58
+ export const feedbackResponseBody = z.object({ success: z.boolean() });
@@ -1,6 +1,7 @@
1
1
  import type { ChatMessage } from '../hook';
2
2
  import Message from './ChatMessage';
3
3
  import ToolCall from './ToolCall';
4
+ import MessageFeedbackButtons from './MessageFeedback';
4
5
 
5
6
  import { motion } from 'motion/react';
6
7
 
@@ -36,6 +37,25 @@ export default function ChatLog({ messages }: { messages: ChatMessage[] }) {
36
37
  if (msg.role === 'assistant' && msg.messageType === 'tool_use') {
37
38
  return <ToolCall key={msg.id} message={msg} />;
38
39
  }
40
+
41
+ if (msg.role === 'assistant' && msg.messageType === 'done') {
42
+ return (
43
+ <MessageFeedbackButtons
44
+ key={msg.id}
45
+ spanId={msg.spanId}
46
+ // all “text” responses to the given message
47
+ messages={messages.flatMap((msg2) =>
48
+ msg2.role === 'assistant' &&
49
+ msg2.respondingTo === msg.respondingTo &&
50
+ msg2.messageType === 'text'
51
+ ? msg2
52
+ : [],
53
+ )}
54
+ />
55
+ );
56
+ }
57
+
58
+ return null;
39
59
  })}
40
60
  </motion.ul>
41
61
  );
@@ -0,0 +1,100 @@
1
+ import { useState, useCallback, useOptimistic, startTransition } from 'react';
2
+ import { motion } from 'motion/react';
3
+ import { ThumbsUpIcon, ThumbsDownIcon, CopyIcon, CheckIcon } from 'lucide-react';
4
+ import { Button } from '@stainless-api/docs/components';
5
+ import { submitResponseFeedback } from '../api';
6
+
7
+ import type { ChatMessage } from '../hook';
8
+
9
+ import styles from '../AiChat.module.css';
10
+ import clsx from 'clsx';
11
+
12
+ type AssistantMessage = Extract<ChatMessage, { role: 'assistant'; messageType: 'text' }>;
13
+
14
+ export default function MessageFeedbackButtons({
15
+ spanId,
16
+ messages,
17
+ }: {
18
+ spanId: string;
19
+ messages: AssistantMessage[];
20
+ }) {
21
+ // Copy response as markdown
22
+ const [copied, setCopied] = useState(false);
23
+ const copyMarkdown = useCallback(() => {
24
+ const combinedText = messages.map((msg) => msg.content).join('\n\n');
25
+ navigator.clipboard
26
+ .writeText(combinedText)
27
+ .then(() => {
28
+ setCopied(true);
29
+ setTimeout(() => setCopied(false), 2000);
30
+ })
31
+ .catch(() => {
32
+ setCopied(false);
33
+ });
34
+ }, [messages]);
35
+
36
+ // Provide message rating
37
+ const [rating, setRating] = useState<'up' | 'down' | null>(null);
38
+ const [optimisticRating, setOptimisticRating] = useOptimistic(
39
+ rating,
40
+ (current, newRating: NonNullable<typeof rating>) => newRating,
41
+ );
42
+ const rateMessage = useCallback(
43
+ (rating: 'up' | 'down') => {
44
+ startTransition(async () => {
45
+ setOptimisticRating(rating);
46
+ const { success } = await submitResponseFeedback(
47
+ spanId,
48
+ { up: 1 as const, down: 0 as const }[rating],
49
+ ).catch(() => ({ success: false }));
50
+ if (success) setRating(rating);
51
+ });
52
+ },
53
+ [spanId, setOptimisticRating],
54
+ );
55
+
56
+ return (
57
+ <motion.li
58
+ layout="position"
59
+ data-message-role="assistant"
60
+ className={clsx(styles['chat-message'], styles['feedback-buttons'])}
61
+ >
62
+ <Button
63
+ type="button"
64
+ variant="ghost"
65
+ size="sm"
66
+ onClick={() => rateMessage('up')}
67
+ className={clsx(optimisticRating === 'up' && styles.active)}
68
+ >
69
+ <Button.Icon icon={ThumbsUpIcon} aria-label="Thumbs up" size={15} />
70
+ </Button>
71
+
72
+ <Button
73
+ type="button"
74
+ variant="ghost"
75
+ size="sm"
76
+ onClick={() => rateMessage('down')}
77
+ className={clsx(optimisticRating === 'down' && styles.active)}
78
+ >
79
+ <Button.Icon icon={ThumbsDownIcon} aria-label="Thumbs down" size={15} />
80
+ </Button>
81
+
82
+ {messages.length > 0 && messages.some((msg) => msg.content.trim().length) && (
83
+ <Button
84
+ type="button"
85
+ variant="ghost"
86
+ size="sm"
87
+ onClick={copyMarkdown}
88
+ className={clsx(copied && styles.active)}
89
+ >
90
+ <Button.Icon
91
+ // TODO: nicer cross-fade transition
92
+ icon={copied ? CheckIcon : CopyIcon}
93
+ aria-label={copied ? 'Copied' : 'Copy response'}
94
+ size={15}
95
+ />
96
+ </Button>
97
+ )}
98
+ </motion.li>
99
+ );
100
+ }
package/src/hook.ts CHANGED
@@ -23,8 +23,16 @@ type AssistantToolCallMessage = BaseAssistantMessage & {
23
23
  toolName: string;
24
24
  input: Record<string, unknown> | undefined;
25
25
  };
26
+ type AssistantDoneMessage = BaseAssistantMessage & {
27
+ messageType: Extract<BaseAssistantMessage['messageType'], 'done'>;
28
+ spanId: string;
29
+ };
26
30
  // All
27
- export type ChatMessage = UserMessage | AssistantTextMessage | AssistantToolCallMessage;
31
+ export type ChatMessage =
32
+ | UserMessage
33
+ | AssistantTextMessage
34
+ | AssistantToolCallMessage
35
+ | AssistantDoneMessage;
28
36
 
29
37
  //
30
38
  // Reducer
@@ -53,7 +61,9 @@ type ChatReducerAction =
53
61
  | { type: 'beginAssistantMessage'; message: Omit<AssistantTextMessage, 'role' | 'isComplete'> }
54
62
  | { type: 'streamMessage'; id: string; newContent: string }
55
63
  | { type: 'completeMessage'; id: string }
56
- | { type: 'addAssistantToolCall'; message: Omit<AssistantToolCallMessage, 'role' | 'id'> };
64
+ | { type: 'addAssistantToolCall'; message: Omit<AssistantToolCallMessage, 'role' | 'id'> }
65
+ // a response potentially contains multiple messages / tool calls
66
+ | { type: 'completeResponse'; respondingTo: UserMessage['id']; spanId: string };
57
67
 
58
68
  function chatReducer(state: ChatMessage[], action: ChatReducerAction) {
59
69
  if (action.type === 'addUserMessage') {
@@ -98,6 +108,16 @@ function chatReducer(state: ChatMessage[], action: ChatReducerAction) {
98
108
  });
99
109
  }
100
110
 
111
+ if (action.type === 'completeResponse') {
112
+ return spliceNewMessage(state, {
113
+ role: 'assistant',
114
+ id: crypto.randomUUID(),
115
+ messageType: 'done',
116
+ respondingTo: action.respondingTo,
117
+ spanId: action.spanId,
118
+ } satisfies Extract<ChatMessage, { role: 'assistant' }>);
119
+ }
120
+
101
121
  return state;
102
122
  }
103
123
 
@@ -145,9 +165,11 @@ export function useChat({ projectId, language }: { projectId: string; language:
145
165
  dispatch({ type: 'completeMessage', id: currentResponseId });
146
166
  }
147
167
 
148
- // stop reading from the stream on done
149
- // TODO: record 'done' in the state so that we can render feedback buttons
150
- if (chunk.type === 'done') break;
168
+ if (chunk.type === 'done') {
169
+ dispatch({ type: 'completeResponse', respondingTo: userMessageId, spanId: chunk.span_id });
170
+ // stop reading from the stream on done
171
+ break;
172
+ }
151
173
 
152
174
  if (chunk.type === 'text') {
153
175
  if (lastChunkType !== 'text') {