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

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,126 @@
1
1
  # @stainless-api/docs-ai-chat
2
2
 
3
- ## 0.1.0-beta.4
3
+ ## 1.0.0-beta.40
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
+ - Updated dependencies [2919b0a]
8
+ - Updated dependencies [e005e5c]
9
+ - @stainless-api/docs-ui@0.1.0-beta.75
14
10
 
15
- ## 0.1.0-beta.3
11
+ ## 0.1.0-beta.39
16
12
 
17
13
  ### Patch Changes
18
14
 
19
- - 77c0d47: steelie: submit on enter
15
+ - Updated dependencies [415629f]
16
+ - @stainless-api/docs-ui@0.1.0-beta.74
20
17
 
21
- ## 0.1.0-beta.2
18
+ ## 0.1.0-beta.38
22
19
 
23
20
  ### Patch Changes
24
21
 
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
22
+ - Updated dependencies [5c36876]
23
+ - @stainless-api/docs-ui@0.1.0-beta.73
32
24
 
33
- ## 0.1.0-beta.1
25
+ ## 0.1.0-beta.37
34
26
 
35
- ### Minor Changes
27
+ ### Patch Changes
28
+
29
+ - Updated dependencies [6b86a8b]
30
+ - @stainless-api/docs-ui@0.1.0-beta.72
31
+
32
+ ## 0.1.0-beta.36
33
+
34
+ ### Patch Changes
35
+
36
+ - Updated dependencies [cd578b7]
37
+ - @stainless-api/docs-ui@0.1.0-beta.71
38
+
39
+ ## 0.1.0-beta.35
40
+
41
+ ### Patch Changes
42
+
43
+ - Updated dependencies [93c8f94]
44
+ - @stainless-api/docs-ui@0.1.0-beta.70
45
+
46
+ ## 0.1.0-beta.34
47
+
48
+ ### Patch Changes
49
+
50
+ - Updated dependencies [61ba36f]
51
+ - @stainless-api/docs-ui@0.1.0-beta.69
52
+
53
+ ## 0.1.0-beta.33
54
+
55
+ ### Patch Changes
56
+
57
+ - Updated dependencies [a3f1ede]
58
+ - @stainless-api/docs-ui@0.1.0-beta.68
59
+
60
+ ## 0.1.0-beta.32
61
+
62
+ ### Patch Changes
63
+
64
+ - @stainless-api/ai-chat@0.1.0-beta.5
65
+ - @stainless-api/docs-ui@0.1.0-beta.67
66
+
67
+ ## 0.1.0-beta.31
68
+
69
+ ### Patch Changes
70
+
71
+ - Updated dependencies [65a1c9b]
72
+ - Updated dependencies [4f1cee7]
73
+ - Updated dependencies [4c72a83]
74
+ - Updated dependencies [068469b]
75
+ - @stainless-api/docs-ui@0.1.0-beta.66
76
+
77
+ ## 0.1.0-beta.30
78
+
79
+ ### Patch Changes
80
+
81
+ - Updated dependencies [b62eb05]
82
+ - @stainless-api/docs-ui@0.1.0-beta.65
83
+ - @stainless-api/ai-chat@0.1.0-beta.4
84
+
85
+ ## 0.1.0-beta.29
86
+
87
+ ### Patch Changes
88
+
89
+ - Updated dependencies [52ece13]
90
+ - Updated dependencies [3411ffe]
91
+ - Updated dependencies [7439be7]
92
+ - @stainless-api/ai-chat@0.1.0-beta.3
93
+ - @stainless-api/docs-ui@0.1.0-beta.64
94
+
95
+ ## 0.1.0-beta.28
96
+
97
+ ### Patch Changes
98
+
99
+ - Updated dependencies [274cefc]
100
+ - @stainless-api/docs-ui@0.1.0-beta.63
101
+
102
+ ## 0.1.0-beta.27
103
+
104
+ ### Patch Changes
105
+
106
+ - Updated dependencies [6ef241e]
107
+ - Updated dependencies [d3a85b5]
108
+ - Updated dependencies [d3a85b5]
109
+ - Updated dependencies [2dcb5fb]
110
+ - @stainless-api/docs-ui@0.1.0-beta.62
111
+
112
+ ## 0.1.0-beta.26
113
+
114
+ ### Patch Changes
115
+
116
+ - Updated dependencies [7155fae]
117
+ - @stainless-api/ai-chat@0.1.0-beta.2
118
+ - @stainless-api/docs-ui@0.1.0-beta.61
36
119
 
37
- - 2d00f0d: first version of docs-ai-chat
120
+ ## 0.1.0-beta.25
38
121
 
39
122
  ### Patch Changes
