@stainless-api/docs-ai-chat 0.1.0-beta.4 → 0.1.0-beta.41

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,44 +1,132 @@
1
1
  # @stainless-api/docs-ai-chat
2
2
 
3
- ## 0.1.0-beta.4
3
+ ## 0.1.0-beta.41
4
4
 
5
5
  ### Patch Changes
6
6
 
7
- - Updated dependencies [3c4a030]
8
- - Updated dependencies [cd86726]
9
- - Updated dependencies [aa9d333]
10
- - Updated dependencies [07a2c87]
11
- - @stainless-api/docs@0.1.0-beta.62
12
- - @stainless-api/docs-ui@0.1.0-beta.51
13
- - @stainless-api/ui-primitives@0.1.0-beta.38
7
+ - 6e762e2: fix steelie for multi-turn conversations
14
8
 
15
- ## 0.1.0-beta.3
9
+ ## 1.0.0-beta.40
16
10
 
17
11
  ### Patch Changes
18
12
 
19
- - 77c0d47: steelie: submit on enter
13
+ - Updated dependencies [2919b0a]
14
+ - Updated dependencies [e005e5c]
15
+ - @stainless-api/docs-ui@0.1.0-beta.75
20
16
 
21
- ## 0.1.0-beta.2
17
+ ## 0.1.0-beta.39
22
18
 
23
19
  ### Patch Changes
24
20
 
25
- - a1502cf: fix: close AIChat when clicking on SVG elements outside its bounds
26
- - 88a9894: patch vite optimizeDeps for docs-ai-chat
27
- - Updated dependencies [2a79bae]
28
- - Updated dependencies [88a9894]
29
- - @stainless-api/ui-primitives@0.1.0-beta.38
30
- - @stainless-api/docs@0.1.0-beta.61
31
- - @stainless-api/docs-ui@0.1.0-beta.51
21
+ - Updated dependencies [415629f]
22
+ - @stainless-api/docs-ui@0.1.0-beta.74
32
23
 
33
- ## 0.1.0-beta.1
24
+ ## 0.1.0-beta.38
34
25
 
35
- ### Minor Changes
26
+ ### Patch Changes
27
+
28
+ - Updated dependencies [5c36876]
29
+ - @stainless-api/docs-ui@0.1.0-beta.73
30
+
31
+ ## 0.1.0-beta.37
32
+
33
+ ### Patch Changes
34
+
35
+ - Updated dependencies [6b86a8b]
36
+ - @stainless-api/docs-ui@0.1.0-beta.72
37
+
38
+ ## 0.1.0-beta.36
39
+
40
+ ### Patch Changes
41
+
42
+ - Updated dependencies [cd578b7]
43
+ - @stainless-api/docs-ui@0.1.0-beta.71
44
+
45
+ ## 0.1.0-beta.35
46
+
47
+ ### Patch Changes
48
+
49
+ - Updated dependencies [93c8f94]
50
+ - @stainless-api/docs-ui@0.1.0-beta.70
51
+
52
+ ## 0.1.0-beta.34
53
+
54
+ ### Patch Changes
55
+
56
+ - Updated dependencies [61ba36f]
57
+ - @stainless-api/docs-ui@0.1.0-beta.69
58
+
59
+ ## 0.1.0-beta.33
60
+
61
+ ### Patch Changes
62
+
63
+ - Updated dependencies [a3f1ede]
64
+ - @stainless-api/docs-ui@0.1.0-beta.68
65
+
66
+ ## 0.1.0-beta.32
67
+
68
+ ### Patch Changes
69
+
70
+ - @stainless-api/ai-chat@0.1.0-beta.5
71
+ - @stainless-api/docs-ui@0.1.0-beta.67
72
+
73
+ ## 0.1.0-beta.31
74
+
75
+ ### Patch Changes
76
+
77
+ - Updated dependencies [65a1c9b]
78
+ - Updated dependencies [4f1cee7]
79
+ - Updated dependencies [4c72a83]
80
+ - Updated dependencies [068469b]
81
+ - @stainless-api/docs-ui@0.1.0-beta.66
82
+
83
+ ## 0.1.0-beta.30
84
+
85
+ ### Patch Changes
86
+
87
+ - Updated dependencies [b62eb05]
88
+ - @stainless-api/docs-ui@0.1.0-beta.65
89
+ - @stainless-api/ai-chat@0.1.0-beta.4
90
+
91
+ ## 0.1.0-beta.29
92
+
93
+ ### Patch Changes
94
+
95
+ - Updated dependencies [52ece13]
96
+ - Updated dependencies [3411ffe]
97
+ - Updated dependencies [7439be7]
98
+ - @stainless-api/ai-chat@0.1.0-beta.3
99
+ - @stainless-api/docs-ui@0.1.0-beta.64
100
+
101
+ ## 0.1.0-beta.28
102
+
103
+ ### Patch Changes
104
+
105
+ - Updated dependencies [274cefc]
106
+ - @stainless-api/docs-ui@0.1.0-beta.63
107
+
108
+ ## 0.1.0-beta.27
109
+
110
+ ### Patch Changes
111
+
112
+ - Updated dependencies [6ef241e]
113
+ - Updated dependencies [d3a85b5]
114
+ - Updated dependencies [d3a85b5]
115
+ - Updated dependencies [2dcb5fb]
116
+ - @stainless-api/docs-ui@0.1.0-beta.62
117
+
118
+ ## 0.1.0-beta.26
119
+
120
+ ### Patch Changes
121
+
122
+ - Updated dependencies [7155fae]
123
+ - @stainless-api/ai-chat@0.1.0-beta.2
124
+ - @stainless-api/docs-ui@0.1.0-beta.61
36
125
 
