@stainless-api/docs-ai-chat 0.1.0-beta.6 → 0.1.0-beta.8
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 +18 -0
- package/package.json +7 -7
- package/src/AiChat.module.css +18 -0
- package/src/AiChat.tsx +4 -5
- package/src/api/index.ts +24 -5
- package/src/api/schemas.ts +5 -0
- package/src/components/ChatLog.tsx +20 -0
- package/src/components/MessageFeedback.tsx +100 -0
- package/src/hook.ts +27 -5
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,23 @@
|
|
|
1
1
|
# @stainless-api/docs-ai-chat
|
|
2
2
|
|
|
3
|
+
## 0.1.0-beta.8
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- 883ad46: Add UI for providing feedback on chat responses
|
|
8
|
+
|
|
9
|
+
### Patch Changes
|
|
10
|
+
|
|
11
|
+
- Updated dependencies [af54129]
|
|
12
|
+
- @stainless-api/docs@0.1.0-beta.66
|
|
13
|
+
|
|
14
|
+
## 0.1.0-beta.7
|
|
15
|
+
|
|
16
|
+
### Patch Changes
|
|
17
|
+
|
|
18
|
+
- Updated dependencies [22a19ed]
|
|
19
|
+
- @stainless-api/docs@0.1.0-beta.65
|
|
20
|
+
|
|
3
21
|
## 0.1.0-beta.6
|
|
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.
|
|
3
|
+
"version": "0.1.0-beta.8",
|
|
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.
|
|
12
|
-
"@stainless-api/
|
|
13
|
-
"@stainless-api/ui
|
|
11
|
+
"@stainless-api/docs": "0.1.0-beta.66",
|
|
12
|
+
"@stainless-api/ui-primitives": "0.1.0-beta.39",
|
|
13
|
+
"@stainless-api/docs-ui": "0.1.0-beta.52"
|
|
14
14
|
},
|
|
15
15
|
"dependencies": {
|
|
16
16
|
"@streamparser/json-whatwg": "^0.0.22",
|
|
17
17
|
"clsx": "^2.1.1",
|
|
18
|
-
"lucide-react": "^0.
|
|
19
|
-
"motion": "^12.
|
|
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.
|
|
24
|
+
"zod": "^4.3.5"
|
|
25
25
|
},
|
|
26
26
|
"devDependencies": {
|
|
27
27
|
"@types/react": "19.2.7",
|
package/src/AiChat.module.css
CHANGED
|
@@ -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
|
@@ -30,9 +30,8 @@ export default function DocsChat({
|
|
|
30
30
|
const ac = new AbortController();
|
|
31
31
|
// “focus” in/out with click
|
|
32
32
|
window.addEventListener('click', (e) => {
|
|
33
|
-
if (!(e.target instanceof Element)) return;
|
|
34
|
-
|
|
35
|
-
else { setFocused(false); }
|
|
33
|
+
if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
|
|
34
|
+
setFocused(baseRef.current.contains(e.target));
|
|
36
35
|
}, { signal: ac.signal });
|
|
37
36
|
// leave with escape
|
|
38
37
|
document.addEventListener('keydown', (e) => {
|
|
@@ -45,8 +44,8 @@ export default function DocsChat({
|
|
|
45
44
|
// record focus state when our chat elements receive focus
|
|
46
45
|
// unfocus when another element outside of our component gets focus (incl. by keyboard)
|
|
47
46
|
document.addEventListener('focusin', (e) => {
|
|
48
|
-
if (!(e.target instanceof
|
|
49
|
-
setFocused(baseRef.current.contains(e.target)
|
|
47
|
+
if (!(e.target instanceof Element) || !baseRef.current || !document.contains(e.target)) return;
|
|
48
|
+
setFocused(baseRef.current.contains(e.target));
|
|
50
49
|
}, { signal: ac.signal });
|
|
51
50
|
return () => ac.abort();
|
|
52
51
|
}, []);
|
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
|
|
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
|
|
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 (!
|
|
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(
|
|
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
|
+
}
|
package/src/api/schemas.ts
CHANGED
|
@@ -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 =
|
|
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
|
-
|
|
149
|
-
|
|
150
|
-
|
|
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') {
|