@stainless-api/docs-ai-chat 0.1.0-beta.5 → 0.1.0-beta.50

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/src/hook.ts CHANGED
@@ -1,31 +1,14 @@
1
+ import type {
2
+ AssistantTextMessage,
3
+ AssistantToolCallMessage,
4
+ ChatMessage,
5
+ UserMessage,
6
+ } from '@stainless-api/ai-chat/src/types';
7
+ import type { DocsLanguage } from '@stainless-api/docs-ui/routing';
1
8
  import { useCallback, useEffect, useReducer, useRef } from 'react';
2
9
  import { getChatResponse } from './api';
3
- import type { DocsLanguage } from '@stainless-api/docs-ui/routing';
4
10
  import type { ResponseChunk } from './api/schemas';
5
11
 
6
- // Base
7
- type BaseMessage = { id: string };
8
- type BaseTextMessage = BaseMessage & { content: string };
9
- // User
10
- type UserMessage = BaseTextMessage & { role: 'user' };
11
- // Assistant
12
- type BaseAssistantMessage = BaseMessage & {
13
- role: 'assistant';
14
- respondingTo: UserMessage['id'];
15
- messageType: ResponseChunk['type'];
16
- };
17
- type AssistantTextMessage = (BaseAssistantMessage & BaseTextMessage) & {
18
- messageType: Extract<BaseAssistantMessage['messageType'], 'text'>;
19
- isComplete: boolean;
20
- };
21
- type AssistantToolCallMessage = BaseAssistantMessage & {
22
- messageType: Extract<BaseAssistantMessage['messageType'], 'tool_use'>;
23
- toolName: string;
24
- input: Record<string, unknown> | undefined;
25
- };
26
- // All
27
- export type ChatMessage = UserMessage | AssistantTextMessage | AssistantToolCallMessage;
28
-
29
12
  //
30
13
  // Reducer
31
14
  //
@@ -53,7 +36,10 @@ type ChatReducerAction =
53
36
  | { type: 'beginAssistantMessage'; message: Omit<AssistantTextMessage, 'role' | 'isComplete'> }
54
37
  | { type: 'streamMessage'; id: string; newContent: string }
55
38
  | { type: 'completeMessage'; id: string }
56
- | { type: 'addAssistantToolCall'; message: Omit<AssistantToolCallMessage, 'role' | 'id'> };
39
+ | { type: 'addAssistantToolCall'; message: Omit<AssistantToolCallMessage, 'role' | 'id'> }
40
+ // a response potentially contains multiple messages / tool calls
41
+ | { type: 'completeResponse'; respondingTo: UserMessage['id']; spanId: string }
42
+ | { type: 'addError'; respondingTo: UserMessage['id']; errorMessage: string };
57
43
 
58
44
  function chatReducer(state: ChatMessage[], action: ChatReducerAction) {
59
45
  if (action.type === 'addUserMessage') {
@@ -98,6 +84,26 @@ function chatReducer(state: ChatMessage[], action: ChatReducerAction) {
98
84
  });
99
85
  }
100
86
 
87
+ if (action.type === 'completeResponse') {
88
+ return spliceNewMessage(state, {
89
+ role: 'assistant',
90
+ id: crypto.randomUUID(),
91
+ messageType: 'done',
92
+ respondingTo: action.respondingTo,
93
+ spanId: action.spanId,
94
+ } satisfies Extract<ChatMessage, { role: 'assistant' }>);
95
+ }
96
+
97
+ if (action.type === 'addError') {
98
+ return spliceNewMessage(state, {
99
+ role: 'assistant',
100
+ id: crypto.randomUUID(),
101
+ messageType: 'error',
102
+ respondingTo: action.respondingTo,
103
+ errorMessage: action.errorMessage,
104
+ });
105
+ }
106
+
101
107
  return state;
102
108
  }
103
109
 