37
- - 2d00f0d: first version of docs-ai-chat
126
+ ## 0.1.0-beta.25
38
127
 
39
128
  ### Patch Changes
40
129
 
41
- - Updated dependencies [2d00f0d]
42
- - @stainless-api/docs@0.1.0-beta.60
43
- - @stainless-api/docs-ui@0.1.0-beta.50
44
- - @stainless-api/ui-primitives@0.1.0-beta.37
130
+ - 5c257e2: separate steelie into separate packages
131
+ - Updated dependencies [9dda4cf]
132
+ - @stainless-api/ai-chat@0.1.0-beta.1
package/eslint.config.js CHANGED
@@ -1,2 +1,2 @@
1
- import { config } from '@stainless/eslint-config/react-internal';
1
+ import { config } from '@stainless/eslint-config/react';
2
2
  export default config;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@stainless-api/docs-ai-chat",
3
- "version": "0.1.0-beta.4",
3
+ "version": "0.1.0-beta.41",
4
4
  "publishConfig": {
5
5
  "access": "public"
6
6
  },
@@ -8,25 +8,16 @@
8
8
  "peerDependencies": {
9
9
  "react": ">=19.0.0",
10
10
  "react-dom": ">=19.0.0",
11
- "@stainless-api/docs": "0.1.0-beta.62",
12
- "@stainless-api/docs-ui": "0.1.0-beta.51",
13
- "@stainless-api/ui-primitives": "0.1.0-beta.38"
11
+ "@stainless-api/docs-ui": "0.1.0-beta.75"
14
12
  },
15
13
  "dependencies": {
16
14
  "@streamparser/json-whatwg": "^0.0.22",
17
- "clsx": "^2.1.1",
18
- "lucide-react": "^0.561.0",
19
- "motion": "^12.23.25",
20
- "react": "^19.2.3",
21
- "react-markdown": "^10.1.0",
22
- "react-syntax-highlighter": "^16.1.0",
23
- "remend": "^1.0.1",
24
- "zod": "^4.1.13"
15
+ "zod": "^4.3.5",
16
+ "@stainless-api/ai-chat": "0.1.0-beta.5"
25
17
  },
26
18
  "devDependencies": {
27
- "@types/react": "19.2.7",
19
+ "@types/react": "19.2.10",
28
20
  "@types/react-dom": "^19.2.3",
29
- "@types/react-syntax-highlighter": "^15.5.13",
30
21
  "typescript": "5.9.3",
31
22
  "@stainless/eslint-config": "0.1.0-beta.1"
32
23
  },
@@ -34,8 +25,8 @@
34
25
  "./plugin": {
35
26
  "import": "./plugin.tsx"
36
27
  },
37
- "./src/AiChat.tsx": {
38
- "import": "./src/AiChat.tsx"
28
+ "./src/DocsChat.tsx": {
29
+ "import": "./src/DocsChat.tsx"
39
30
  }
40
31
  },