40
123
 
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
124
+ - 5c257e2: separate steelie into separate packages
125
+ - Updated dependencies [9dda4cf]
126
+ - @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.40",
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
@@ -13,16 +41,18 @@ export async function* getChatResponse(
13
41
  query,
14
42
  project,
15
43
  language,
16
- priorMessages,
44
+ sessionId,
45
+ siteTitle,
17
46
  }: {
18
47
  query: string;
19
48
  project: string;
20
49
  language: DocsLanguage;
21
- priorMessages: NonNullable<RequestBody['additionalContext']>['prior_messages'];
50
+ sessionId: string | undefined;
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
+ session_id: sessionId,
65
+ additionalContext: {
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,20 +14,16 @@ 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
- prior_messages: z.array(
20
- z.object({
21
- role: z.enum(['user', 'assistant']),
22
- content: z.string(),
23
- }),
24
- ),
25
20
  code: z.string().optional(),
26
21
  intent: z.string().optional(),
27
22
  lsp: z.string().optional(),
28
23
  errors: z.string().optional(),
29
24
  })
30
25
  .optional(),
26
+ browser_id: z.string().optional(),
31
27
  });
32
28
  export type RequestBody = z.input<typeof requestBody>;
33
29
 
@@ -48,6 +44,19 @@ export const responseChunk = z.discriminatedUnion('type', [
48
44
  }),
49
45
  z.object({
50
46
  type: z.literal('done'),
47
+ span_id: z.string(),
48
+ }),
49
+ z.object({
50
+ type: z.literal('start_session'),
51
+ session_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() });
59
+
60
+ export const metadataRequestBody = z.object({ metadata: z.record(z.string(), z.string()) });
61
+ export type MetadataRequestBody = z.infer<typeof metadataRequestBody>;
62
+ 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(() => {
@@ -116,6 +130,7 @@ export function useChat({ projectId, language }: { projectId: string; language:
116
130
  return () => ac.abort('Component unmounted');
117
131
  }, []);
118
132
 
133
+ const sessionId = useRef<string | undefined>(undefined);
119
134
  const [chatMessages, dispatch] = useReducer(chatReducer, []);
120
135
 
121
136
  /** Send a message and stream back the response in chat */
@@ -127,66 +142,78 @@ export function useChat({ projectId, language }: { projectId: string; language:
127
142
  let currentResponseId = crypto.randomUUID(); // for streaming text messages
128
143
  let lastChunkType: ResponseChunk['type'] | undefined = undefined;
129
144
 
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
- }
145
+ try {
146
+ for await (const chunk of getChatResponse(
147
+ {
148
+ query: question,
149
+ project: projectId,
150
+ language,
151
+ sessionId: sessionId.current,
152
+ siteTitle,
153
+ },
154
+ abortController.current.signal,
155
+ )) {
156
+ if (abortController.current.signal.aborted) break;
157
+
158
+ // store session id at start of session
159
+ if (chunk.type === 'start_session') {
160
+ sessionId.current = chunk.session_id;
161
+ }
147
162
 
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;
163
+ // mark complete when text messages finish streaming
164
+ if (lastChunkType === 'text' && chunk.type !== 'text') {
165
+ dispatch({ type: 'completeMessage', id: currentResponseId });
166
+ }
151
167
 
152
- if (chunk.type === 'text') {
153
- if (lastChunkType !== 'text') {
154
- // start a new text message
155
- currentResponseId = crypto.randomUUID();
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
+ }
173
+
174
+ if (chunk.type === 'text') {
175
+ if (lastChunkType !== 'text') {
176
+ // start a new text message
177
+ currentResponseId = crypto.randomUUID();
178
+ dispatch({
179
+ type: 'beginAssistantMessage',
180
+ message: {
181
+ content: chunk.text,
182
+ id: currentResponseId,
183
+ messageType: chunk.type,
184
+ respondingTo: userMessageId,
185
+ },
186
+ });
187
+ } else {
188
+ // continue the current message with the new content
189
+ dispatch({ type: 'streamMessage', id: currentResponseId, newContent: chunk.text });
190
+ }
191
+ }
192
+
193
+ if (chunk.type === 'tool_use') {
156
194
  dispatch({
157
- type: 'beginAssistantMessage',
195
+ type: 'addAssistantToolCall',
158
196
  message: {
159
- content: chunk.text,
160
- id: currentResponseId,
161
- messageType: chunk.type,
162
197
  respondingTo: userMessageId,
198
+ messageType: chunk.type,
199
+ toolName: chunk.name,
200
+ input: chunk.input,
163
201
  },
164
202
  });
165
- } else {
166
- // continue the current message with the new content
167
- dispatch({ type: 'streamMessage', id: currentResponseId, newContent: chunk.text });
168
203
  }
169
- }
170
204
 
171
- if (chunk.type === 'tool_use') {
172
- dispatch({
173
- type: 'addAssistantToolCall',
174
- message: {
175
- respondingTo: userMessageId,
176
- messageType: chunk.type,
177
- toolName: chunk.name,
178
- input: chunk.input,
179
- },
180
- });
205
+ lastChunkType = chunk.type;
181
206
  }
182
-
183
- lastChunkType = chunk.type;
207
+ } catch {
208
+ dispatch({
209
+ type: 'addError',
210
+ respondingTo: userMessageId,
211
+ errorMessage: 'Something went wrong. Please try again.',
212
+ });
184
213
  }
185
214
  },
186
- [language, projectId, chatMessages],
215
+ [language, projectId, siteTitle],
187
216
  );
188
217
 
189
- // TODO: error handling
190
-
191
218
  return { chatMessages, sendMessage };
192
219
  }
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
- }