@@ -105,14 +111,22 @@ function chatReducer(state: ChatMessage[], action: ChatReducerAction) {
105
111
  // Consumable hook
106
112
  //
107
113
 
108
- export function useChat({ projectId, language }: { projectId: string; language: DocsLanguage }) {
114
+ export function useChat({
115
+ projectId,
116
+ language,
117
+ siteTitle,
118
+ }: {
119
+ projectId: string;
120
+ language: DocsLanguage;
121
+ siteTitle: string | undefined;
122
+ }) {
109
123
  // Used to clean up stray streaming requests on unmount (prevent setState on unmounted component)
110
- const abortController = useRef(new AbortController());
124
+ const abortControllerRef = useRef(new AbortController());
111
125
  useEffect(() => {
112
- abortController.current = abortController.current.signal.aborted
126
+ abortControllerRef.current = abortControllerRef.current.signal.aborted
113
127
  ? new AbortController()
114
- : abortController.current;
115
- const ac = abortController.current;
128
+ : abortControllerRef.current;
129
+ const ac = abortControllerRef.current;
116
130
  return () => ac.abort('Component unmounted');
117
131
  }, []);
118
132
 
@@ -127,66 +141,83 @@ export function useChat({ projectId, language }: { projectId: string; language:
127
141
  let currentResponseId = crypto.randomUUID(); // for streaming text messages
128
142
  let lastChunkType: ResponseChunk['type'] | undefined = undefined;
129
143
 
130
- for await (const chunk of getChatResponse(
131
- {
132
- query: question,
133
- project: projectId,
134
- language,
135
- priorMessages: chatMessages.filter(
136
- (msg) => msg.role === 'user' || (msg.role === 'assistant' && msg.messageType === 'text'),
137
- ),
138
- },
139
- abortController.current.signal,
140
- )) {
141
- if (abortController.current.signal.aborted) break;
142
-
143
- // mark complete when text messages finish streaming
144
- if (lastChunkType === 'text' && chunk.type !== 'text') {
145
- dispatch({ type: 'completeMessage', id: currentResponseId });
146
- }
144
+ try {
145
+ let chunk: ResponseChunk | undefined = undefined;
146
+ for await (chunk of getChatResponse(
147
+ {
148
+ query: question,
149
+ project: projectId,
150
+ language,
151
+ priorMessages: chatMessages.filter(
152
+ (msg) => msg.role === 'user' || (msg.role === 'assistant' && msg.messageType === 'text'),
153
+ ),
154
+ siteTitle,
155
+ },
156
+ abortControllerRef.current.signal,
157
+ )) {
158
+ if (abortControllerRef.current.signal.aborted) break;
159
+
160
+ // mark complete when text messages finish streaming
161
+ if (lastChunkType === 'text' && chunk.type !== 'text') {
162
+ dispatch({ type: 'completeMessage', id: currentResponseId });
163
+ }
147
164
 
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;
165
+ if (chunk.type === 'done') {
166
+ dispatch({ type: 'completeResponse', respondingTo: userMessageId, spanId: chunk.span_id });
167
+ // stop reading from the stream on done
168
+ break;
169
+ }
170
+
171
+ if (chunk.type === 'text') {
172
+ if (lastChunkType !== 'text') {
173
+ // start a new text message
174
+ currentResponseId = crypto.randomUUID();
175
+ dispatch({
176
+ type: 'beginAssistantMessage',
177
+ message: {
178
+ content: chunk.text,
179
+ id: currentResponseId,
180
+ messageType: chunk.type,
181
+ respondingTo: userMessageId,
182
+ },
183
+ });
184
+ } else {
185
+ // continue the current message with the new content
186
+ dispatch({ type: 'streamMessage', id: currentResponseId, newContent: chunk.text });
187
+ }
188
+ }
151
189
 
152
- if (chunk.type === 'text') {
153
- if (lastChunkType !== 'text') {
154
- // start a new text message
155
- currentResponseId = crypto.randomUUID();
190
+ if (chunk.type === 'tool_use') {
156
191
  dispatch({
157
- type: 'beginAssistantMessage',
192
+ type: 'addAssistantToolCall',
158
193
  message: {
159
- content: chunk.text,
160
- id: currentResponseId,
161
- messageType: chunk.type,
162
194
  respondingTo: userMessageId,
195
+ messageType: chunk.type,
196
+ toolName: chunk.name,
197
+ input: chunk.input,
163
198
  },
164
199
  });
165
- } else {
166
- // continue the current message with the new content
167
- dispatch({ type: 'streamMessage', id: currentResponseId, newContent: chunk.text });
168
200
  }
169
- }
170
201
 
171
- if (chunk.type === 'tool_use') {
202
+ lastChunkType = chunk.type;
203
+ }
204
+ if (!chunk || lastChunkType === 'start_session') {
172
205
  dispatch({
173
- type: 'addAssistantToolCall',
174
- message: {
175
- respondingTo: userMessageId,
176
- messageType: chunk.type,
177
- toolName: chunk.name,
178
- input: chunk.input,
179
- },
206
+ type: 'addError',
207
+ respondingTo: userMessageId,
208
+ errorMessage: 'No response received. Please try again.',
180
209
  });
181
210
  }
182
-
183
- lastChunkType = chunk.type;
211
+ } catch {
212
+ dispatch({
213
+ type: 'addError',
214
+ respondingTo: userMessageId,
215
+ errorMessage: 'Something went wrong. Please try again.',
216
+ });
184
217
  }
185
218
  },
186
- [language, projectId, chatMessages],
219
+ [language, projectId, siteTitle, chatMessages],
187
220
  );
188
221
 
189
- // TODO: error handling
190
-
191
222
  return { chatMessages, sendMessage };
192
223
  }
package/tsconfig.json CHANGED
@@ -3,5 +3,5 @@
3
3
  "compilerOptions": {
4
4
  "jsx": "react-jsx"
5
5
  },
6
- "include": ["src", "*.d.ts", "plugin.tsx"]
6
+ "include": ["src", "*.d.ts", "*.config.*", "plugin.tsx"]
7
7
  }
@@ -1,221 +0,0 @@
1
- .outer-wrapper {
2
- display: contents;
3
- font-size: var(--stl-typography-scale-sm);
4
-
5
- --shadow-color: light-dark(rgb(0 0 0 / 0.15), var(--stl-color-background));
6
- --trigger-size: 3rem;
7
- --fixed-inset-bottom: calc(env(safe-area-inset-bottom, 0px) + 1rem);
8
- --fixed-inset-right: calc(env(safe-area-inset-right, 0px) + 1rem);
9
- }
10
-
11
- .trigger-border {
12
- position: fixed;
13
- bottom: var(--fixed-inset-bottom);
14
- right: var(--fixed-inset-right);
15
- z-index: 51;
16
-
17
- padding: 1px;
18
- background-color: var(--stl-color-border);
19
- display: block;
20
- transition: background-color 0.1s ease;
21
-
22
- &:hover {
23
- background-color: var(--stl-color-border-strong);
24
- }
25
-
26
- &:has(textarea:focus-visible) {
27
- background-color: var(--stl-color-blue-border-strong);
28
- transition: background-color 0.25s ease;
29
- }
30
-
31
- &:not(:has(.trigger.expanded)) {
32
- cursor: pointer;
33
- }
34
- }
35
-
36
- .trigger {
37
- display: flex;
38
- align-items: stretch;
39
- min-height: var(--trigger-size);
40
- min-width: var(--trigger-size);
41
- position: relative;
42
-
43
- background-color: var(--stl-color-background);
44
- color: var(--stl-color-foreground);
45
- --padding: 0.5rem;
46
- padding: var(--padding);
47
-
48
- overflow: clip;
49
-
50
- .bot-icon {
51
- position: absolute;
52
- --icon-size: 1.5rem;
53
- top: calc(var(--trigger-size) / 2 - var(--icon-size) / 2);
54
- left: calc(var(--trigger-size) / 2 - var(--icon-size) / 2);
55
- width: var(--icon-size);
56
- height: var(--icon-size);
57
- pointer-events: none;
58
- }
59
-
60
- .focused-contents {
61
- display: flex;
62
- gap: 0.2rem;
63
- align-items: center;
64
- user-select: none;
65
- }
66
- &:not(.expanded) .focused-contents {
67
- position: absolute;
68
- pointer-events: none;
69
- textarea {
70
- user-select: none;
71
- }
72
- }
73
-
74
- textarea {
75
- display: block;
76
- resize: none;
77
- width: 24ch;
78
- height: auto;
79
-
80
- font-size: var(--stl-typography-scale-base);
81
- line-height: 1.35;
82
- padding: 0.25em 0.25em 0.25em 0.5em;
83
-
84
- color: inherit;
85
- background: none;
86
-
87
- border: none;
88
- user-select: initial;
89
- &:focus-visible {
90
- outline: none;
91
- }
92
- }
93
-
94
- button[type='submit'] {
95
- align-self: flex-end;
96
-
97
- width: calc(var(--trigger-size) - var(--padding) * 2);
98
- height: calc(var(--trigger-size) - var(--padding) * 2);
99
- display: flex;
100
- align-items: center;
101
- justify-content: center;
102
-
103
- border: none;
104
- border-radius: calc(var(--border-radius) - var(--padding));
105
-
106
- background-color: var(--stl-color-accent-inverse-background);
107
- color: var(--stl-color-accent-inverse-foreground);
108
-
109
- cursor: pointer;
110
-
111
- svg {
112
- width: 1.15rem;
113
- height: 1.15rem;
114
- * {
115
- stroke-width: 2.25px;
116
- }
117
- }
118
-
119
- &:disabled {
120
- background-color: var(--stl-color-muted-background);
121
- color: var(--stl-color-foreground);
122
- opacity: 0.6;
123
- cursor: default;
124
- }
125
- }
126
- }
127
-
128
- .chat-area-border {
129
- position: fixed;
130
- --chat-area-inset-bottom: calc(var(--fixed-inset-bottom) + var(--trigger-size) + 0.5rem);
131
- bottom: var(--chat-area-inset-bottom);
132
- right: var(--fixed-inset-right);
133
- z-index: 50;
134
- }
135
-
136
- .chat-area {
137
- display: flex;
138
- flex-direction: column-reverse;
139
- align-items: center;
140
-
141
- max-width: 48ch;
142
- max-height: calc(75svh - var(--chat-area-inset-bottom) - 1rem);
143
- overflow-y: auto;
144
- overscroll-behavior: contain;
145
-
146
- padding: 1em;
147
-
148
- background-color: var(--stl-color-background);
149
- background-image: linear-gradient(
150
- to bottom,
151
- var(--stl-color-ui-background),
152
- var(--stl-color-ui-background)
153
- );
154
- }
155
-
156
- .message-log {
157
- padding: 0;
158
- list-style-type: none;
159
-
160
- flex: 0;
161
- position: relative;
162
- z-index: 1;
163
-
164
- display: flex;
165
- flex-direction: column;
166
- gap: 0.5rem;
167
-
168
- width: calc(100vw - 2rem);
169
- max-width: 100%;
170
-
171
- &:not(:has(.chat-message)) {
172
- display: none;
173
- }
174
- }
175
-
176
- .chat-message {
177
- list-style-type: none;
178
- text-wrap: pretty;
179
- font-size: 15px;
180
- line-height: 1.4;
181
-
182
- &[data-message-role='user'] {
183
- max-width: 40ch;
184
- align-self: flex-end;
185
- padding: 0.5em 0.75em;
186
- background-color: var(--stl-color-accent-inverse-background);
187
- color: var(--stl-color-accent-inverse-foreground);
188
- overflow: clip;
189
-
190
- ::selection {
191
- background-color: rgb(from var(--stl-color-accent-inverse-foreground) r g b / 0.25);
192
- }
193
- }
194
-
195
- &[data-message-role='assistant'] {
196
- align-self: stretch;
197
- padding: 0.25em 0;
198
- color: var(--stl-color-foreground-reduced);
199
- }
200
-
201
- pre {
202
- padding: 0.25em 0.65em;
203
- font-size: var(--stl-typography-scale-sm);
204
- line-height: 1.5;
205
- }
206
-
207
- &.tool-use {
208
- color: var(--stl-color-foreground-muted);
209
- opacity: 0.8;
210
-
211
- strong,
212
- em {
213
- font-weight: 500;
214
- color: var(--stl-color-foreground-reduced);
215
- }
216
-
217
- & + .tool-use {
218
- margin-top: -0.75em;
219
- }
220
- }
221
- }
package/src/AiChat.tsx DELETED
@@ -1,84 +0,0 @@
1
- import { useEffect, useRef, useState } from 'react';
2
- import { useChat } from './hook';
3
- import { motion } from 'motion/react';
4
-
5
- import type { DocsLanguage } from '@stainless-api/docs-ui/routing';
6
-
7
- import styles from './AiChat.module.css';
8
- import ChatLog from './components/ChatLog';
9
- import AiChatTrigger from './Trigger';
10
-
11
- const borderRadius = 16;
12
-
13
- export default function DocsChat({
14
- projectId,
15
- language,
16
- }: {
17
- projectId: string;
18
- language: DocsLanguage | undefined;
19
- }) {
20
- const { chatMessages, sendMessage } = useChat({ projectId, language: language ?? 'typescript' });
21
-
22
- const baseRef = useRef<HTMLDivElement>(null);
23
- const inputRef = useRef<HTMLTextAreaElement>(null);
24
- const [focused, setFocused] = useState(false);
25
- const chatExpanded = focused && chatMessages.length > 0;
26
-
27
- // Manage “focus” state
28
- // prettier-ignore
29
- useEffect(() => {
30
- const ac = new AbortController();
31
- // “focus” in/out with click
32
- 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
- }, { signal: ac.signal });
37
- // leave with escape
38
- document.addEventListener('keydown', (e) => {
39
- if (e.key === 'Escape') {
40
- setFocused(false);
41
- inputRef.current?.blur(); // this is the one case where the input won’t have already lost focus
42
- }
43
- }, { signal: ac.signal });
44
-
45
- // record focus state when our chat elements receive focus
46
- // unfocus when another element outside of our component gets focus (incl. by keyboard)
47
- document.addEventListener('focusin', (e) => {
48
- if (!(e.target instanceof HTMLElement) || !baseRef.current) return;
49
- setFocused(baseRef.current.contains(e.target) ?? false);
50
- }, { signal: ac.signal });
51
- return () => ac.abort();
52
- }, []);
53
-
54
- return (
55
- <div className={styles['outer-wrapper']} ref={baseRef}>
56
- <AiChatTrigger
57
- focused={focused}
58
- updateFocused={setFocused}
59
- sendMessage={sendMessage}
60
- inputRef={inputRef}
61
- borderRadius={borderRadius}
62
- />
63
-
64
- {/* TODO: enter and leave animations */}
65
- <motion.div
66
- layout
67
- className={styles['chat-area-border']}
68
- style={{
69
- display: chatExpanded ? 'block' : 'none', // Activity doesn’t play nice with framer-motion layout animations
70
- borderRadius,
71
- boxShadow: '0 8px 20px -6px var(--shadow-color)',
72
- }}
73
- >
74
- <motion.div
75
- layout
76
- className={styles['chat-area']}
77
- style={{ borderRadius, boxShadow: 'inset 0 0 0 1px var(--stl-color-border)' }}
78
- >
79
- <ChatLog messages={chatMessages} />
80
- </motion.div>
81
- </motion.div>
82
- </div>
83
- );
84
- }
package/src/Trigger.tsx DELETED
@@ -1,135 +0,0 @@
1
- import React, { useState } from 'react';
2
- import { ArrowUpIcon, BotMessageSquareIcon } from 'lucide-react';
3
- import clsx from 'clsx';
4
- import { Transition } from 'motion';
5
- import styles from './AiChat.module.css';
6
- import { motion } from 'motion/react';
7
-
8
- const MotionBotIcon = motion.create(BotMessageSquareIcon);
9
-
10
- export default function AiChatTrigger({
11
- focused,
12
- updateFocused,
13
- sendMessage,
14
- inputRef,
15
- borderRadius,
16
- }: {
17
- focused: boolean;
18
- updateFocused: (focused: boolean) => void;
19
- sendMessage: (question: string) => void;
20
- inputRef: React.RefObject<HTMLTextAreaElement | null>;
21
- borderRadius: number;
22
- }) {
23
- const [empty, setEmpty] = useState(true);
24
- const [resetKey, setResetKey] = useState(0);
25
-
26
- const layoutTransition = {
27
- type: 'spring',
28
- mass: 0.7,
29
- stiffness: 275,
30
- damping: 20,
31
- } satisfies Transition;
32
-
33
- const crossBlurTransition = {
34
- delay: focused ? 0.07 : 0,
35
- duration: 0.1,
36
- ease: 'easeInOut',
37
- } satisfies Transition;
38
-
39
- return (
40
- <form
41
- style={{ display: 'contents' }}
42
- action={(formData) => {
43
- const question = formData.get('question');
44
- if (typeof question === 'string' && question.trim().length) {
45
- sendMessage(question);
46
- setResetKey((k) => k + 1);
47
- setEmpty(true);
48
- }
49
- }}
50
- >
51
- <motion.label
52
- layout
53
- transition={layoutTransition}
54
- className={styles['trigger-border']}
55
- style={{
56
- borderRadius: borderRadius + 1,
57
- boxShadow: '0 4px 12px -4px var(--shadow-color)',
58
- }}
59
- >
60
- <motion.div
61
- layout
62
- transition={layoutTransition}
63
- className={clsx(styles.trigger, focused && styles.expanded)}
64
- style={{ borderRadius: borderRadius, '--border-radius': `${borderRadius}px` }}
65
- >
66
- {/* Bot icon is visible while closed */}
67
- <MotionBotIcon
68
- layout
69
- className={styles['bot-icon']}
70
- animate={{
71
- opacity: focused ? 0 : 1,
72
- scale: focused ? 0.75 : 1,
73
- filter: focused ? 'blur(4px)' : 'blur(0px)',
74
- }}
75
- style={{ willChange: 'filter, transform' }}
76
- transition={crossBlurTransition}
77
- aria-label="AI chat"
78
- />
79
-
80
- {/* Input & send button are visible while open */}
81
- <motion.div
82
- layout
83
- className={styles['focused-contents']}
84
- initial={{ opacity: 0 }}
85
- animate={{
86
- opacity: focused ? 1 : 0,
87
- filter: focused ? 'blur(0px)' : 'blur(4px)',
88
- }}
89
- style={{ willChange: 'filter, transform' }}
90
- transition={crossBlurTransition}
91
- >
92
- <motion.textarea
93
- layout
94
- transition={layoutTransition}
95
- name="question"
96
- rows={1}
97
- placeholder="Ask a question"
98
- // Keep track of whether the question is submittable
99
- onChange={(e: React.ChangeEvent<HTMLTextAreaElement>) => {
100
- setEmpty(e.target.value.trim().length === 0);
101
- }}
102
- // Submit on Cmd+Enter
103
- onKeyDown={(e: React.KeyboardEvent<HTMLTextAreaElement>) => {
104
- if (e.key === 'Enter' && !e.shiftKey) {
105
- e.preventDefault();
106
- e.currentTarget.form?.requestSubmit();
107
- }
108
- }}
109
- // Update textarea height to fit content as user types
110
- ref={(el: HTMLTextAreaElement | null) => {
111
- inputRef.current = el;
112
- if (!el) return;
113
- const updateSize = () => {
114
- el.style.height = 'auto';
115
- el.style.height = `${el.scrollHeight}px`;
116
- };
117
- const ac = new AbortController();
118
- el.addEventListener('input', updateSize, { signal: ac.signal });
119
- updateSize();
120
- // in case the user focused it before we mounted
121
- if (document.activeElement === el) updateFocused(true);
122
- return () => ac.abort();
123
- }}
124
- // make the ref re-mount so we get a fresh height measurement after reset
125
- key={resetKey}
126
- />
127
- <motion.button layout type="submit" disabled={empty} transition={layoutTransition}>
128
- <ArrowUpIcon aria-label="Send" />
129
- </motion.button>
130
- </motion.div>
131
- </motion.div>
132
- </motion.label>
133
- </form>
134
- );
135
- }