41
32
  "scripts": {
package/plugin.tsx CHANGED
@@ -1,3 +1,3 @@
1
- export default function aiChatPlugin() {
2
- return { chatComponentPath: '@stainless-api/docs-ai-chat/src/AiChat.tsx' };
1
+ export default function docsChatPlugin() {
2
+ return { chatComponentPath: '@stainless-api/docs-ai-chat/src/DocsChat.tsx' };
3
3
  }
@@ -0,0 +1,42 @@
1
+ import AiChat from '@stainless-api/ai-chat/src/AiChat.tsx';
2
+ import type { DocsLanguage } from '@stainless-api/docs-ui/routing';
3
+ import { setResponseMetadata, submitResponseFeedback } from './api';
4
+ import { useChat } from './hook';
5
+
6
+ export default function DocsChat({
7
+ projectId,
8
+ language,
9
+ siteTitle,
10
+ }: {
11
+ projectId: string;
12
+ language: DocsLanguage | undefined;
13
+ siteTitle: string | undefined;
14
+ }) {
15
+ const { chatMessages, sendMessage } = useChat({
16
+ projectId,
17
+ language: language ?? 'http',
18
+ siteTitle,
19
+ });
20
+
21
+ const rateMessage = async (spanId: string, rating: 'up' | 'down'): Promise<boolean> => {
22
+ try {
23
+ const { success } = await submitResponseFeedback(spanId, { up: 1 as const, down: 0 as const }[rating]);
24
+ return success;
25
+ } catch {
26
+ return false;
27
+ }
28
+ };
29
+
30
+ const onCopyMessage = (spanId: string) => {
31
+ setResponseMetadata(spanId, { copied_to_clipboard: 'true' }).catch(() => {});
32
+ };
33
+
34
+ return (
35
+ <AiChat
36
+ chatMessages={chatMessages}
37
+ sendMessage={sendMessage}
38
+ rateMessage={rateMessage}
39
+ onCopyMessage={onCopyMessage}
40
+ />
41
+ );
42
+ }
@@ -0,0 +1,11 @@
1
+ /**
2
+ * Identifier for tracking unique users in braintrust
3
+ */
4
+ export function getClientId() {
5
+ let clientId = localStorage.getItem('stainless-client-id');
6
+ if (!clientId) {
7
+ clientId = crypto.randomUUID();
8
+ localStorage.setItem('stainless-client-id', clientId);
9
+ }
10
+ return clientId;
11
+ }
package/src/api/index.ts CHANGED
@@ -1,9 +1,37 @@
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 {
4
+ type RequestBody,
5
+ responseChunk,
6
+ type FeedbackRequestBody,
7
+ feedbackResponseBody,
8
+ type MetadataRequestBody,
9
+ metadataResponseBody,
10
+ } from './schemas';
4
11
  import { streamAsyncIterator } from './util';
12
+ import { getClientId } from './client-id';
5
13
 
6
- export const CHAT_ENDPOINT = 'https://app.stainless.com/api/ai/get-agentic-help';
14
+ export const API_URL = new URL('https://app.stainless.com/api/');
15
+ export const CHAT_ENDPOINT = new URL('ai/get-agentic-help', API_URL);
16
+
17
+ export const FEEDBACK_ENDPOINT = (spanId: string) => new URL(`ai/agentic-help/${spanId}/score`, API_URL);
18
+ export const METADATA_ENDPOINT = (spanId: string) => new URL(`ai/agentic-help/${spanId}/metadata`, API_URL);
19
+
20
+ /** Context on what the user is currently viewing to pass to the agent */
21
+ function getPageContext({ siteTitle }: { siteTitle: string | undefined }) {
22
+ const { href } = window.location;
23
+ const markdownUrl = `${href.replace(/\/$/, '')}/index.md`;
24
+ const pageTitle = document.querySelector('h1')?.textContent;
25
+ return [
26
+ `The user is viewing a documentation page${siteTitle ? ` for ${siteTitle}` : ''}.`,
27
+ `- Content URL: ${markdownUrl}`,
28
+ pageTitle && `- Page title: “${pageTitle}”`,
29
+ // TODO: include stainless path here? does the agent know how to use it?
30
+ // TODO: pass more of the page content into context without the agent having to retrieve it
31
+ ]
32
+ .filter(Boolean)
33
+ .join('\n');
34
+ }
7
35
 
8
36
  /**
9
37
  * Stream chat response from the server
@@ -14,15 +42,17 @@ export async function* getChatResponse(
14
42
  project,
15
43
  language,
16
44
  priorMessages,
45
+ siteTitle,
17
46
  }: {
18
47
  query: string;
19
48
  project: string;
20
49
  language: DocsLanguage;
21
50
  priorMessages: NonNullable<RequestBody['additionalContext']>['prior_messages'];
51
+ siteTitle: string | undefined;
22
52
  },
23
53
  abortSignal: AbortSignal,
24
54
  ) {
25
- const req = await fetch(CHAT_ENDPOINT, {
55
+ const res = await fetch(CHAT_ENDPOINT, {
26
56
  method: 'POST',
27
57
  headers: {
28
58
  'Content-Type': 'application/json',
@@ -31,17 +61,53 @@ export async function* getChatResponse(
31
61
  query,
32
62
  sdk: { project, language },
33
63
  stream: true,
34
- additionalContext: { prior_messages: priorMessages },
64
+ additionalContext: {
65
+ prior_messages: priorMessages,
66
+ intent: getPageContext({ siteTitle }),
67
+ },
68
+ browser_id: getClientId(),
35
69
  } satisfies RequestBody),
36
70
 
37
71
  signal: abortSignal,
38
72
  });
39
73
 
40
- if (!req.ok || !req.body) throw new Error(`Chat request failed with status ${req.status}`);
74
+ if (!res.ok || !res.body) throw new Error(`Chat request failed with status ${res.status}`);
41
75
 
42
76
  const parser = new JSONParser({ separator: '\n', paths: ['$'] });
43
- for await (const chunk of streamAsyncIterator(req.body.pipeThrough(parser))) {
77
+ for await (const chunk of streamAsyncIterator(res.body.pipeThrough(parser))) {
44
78
  const chunkParsed = responseChunk.safeParse(chunk.value);
45
79
  if (chunkParsed.success) yield chunkParsed.data;
46
80
  }
47
81
  }
82
+
83
+ /**
84
+ * Attach a score to a response
85
+ */
86
+ export async function submitResponseFeedback(spanId: string, score: 0 | 1) {
87
+ const res = await fetch(FEEDBACK_ENDPOINT(spanId), {
88
+ method: 'PUT',
89
+ headers: {
90
+ 'Content-Type': 'application/json',
91
+ },
92
+ body: JSON.stringify({ score } satisfies FeedbackRequestBody),
93
+ });
94
+
95
+ if (!res.ok) throw new Error(`Feedback request failed with status ${res.status}`);
96
+ return feedbackResponseBody.parse(await res.json());
97
+ }
98
+
99
+ /**
100
+ * Attach a score to a response
101
+ */
102
+ export async function setResponseMetadata(spanId: string, metadata: Record<string, string>) {
103
+ const res = await fetch(METADATA_ENDPOINT(spanId), {
104
+ method: 'PUT',
105
+ headers: {
106
+ 'Content-Type': 'application/json',
107
+ },
108
+ body: JSON.stringify({ metadata } satisfies MetadataRequestBody),
109
+ });
110
+
111
+ if (!res.ok) throw new Error(`Metadata request failed with status ${res.status}`);
112
+ return metadataResponseBody.parse(await res.json());
113
+ }
@@ -14,6 +14,7 @@ export const requestBody = z.object({
14
14
  maxTokens: z.number().optional(),
15
15
  })
16
16
  .optional(),
17
+ session_id: z.string().optional(),
17
18
  additionalContext: z
18
19
  .object({
19
20
  prior_messages: z.array(
@@ -28,6 +29,7 @@ export const requestBody = z.object({
28
29
  errors: z.string().optional(),
29
30
  })
30
31
  .optional(),
32
+ browser_id: z.string().optional(),
31
33
  });
32
34
  export type RequestBody = z.input<typeof requestBody>;
33
35
 
@@ -48,6 +50,19 @@ export const responseChunk = z.discriminatedUnion('type', [
48
50
  }),
49
51
  z.object({
50
52
  type: z.literal('done'),
53
+ span_id: z.string(),
54
+ }),
55
+ z.object({
56
+ type: z.literal('start_session'),
57
+ session_id: z.string(),
51
58
  }),
52
59
  ]);
53
60
  export type ResponseChunk = z.infer<typeof responseChunk>;
61
+
62
+ export const feedbackRequestBody = z.object({ score: z.number().min(0).max(1) });
63
+ export type FeedbackRequestBody = z.infer<typeof feedbackRequestBody>;
64
+ export const feedbackResponseBody = z.object({ success: z.boolean() });
65
+
66
+ export const metadataRequestBody = z.object({ metadata: z.record(z.string(), z.string()) });
67
+ export type MetadataRequestBody = z.infer<typeof metadataRequestBody>;
68
+ export const metadataResponseBody = z.object({ success: z.boolean() });
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,7 +111,15 @@ 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
124
  const abortController = useRef(new AbortController());
111
125
  useEffect(() => {
@@ -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
+ abortController.current.signal,
157
+ )) {
158
+ if (abortController.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) {
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", "plugin.tsx", "*.config.*"]
7
7
  }
@@ -1,7 +0,0 @@
1
- import 'react';
2
-
3
- declare module 'react' {
4
- interface CSSProperties {
5
- [key: `--${string}`]: string | number;
6
- }
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
- }
@@ -1,42 +0,0 @@
1
- import type { ChatMessage } from '../hook';
2
- import Message from './ChatMessage';
3
- import ToolCall from './ToolCall';
4
-
5
- import { motion } from 'motion/react';
6
-
7
- import styles from '../AiChat.module.css';
8
-
9
- export default function ChatLog({ messages }: { messages: ChatMessage[] }) {
10
- return (
11
- <motion.ul
12
- layout
13
- role="log"
14
- aria-live="polite"
15
- className={styles['message-log']}
16
- initial={{ opacity: 0, filter: `blur(4px)` }}
17
- animate={{ opacity: 1, filter: `blur(0px)` }}
18
- >
19
- {messages.map((msg) => {
20
- if (msg.role === 'user') {
21
- return (
22
- <Message key={msg.id} role="user">
23
- {msg.content}
24
- </Message>
25
- );
26
- }
27
-
28
- if (msg.role === 'assistant' && msg.messageType === 'text') {
29
- return (
30
- <Message key={msg.id} role={msg.role} isMarkdown isStreaming={!msg.isComplete}>
31
- {msg.content}
32
- </Message>
33
- );
34
- }
35
-
36
- if (msg.role === 'assistant' && msg.messageType === 'tool_use') {
37
- return <ToolCall key={msg.id} message={msg} />;
38
- }
39
- })}
40
- </motion.ul>
41
- );
42
- }
@@ -1,43 +0,0 @@
1
- import remend from 'remend';
2
- import Markdown from 'react-markdown';
3
- import { motion } from 'motion/react';
4
-
5
- import highlightCodeComponent from './CodeBlock';
6
-
7
- import clsx from 'clsx';
8
- import styles from '../AiChat.module.css';
9
-
10
- export default function ChatMessage({
11
- children,
12
- role,
13
- isStreaming = false,
14
- isMarkdown = false,
15
- }: {
16
- children: string;
17
- role: 'user' | 'assistant';
18
- isStreaming?: boolean;
19
- isMarkdown?: boolean;
20
- }) {
21
- return (
22
- <motion.li
23
- layout="position"
24
- data-message-role={role}
25
- className={clsx(styles['chat-message'], 'stl-ui-prose', 'smaller-headings')}
26
- style={{ borderRadius: 16 }}
27
- >
28
- {/* inner div provides scale correction while outer container transforms */}
29
- {isMarkdown ? (
30
- <Markdown
31
- components={{
32
- ...highlightCodeComponent,
33
- }}
34
- >
35
- {/* repair incomplete markdown syntax during streaming to ensure proper rendering */}
36
- {isStreaming ? remend(children) : children}
37
- </Markdown>
38
- ) : (
39
- <p>{children}</p>
40
- )}
41
- </motion.li>
42
- );
43
- }
@@ -1,29 +0,0 @@
1
- import { Fragment } from 'react';
2
-
3
- import { LightAsync as SyntaxHighlighter } from 'react-syntax-highlighter';
4
-
5
- import type { Components } from 'react-markdown';
6
- import clsx from 'clsx';
7
- import './hljs-github.css';
8
-
9
- export default {
10
- code(props) {
11
- const { children, className, ref, style, ...rest } = props;
12
- const match = /language-(\w+)/.exec(className || '');
13
- return match ? (
14
- <SyntaxHighlighter
15
- {...rest}
16
- PreTag={Fragment}
17
- language={match[1]}
18
- useInlineStyles
19
- codeTagProps={{ className: clsx(className, 'hljs-github') }}
20
- >
21
- {String(children).replace(/\n$/, '')}
22
- </SyntaxHighlighter>
23
- ) : (
24
- <code {...rest} className={className}>
25
- {children}
26
- </code>
27
- );
28
- },
29
- } satisfies Components;
@@ -1,36 +0,0 @@
1
- import { motion } from 'motion/react';
2
- import z from 'zod';
3
-
4
- import type { ChatMessage } from '../hook';
5
-
6
- import styles from '../AiChat.module.css';
7
- import clsx from 'clsx';
8
-
9
- type ToolUseMessage = Extract<ChatMessage, { role: 'assistant'; messageType: 'tool_use' }>;
10
-
11
- export default function ToolCall({
12
- message,
13
- }: {
14
- message: Pick<ToolUseMessage, 'id' | 'toolName' | 'input'>;
15
- }) {
16
- // Render docs searches
17
- if (message.toolName.endsWith('search_docs')) {
18
- const parsed = z.object({ query: z.string() }).safeParse(message.input);
19
- if (parsed.success) {
20
- return (
21
- <motion.li
22
- layout="position"
23
- data-message-role="assistant"
24
- className={clsx(styles['chat-message'], styles['tool-use'])}
25
- >
26
- <p>
27
- Searched for <em>{parsed.data.query}</em>
28
- </p>
29
- </motion.li>
30
- );
31
- }
32
- }
33
-
34
- // No other tool renderers yet
35
- return null;
36
- }
@@ -1,81 +0,0 @@
1
- .hljs-github {
2
- /* color: light-dark(#24292e, #c9d1d9); */
3
- /* background: light-dark(#ffffff, #0d1117); */
4
-
5
- .hljs-doctag,
6
- .hljs-keyword,
7
- .hljs-meta .hljs-keyword,
8
- .hljs-template-tag,
9
- .hljs-template-variable,
10
- .hljs-type,
11
- .hljs-variable.language_ {
12
- color: light-dark(#d73a49, #ff7b72);
13
- }
14
-
15
- .hljs-title,
16
- .hljs-title.class_,
17
- .hljs-title.class_.inherited__,
18
- .hljs-title.function_ {
19
- color: light-dark(#6f42c1, #d2a8ff);
20
- }
21
-
22
- .hljs-attr,
23
- .hljs-attribute,
24
- .hljs-literal,
25
- .hljs-meta,
26
- .hljs-number,
27
- .hljs-operator,
28
- .hljs-variable,
29
- .hljs-selector-attr,
30
- .hljs-selector-class,
31
- .hljs-selector-id {
32
- color: light-dark(#005cc5, #79c0ff);
33
- }
34
-
35
- .hljs-regexp,
36
- .hljs-string,
37
- .hljs-meta .hljs-string {
38
- color: light-dark(#032f62, #a5d6ff);
39
- }
40
-
41
- .hljs-built_in,
42
- .hljs-symbol {
43
- color: light-dark(#e36209, #ffa657);
44
- }
45
-
46
- .hljs-comment,
47
- .hljs-code,
48
- .hljs-formula {
49
- color: light-dark(#6a737d, #8b949e);
50
- }
51
-
52
- .hljs-name,
53
- .hljs-quote,
54
- .hljs-selector-tag,
55
- .hljs-selector-pseudo {
56
- color: light-dark(#22863a, #7ee787);
57
- }
58
-
59
- .hljs-subst {
60
- color: light-dark(#24292e, #c9d1d9);
61
- }
62
-
63
- .hljs-section {
64
- color: light-dark(#005cc5, #1f6feb);
65
- font-weight: bold;
66
- }
67
-
68
- .hljs-bullet {
69
- color: light-dark(#735c0f, #f2cc60);
70
- }
71
-
72
- .hljs-emphasis {
73
- color: light-dark(#24292e, #c9d1d9);
74
- font-style: italic;
75
- }
76
-
77
- .hljs-strong {
78
- color: light-dark(#24292e, #c9d1d9);
79
- font-weight: bold;
80
- }
81
- }