deepspace 0.7.0 → 0.8.0
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 +30 -0
- package/README.md +22 -8
- package/dist/cli.js +10306 -5256
- package/dist/cli.js.map +1 -1
- package/dist/feature-installer.js +638 -0
- package/dist/feature-installer.js.map +1 -0
- package/dist/index.d.ts +107 -117
- package/dist/index.js +610 -779
- package/dist/index.js.map +1 -1
- package/dist/server.d.ts +403 -167
- package/dist/server.js +498 -367
- package/dist/server.js.map +1 -1
- package/dist/testing.d.ts +115 -0
- package/dist/testing.js +3 -3
- package/dist/testing.js.map +1 -1
- package/dist/worker.d.ts +397 -160
- package/dist/worker.js +506 -367
- package/dist/worker.js.map +1 -1
- package/features/ai-chat/feature.json +2 -0
- package/features/ai-chat/src/AiChatPage.tsx +2 -3
- package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
- package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
- package/features/ai-chat/src/ChatPanel.tsx +158 -862
- package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
- package/features/canvas/src/CanvasListPage.tsx +1 -1
- package/features/docs/feature.json +4 -8
- package/features/docs/src/DocsEditorPage.tsx +33 -326
- package/features/docs/src/DocsListPage.tsx +33 -29
- package/features/docs/src/InviteDialog.tsx +11 -40
- package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
- package/features/docs/src/docs-library-types.ts +28 -3
- package/features/docs/src/docs-ui.css +1 -4
- package/features/docs/src/use-docs-presence-access.ts +330 -0
- package/features/file-attachments/feature.json +2 -2
- package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
- package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
- package/features/file-attachments/src/useFilePreview.ts +54 -68
- package/features/file-manager/src/FileManagerPage.tsx +1 -1
- package/features/integration-test/src/integration.spec.ts +1 -1
- package/features/items/feature.json +1 -5
- package/features/items/src/ItemsPage.tsx +207 -135
- package/features/landing/feature.json +3 -0
- package/features/landing/src/LandingPage.tsx +4 -18
- package/features/leaderboard/feature.json +1 -5
- package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
- package/features/messaging/feature.json +0 -4
- package/features/messaging/src/ChatMultiPage.tsx +1 -3
- package/features/messaging/src/ChatPage.tsx +1 -3
- package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
- package/features/search-bar/feature.json +8 -3
- package/features/search-bar/src/SearchOverlay.tsx +108 -252
- package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
- package/features/search-bar/src/search-model.ts +223 -0
- package/features/tasks/src/TasksPage.tsx +3 -3
- package/features/topbar/src/Topbar.tsx +0 -17
- package/package.json +1 -2
- package/features/docs/src/docs-favorites.ts +0 -18
- package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
- package/features/file-attachments/src/attachments-constants.ts +0 -9
- package/features/items/src/items-constants.ts +0 -10
- package/features/leaderboard/src/leaderboard-constants.ts +0 -21
- package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
- package/scripts/add-feature.cjs +0 -807
|
@@ -1,48 +1,35 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* DO-backed AI chat surface.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* pass `chatId={null}` and we'll auto-create on first send via `onChatCreated`.
|
|
4
|
+
* The parent owns chat lifecycle. Pass `chatId={null}` to create on first send
|
|
5
|
+
* and update the parent from `onChatCreated`.
|
|
7
6
|
*/
|
|
8
7
|
|
|
9
8
|
import {
|
|
10
|
-
memo,
|
|
11
|
-
useState,
|
|
12
|
-
useRef,
|
|
13
9
|
useEffect,
|
|
14
10
|
useMemo,
|
|
15
|
-
|
|
11
|
+
useRef,
|
|
12
|
+
useState,
|
|
16
13
|
type KeyboardEvent,
|
|
17
14
|
type ReactNode,
|
|
18
15
|
} from 'react'
|
|
19
|
-
import ReactMarkdown, { type Components } from 'react-markdown'
|
|
20
|
-
import remarkGfm from 'remark-gfm'
|
|
21
|
-
import remarkBreaks from 'remark-breaks'
|
|
22
|
-
import rehypeHighlight from 'rehype-highlight'
|
|
23
16
|
import {
|
|
24
17
|
AlertCircle,
|
|
25
18
|
ArrowUp,
|
|
26
19
|
Check,
|
|
27
20
|
ChevronDown,
|
|
28
|
-
Copy,
|
|
29
21
|
Square,
|
|
30
22
|
} from 'lucide-react'
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
import '
|
|
34
|
-
import {
|
|
35
|
-
getAuthToken,
|
|
36
|
-
useQuery,
|
|
37
|
-
parseSseLine,
|
|
38
|
-
decodeAiStreamChunk,
|
|
39
|
-
type AiStreamAction,
|
|
40
|
-
} from 'deepspace'
|
|
23
|
+
import { useQuery } from 'deepspace'
|
|
24
|
+
import { EmptyState, MessageTurn, ThinkingIndicator } from './ChatPanel.messages'
|
|
25
|
+
import { useStreamingChat } from './ChatPanel.stream'
|
|
41
26
|
|
|
42
|
-
type ModelOption = {
|
|
27
|
+
export type ModelOption = {
|
|
28
|
+
id: string
|
|
29
|
+
label: string
|
|
30
|
+
provider: string
|
|
31
|
+
}
|
|
43
32
|
|
|
44
|
-
// The ai-messages columns. useQuery wraps this in a RecordData envelope
|
|
45
|
-
// (recordId / data / createdAt / updatedAt) — we flatten before rendering.
|
|
46
33
|
type AiMessageData = {
|
|
47
34
|
chatId: string
|
|
48
35
|
userId: string
|
|
@@ -53,573 +40,187 @@ type AiMessageData = {
|
|
|
53
40
|
|
|
54
41
|
type RenderMessage = {
|
|
55
42
|
id: string
|
|
56
|
-
role: '
|
|
43
|
+
role: AiMessageData['role']
|
|
57
44
|
content: string
|
|
58
45
|
parts?: unknown[]
|
|
59
46
|
}
|
|
60
47
|
|
|
61
|
-
type
|
|
62
|
-
|
|
63
|
-
role: 'user' | 'assistant'
|
|
64
|
-
content: string
|
|
65
|
-
parts: MessagePart[]
|
|
66
|
-
/** Server-assigned recordId for the row that will be persisted at
|
|
67
|
-
* onFinish — set on assistant messages once the response header
|
|
68
|
-
* `X-Asst-Id` arrives. Dedup against the WebSocket-broadcast persisted
|
|
69
|
-
* row checks this id (which matches), avoiding the clock-skew bug that
|
|
70
|
-
* bit the previous `spawnTime` vs `createdAt` comparison. Undefined for
|
|
71
|
-
* user rows (whose `id` already matches the server-side recordId). */
|
|
72
|
-
serverId?: string
|
|
73
|
-
/** The chat this overlay belongs to. Items from a previous chat must
|
|
74
|
-
* not render in the current view. */
|
|
75
|
-
forChatId: string
|
|
76
|
-
}
|
|
77
|
-
|
|
78
|
-
export interface ChatPanelProps {
|
|
79
|
-
/** Active chat. `null` triggers auto-create on first send. */
|
|
48
|
+
export type ChatPanelProps = {
|
|
49
|
+
/** Active chat. `null` creates one on first send. */
|
|
80
50
|
chatId: string | null
|
|
81
|
-
/** Current user id; scopes the messages query as defense
|
|
51
|
+
/** Current user id; scopes the messages query as defense in depth. */
|
|
82
52
|
userId: string
|
|
83
|
-
/**
|
|
53
|
+
/** Required in practice when `chatId` is null so the parent tracks the new chat. */
|
|
84
54
|
onChatCreated?: (chatId: string) => void
|
|
85
|
-
/** Models
|
|
55
|
+
/** Models shown in the picker. */
|
|
86
56
|
models?: ModelOption[]
|
|
87
57
|
/** Clickable prompts shown when the conversation is empty. */
|
|
88
58
|
emptyStatePrompts?: string[]
|
|
89
|
-
/** Applied to the outer container.
|
|
59
|
+
/** Applied to the outer container. */
|
|
90
60
|
className?: string
|
|
91
|
-
/** Optional
|
|
61
|
+
/** Optional content rendered above the messages. */
|
|
92
62
|
header?: ReactNode
|
|
93
|
-
/** Tighter
|
|
63
|
+
/** Tighter spacing for narrow containers. */
|
|
94
64
|
compact?: boolean
|
|
95
|
-
/**
|
|
96
|
-
* this, a fast typist can submit between the parent's eager-create POST
|
|
97
|
-
* and its response — both layers would auto-create, spawning two chats. */
|
|
65
|
+
/** Suspends send while a parent-owned create is in flight. */
|
|
98
66
|
disabled?: boolean
|
|
99
67
|
}
|
|
100
68
|
|
|
101
69
|
const MODEL_STORAGE_KEY = 'deepspace-ai-model'
|
|
102
|
-
|
|
103
70
|
const DEFAULT_PROMPTS = [
|
|
104
71
|
'What can you help with?',
|
|
105
72
|
'Summarize recent activity',
|
|
106
73
|
'List my collections',
|
|
107
74
|
]
|
|
108
75
|
|
|
109
|
-
//
|
|
110
|
-
// default — balanced cost/capability with the same 1M-token context as Opus
|
|
111
|
-
// at a lower price. Within each provider we list default → premium → cheap
|
|
112
|
-
// so the picker shows the recommended option first when a user opens it.
|
|
113
|
-
// Override by passing your own `models` prop — apps scaffolded before this
|
|
114
|
-
// lineup must either update their ALLOWED_MODELS to match or pass the models
|
|
115
|
-
// they allowlist, otherwise the worker rejects the picker's ids with a 400.
|
|
76
|
+
// Keep this picker aligned with ALLOWED_MODELS in src/ai/chat-routes.ts.
|
|
116
77
|
const DEFAULT_MODELS: ModelOption[] = [
|
|
117
|
-
{ id: 'claude-sonnet-5',
|
|
118
|
-
{ id: 'claude-opus-4-8',
|
|
78
|
+
{ id: 'claude-sonnet-5', label: 'Claude Sonnet 5', provider: 'Anthropic' },
|
|
79
|
+
{ id: 'claude-opus-4-8', label: 'Claude Opus 4.8', provider: 'Anthropic' },
|
|
119
80
|
{ id: 'claude-haiku-4-5', label: 'Claude Haiku 4.5', provider: 'Anthropic' },
|
|
120
|
-
{ id: 'gpt-5.6-terra',
|
|
121
|
-
{ id: 'gpt-5.6-sol',
|
|
122
|
-
{ id: 'gpt-5.6-luna',
|
|
123
|
-
{ id: 'gpt-oss-120b',
|
|
81
|
+
{ id: 'gpt-5.6-terra', label: 'GPT-5.6 Terra', provider: 'OpenAI' },
|
|
82
|
+
{ id: 'gpt-5.6-sol', label: 'GPT-5.6 Sol', provider: 'OpenAI' },
|
|
83
|
+
{ id: 'gpt-5.6-luna', label: 'GPT-5.6 Luna', provider: 'OpenAI' },
|
|
84
|
+
{ id: 'gpt-oss-120b', label: 'GPT-OSS 120B', provider: 'Cerebras' },
|
|
124
85
|
]
|
|
125
86
|
|
|
126
|
-
// ============================================================================
|
|
127
|
-
// useStreamingChat — POSTs /api/ai/chat, parses Vercel data-stream lines,
|
|
128
|
-
// exposes an in-flight overlay until useQuery delivers the persisted rows.
|
|
129
|
-
// ============================================================================
|
|
130
|
-
|
|
131
|
-
function useStreamingChat(
|
|
132
|
-
chatId: string | null,
|
|
133
|
-
modelId: string | undefined,
|
|
134
|
-
onChatCreated?: (id: string) => void,
|
|
135
|
-
) {
|
|
136
|
-
const [inFlight, setInFlight] = useState<InFlightMessage[]>([])
|
|
137
|
-
const [isLoading, setIsLoading] = useState(false)
|
|
138
|
-
// Mirror of `isLoading` for use inside `send`'s in-flight guard. Reading
|
|
139
|
-
// from state directly would force `isLoading` into the useCallback deps,
|
|
140
|
-
// recreating `send` on every loading flip and exposing a stale-closure
|
|
141
|
-
// window where a programmatic retry could see the pre-flip closure.
|
|
142
|
-
const isLoadingRef = useRef(false)
|
|
143
|
-
const [error, setError] = useState<Error | null>(null)
|
|
144
|
-
const abortRef = useRef<AbortController | null>(null)
|
|
145
|
-
const lastSendRef = useRef<{ content: string } | null>(null)
|
|
146
|
-
const prevChatIdRef = useRef<string | null>(chatId)
|
|
147
|
-
|
|
148
|
-
// Abort on any transition AWAY from a real chatId — chat switch (id→id) and
|
|
149
|
-
// chat clear (id→null, e.g. the active chat being deleted). null→newId is
|
|
150
|
-
// the auto-create promotion mid-send and must NOT abort, or the user's
|
|
151
|
-
// first message dies before it reaches the server. Without aborting on
|
|
152
|
-
// id→null, deleting the chat you're streaming in lets onFinish persist an
|
|
153
|
-
// orphan assistant row tagged with a chatId that no longer exists.
|
|
154
|
-
useEffect(() => {
|
|
155
|
-
const prev = prevChatIdRef.current
|
|
156
|
-
prevChatIdRef.current = chatId
|
|
157
|
-
if (prev !== null && prev !== chatId) {
|
|
158
|
-
abortRef.current?.abort()
|
|
159
|
-
setInFlight([])
|
|
160
|
-
}
|
|
161
|
-
}, [chatId])
|
|
162
|
-
|
|
163
|
-
// Abort any in-flight stream on unmount — without this, navigating away
|
|
164
|
-
// mid-stream leaks the fetch (response body keeps being consumed, setState
|
|
165
|
-
// fires on an unmounted component, AI tokens keep accruing server-side).
|
|
166
|
-
useEffect(() => {
|
|
167
|
-
return () => {
|
|
168
|
-
abortRef.current?.abort()
|
|
169
|
-
}
|
|
170
|
-
}, [])
|
|
171
|
-
|
|
172
|
-
const send = useCallback(
|
|
173
|
-
async (content: string) => {
|
|
174
|
-
// Defense-in-depth guard for programmatic call paths (retry, etc.) —
|
|
175
|
-
// the canSend UI gate already blocks form submits while loading. We
|
|
176
|
-
// read from a ref (not state) so this callback stays stable across
|
|
177
|
-
// loading transitions; that closes the stale-closure window a retry
|
|
178
|
-
// click could otherwise hit during the brief moment between
|
|
179
|
-
// setIsLoading(true) and React re-rendering with the new closure.
|
|
180
|
-
if (isLoadingRef.current) return
|
|
181
|
-
setError(null)
|
|
182
|
-
lastSendRef.current = { content }
|
|
183
|
-
// Claim the in-flight slot BEFORE any await. The auto-create below is a
|
|
184
|
-
// real network round-trip; setting the guard only after it would let a
|
|
185
|
-
// second send() slip through with chatId still null and create a second
|
|
186
|
-
// chat (double-create race, orphaning the first turn).
|
|
187
|
-
isLoadingRef.current = true
|
|
188
|
-
setIsLoading(true)
|
|
189
|
-
|
|
190
|
-
let activeChatId = chatId
|
|
191
|
-
if (!activeChatId) {
|
|
192
|
-
try {
|
|
193
|
-
const token = await getAuthToken()
|
|
194
|
-
const res = await fetch('/api/ai/chats', {
|
|
195
|
-
method: 'POST',
|
|
196
|
-
headers: {
|
|
197
|
-
'Content-Type': 'application/json',
|
|
198
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
199
|
-
},
|
|
200
|
-
body: JSON.stringify({}),
|
|
201
|
-
})
|
|
202
|
-
if (!res.ok) throw new Error(`Failed to create chat: ${res.status}`)
|
|
203
|
-
const data = (await res.json()) as { chat: { id: string } }
|
|
204
|
-
activeChatId = data.chat.id
|
|
205
|
-
onChatCreated?.(activeChatId)
|
|
206
|
-
} catch (err) {
|
|
207
|
-
setError(err instanceof Error ? err : new Error(String(err)))
|
|
208
|
-
isLoadingRef.current = false
|
|
209
|
-
setIsLoading(false)
|
|
210
|
-
return
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
|
|
214
|
-
const localTs = Date.now()
|
|
215
|
-
const userMessageId = `usr-${localTs}-${Math.random().toString(36).slice(2, 8)}`
|
|
216
|
-
const assistantId = `asst-pending-${localTs}`
|
|
217
|
-
// forChatId uses the resolved id (whether passed in or just created above).
|
|
218
|
-
const userMsg: InFlightMessage = { id: userMessageId, role: 'user', content, parts: [], forChatId: activeChatId }
|
|
219
|
-
const asstMsg: InFlightMessage = { id: assistantId, role: 'assistant', content: '', parts: [], forChatId: activeChatId }
|
|
220
|
-
setInFlight([userMsg, asstMsg])
|
|
221
|
-
abortRef.current = new AbortController()
|
|
222
|
-
|
|
223
|
-
try {
|
|
224
|
-
const token = await getAuthToken()
|
|
225
|
-
const res = await fetch('/api/ai/chat', {
|
|
226
|
-
method: 'POST',
|
|
227
|
-
headers: {
|
|
228
|
-
'Content-Type': 'application/json',
|
|
229
|
-
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
230
|
-
},
|
|
231
|
-
body: JSON.stringify({ chatId: activeChatId, userMessageId, content, modelId }),
|
|
232
|
-
signal: abortRef.current.signal,
|
|
233
|
-
})
|
|
234
|
-
if (!res.ok || !res.body) {
|
|
235
|
-
const detail = res.body ? await res.text().catch(() => '') : ''
|
|
236
|
-
throw new Error(detail || `Request failed: ${res.status}`)
|
|
237
|
-
}
|
|
238
|
-
|
|
239
|
-
// Tag the in-flight assistant row with the server-assigned id so the
|
|
240
|
-
// dedup memo can drop it once the persisted row arrives over WS,
|
|
241
|
-
// independent of either party's clock.
|
|
242
|
-
const serverAsstId = res.headers.get('X-Asst-Id')
|
|
243
|
-
if (serverAsstId) {
|
|
244
|
-
setInFlight((cur) =>
|
|
245
|
-
cur.map((m) => (m.id === assistantId ? { ...m, serverId: serverAsstId } : m)),
|
|
246
|
-
)
|
|
247
|
-
}
|
|
248
|
-
|
|
249
|
-
// v5's `toUIMessageStreamResponse` emits SSE: `data: <json>\n\n`
|
|
250
|
-
// events terminating with `data: [DONE]\n\n`. `parseSseLine` +
|
|
251
|
-
// `decodeAiStreamChunk` are the SDK's pure decoders (covered by
|
|
252
|
-
// `ai-stream.test.ts`); we just apply the resulting actions to
|
|
253
|
-
// local React state.
|
|
254
|
-
const reader = res.body.getReader()
|
|
255
|
-
const decoder = new TextDecoder()
|
|
256
|
-
let buf = ''
|
|
257
|
-
while (true) {
|
|
258
|
-
const { done, value } = await reader.read()
|
|
259
|
-
if (done) break
|
|
260
|
-
buf += decoder.decode(value, { stream: true })
|
|
261
|
-
const lines = buf.split('\n')
|
|
262
|
-
buf = lines.pop() ?? ''
|
|
263
|
-
for (const line of lines) handleSseLine(line, assistantId, setInFlight, setError)
|
|
264
|
-
}
|
|
265
|
-
// Flush any pending bytes the streaming decoder held back at a
|
|
266
|
-
// multi-byte boundary, then process the trailing line.
|
|
267
|
-
buf += decoder.decode()
|
|
268
|
-
handleSseLine(buf, assistantId, setInFlight, setError)
|
|
269
|
-
} catch (err) {
|
|
270
|
-
const e = err instanceof Error ? err : new Error(String(err))
|
|
271
|
-
if (e.name !== 'AbortError') {
|
|
272
|
-
console.error('[chat] STREAM error', { name: e.name, message: e.message })
|
|
273
|
-
setError(e)
|
|
274
|
-
}
|
|
275
|
-
} finally {
|
|
276
|
-
isLoadingRef.current = false
|
|
277
|
-
setIsLoading(false)
|
|
278
|
-
abortRef.current = null
|
|
279
|
-
// No wall-clock timer to clear inFlight. Dedup is by id: user rows
|
|
280
|
-
// share their id with the server; assistant rows are matched via
|
|
281
|
-
// the server-emitted `X-Asst-Id` header captured into `serverId`.
|
|
282
|
-
}
|
|
283
|
-
},
|
|
284
|
-
[chatId, modelId, onChatCreated],
|
|
285
|
-
)
|
|
286
|
-
|
|
287
|
-
const stop = useCallback(() => {
|
|
288
|
-
abortRef.current?.abort()
|
|
289
|
-
}, [])
|
|
290
|
-
|
|
291
|
-
const retry = useCallback(() => {
|
|
292
|
-
if (lastSendRef.current) void send(lastSendRef.current.content)
|
|
293
|
-
}, [send])
|
|
294
|
-
|
|
295
|
-
return { send, stop, retry, isLoading, error, inFlight }
|
|
296
|
-
}
|
|
297
|
-
|
|
298
|
-
// SDK-decoded chunk → React state mutation. The decoder vocabulary
|
|
299
|
-
// (`AiStreamAction`) lives in `deepspace/src/client/ai-stream.ts` and is
|
|
300
|
-
// covered by unit tests. This function does the React-bound part: which
|
|
301
|
-
// in-flight message to update, how to coalesce text, when to drop the
|
|
302
|
-
// overlay. Keep it dumb — anything the decoder can decide should stay
|
|
303
|
-
// in the decoder so it remains testable in isolation.
|
|
304
|
-
function handleSseLine(
|
|
305
|
-
line: string,
|
|
306
|
-
assistantId: string,
|
|
307
|
-
setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
|
|
308
|
-
setError: (e: Error | null) => void,
|
|
309
|
-
) {
|
|
310
|
-
const chunk = parseSseLine(line)
|
|
311
|
-
if (!chunk) return
|
|
312
|
-
const action = decodeAiStreamChunk(chunk)
|
|
313
|
-
if (!action) return
|
|
314
|
-
applyStreamAction(action, assistantId, setInFlight, setError)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
function applyStreamAction(
|
|
318
|
-
action: AiStreamAction,
|
|
319
|
-
assistantId: string,
|
|
320
|
-
setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
|
|
321
|
-
setError: (e: Error | null) => void,
|
|
322
|
-
) {
|
|
323
|
-
switch (action.type) {
|
|
324
|
-
case 'append-text':
|
|
325
|
-
// Append to the last text part if it's still the tail; otherwise start
|
|
326
|
-
// a new text part. Keeps text + tool-invocations in chronological order.
|
|
327
|
-
setInFlight((cur) =>
|
|
328
|
-
cur.map((m) => {
|
|
329
|
-
if (m.id !== assistantId) return m
|
|
330
|
-
const last = m.parts[m.parts.length - 1]
|
|
331
|
-
if (last && last.type === 'text') {
|
|
332
|
-
const merged: MessagePart = { type: 'text', text: (last as { text: string }).text + action.delta }
|
|
333
|
-
return { ...m, content: m.content + action.delta, parts: [...m.parts.slice(0, -1), merged] }
|
|
334
|
-
}
|
|
335
|
-
return {
|
|
336
|
-
...m,
|
|
337
|
-
content: m.content + action.delta,
|
|
338
|
-
parts: [...m.parts, { type: 'text', text: action.delta }],
|
|
339
|
-
}
|
|
340
|
-
}),
|
|
341
|
-
)
|
|
342
|
-
return
|
|
343
|
-
|
|
344
|
-
case 'upsert-tool-call':
|
|
345
|
-
upsertToolInvocation(setInFlight, assistantId, action.toolCallId, {
|
|
346
|
-
toolName: action.toolName,
|
|
347
|
-
state: 'call',
|
|
348
|
-
args: action.input,
|
|
349
|
-
})
|
|
350
|
-
return
|
|
351
|
-
|
|
352
|
-
case 'finalize-tool-call':
|
|
353
|
-
finalizeToolInvocation(setInFlight, assistantId, action.toolCallId, {
|
|
354
|
-
result: action.result as ToolInvocation['result'],
|
|
355
|
-
})
|
|
356
|
-
return
|
|
357
|
-
|
|
358
|
-
case 'fail-tool-input':
|
|
359
|
-
// Schema validation failed BEFORE the tool ran; no preceding
|
|
360
|
-
// `tool-input-available` was emitted. Upsert a failed invocation
|
|
361
|
-
// (we have toolName + the rejected input) so the user sees a tool
|
|
362
|
-
// row with the failure dot, not a stuck spinner / nothing at all.
|
|
363
|
-
console.error('[chat] STREAM tool-input-error', {
|
|
364
|
-
toolCallId: action.toolCallId,
|
|
365
|
-
errorText: action.errorText,
|
|
366
|
-
})
|
|
367
|
-
upsertToolInvocation(setInFlight, assistantId, action.toolCallId, {
|
|
368
|
-
toolName: action.toolName,
|
|
369
|
-
state: 'result',
|
|
370
|
-
args: action.input,
|
|
371
|
-
result: { success: false, error: action.errorText } as ToolInvocation['result'],
|
|
372
|
-
})
|
|
373
|
-
return
|
|
374
|
-
|
|
375
|
-
case 'fail-tool-output':
|
|
376
|
-
// Tool ran (or failed during execution); a preceding `upsert-tool-call`
|
|
377
|
-
// already produced the in-flight invocation, so we just finalize it.
|
|
378
|
-
console.error('[chat] STREAM tool-output-error', {
|
|
379
|
-
toolCallId: action.toolCallId,
|
|
380
|
-
errorText: action.errorText,
|
|
381
|
-
})
|
|
382
|
-
finalizeToolInvocation(setInFlight, assistantId, action.toolCallId, {
|
|
383
|
-
result: { success: false, error: action.errorText } as ToolInvocation['result'],
|
|
384
|
-
})
|
|
385
|
-
return
|
|
386
|
-
|
|
387
|
-
case 'stream-error':
|
|
388
|
-
console.error('[chat] STREAM error', action.errorText)
|
|
389
|
-
setError(new Error(action.errorText))
|
|
390
|
-
// Drop the assistant overlay entirely — emptying it would leave a
|
|
391
|
-
// zero-content flex container that still eats the parent's gap, showing
|
|
392
|
-
// a phantom vertical space between the user message and the error banner.
|
|
393
|
-
setInFlight((cur) => cur.filter((m) => m.id !== assistantId))
|
|
394
|
-
return
|
|
395
|
-
|
|
396
|
-
case 'abort':
|
|
397
|
-
// Server-side abort with no error chunk to follow. Drop the empty
|
|
398
|
-
// assistant overlay so we don't leave a phantom bubble — but keep
|
|
399
|
-
// it if the user already saw partial text OR a tool invocation
|
|
400
|
-
// landed before the abort. `parts.length > 0` covers both: a
|
|
401
|
-
// text-delta appends to both `content` and `parts`, and a tool
|
|
402
|
-
// call lands in `parts` only.
|
|
403
|
-
setInFlight((cur) => cur.filter((m) => m.id !== assistantId || m.parts.length > 0))
|
|
404
|
-
return
|
|
405
|
-
|
|
406
|
-
default: {
|
|
407
|
-
// Exhaustiveness check — adding a new variant to AiStreamAction
|
|
408
|
-
// without a case here will fail this assignment at compile time.
|
|
409
|
-
const _exhaustive: never = action
|
|
410
|
-
void _exhaustive
|
|
411
|
-
return
|
|
412
|
-
}
|
|
413
|
-
}
|
|
414
|
-
}
|
|
415
|
-
|
|
416
|
-
function upsertToolInvocation(
|
|
417
|
-
setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
|
|
418
|
-
assistantId: string,
|
|
419
|
-
toolCallId: string,
|
|
420
|
-
inv: ToolInvocation,
|
|
421
|
-
) {
|
|
422
|
-
const part: MessagePart = { type: 'tool-invocation', toolInvocation: inv, toolCallId }
|
|
423
|
-
// Dedup by toolCallId — replace if already present (the multi-step loop
|
|
424
|
-
// can re-emit a tool-input-available for the same call on retry).
|
|
425
|
-
setInFlight((cur) =>
|
|
426
|
-
cur.map((m) => {
|
|
427
|
-
if (m.id !== assistantId) return m
|
|
428
|
-
const existingIdx = m.parts.findIndex(
|
|
429
|
-
(p) => p.type === 'tool-invocation' && (p as ToolInvocationPart).toolCallId === toolCallId,
|
|
430
|
-
)
|
|
431
|
-
if (existingIdx >= 0) {
|
|
432
|
-
const next = m.parts.slice()
|
|
433
|
-
next[existingIdx] = part
|
|
434
|
-
return { ...m, parts: next }
|
|
435
|
-
}
|
|
436
|
-
return { ...m, parts: [...m.parts, part] }
|
|
437
|
-
}),
|
|
438
|
-
)
|
|
439
|
-
}
|
|
440
|
-
|
|
441
|
-
function finalizeToolInvocation(
|
|
442
|
-
setInFlight: React.Dispatch<React.SetStateAction<InFlightMessage[]>>,
|
|
443
|
-
assistantId: string,
|
|
444
|
-
toolCallId: string,
|
|
445
|
-
patch: { result: ToolInvocation['result'] },
|
|
446
|
-
) {
|
|
447
|
-
setInFlight((cur) =>
|
|
448
|
-
cur.map((m) => {
|
|
449
|
-
if (m.id !== assistantId) return m
|
|
450
|
-
const parts = m.parts.map((p) => {
|
|
451
|
-
if (
|
|
452
|
-
p.type === 'tool-invocation' &&
|
|
453
|
-
(p as ToolInvocationPart).toolCallId === toolCallId
|
|
454
|
-
) {
|
|
455
|
-
const inv = (p as ToolInvocationPart).toolInvocation
|
|
456
|
-
const updated: ToolInvocation = { ...inv, state: 'result', result: patch.result }
|
|
457
|
-
return { ...p, toolInvocation: updated }
|
|
458
|
-
}
|
|
459
|
-
return p
|
|
460
|
-
})
|
|
461
|
-
return { ...m, parts }
|
|
462
|
-
}),
|
|
463
|
-
)
|
|
464
|
-
}
|
|
465
|
-
|
|
466
|
-
// ============================================================================
|
|
467
|
-
// ChatPanel
|
|
468
|
-
// ============================================================================
|
|
469
|
-
|
|
470
87
|
export function ChatPanel({
|
|
471
88
|
chatId,
|
|
472
89
|
userId,
|
|
473
90
|
onChatCreated,
|
|
474
|
-
models:
|
|
475
|
-
emptyStatePrompts,
|
|
91
|
+
models: modelOptions,
|
|
92
|
+
emptyStatePrompts = DEFAULT_PROMPTS,
|
|
476
93
|
className,
|
|
477
94
|
header,
|
|
478
95
|
compact = false,
|
|
479
96
|
disabled = false,
|
|
480
97
|
}: ChatPanelProps) {
|
|
481
|
-
const models =
|
|
98
|
+
const models = modelOptions ?? DEFAULT_MODELS
|
|
482
99
|
const scrollRef = useRef<HTMLDivElement>(null)
|
|
483
100
|
const inputRef = useRef<HTMLTextAreaElement>(null)
|
|
101
|
+
const stickToBottomRef = useRef(true)
|
|
102
|
+
const [input, setInput] = useState('')
|
|
103
|
+
const [modelId, setModelId] = useState<string | undefined>(() =>
|
|
104
|
+
initialModelId(models),
|
|
105
|
+
)
|
|
484
106
|
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
if (
|
|
489
|
-
|
|
490
|
-
})
|
|
491
|
-
function updateModelId(id: string) {
|
|
492
|
-
setModelId(id)
|
|
493
|
-
try { if (typeof window !== 'undefined') window.localStorage.setItem(MODEL_STORAGE_KEY, id) } catch { /* ignore */ }
|
|
494
|
-
}
|
|
107
|
+
// A custom model list may change at runtime. Never keep sending an id the
|
|
108
|
+
// picker no longer offers (the worker correctly rejects unknown ids).
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
if (modelId && models.some((model) => model.id === modelId)) return
|
|
111
|
+
setModelId(models[0]?.id)
|
|
112
|
+
}, [modelId, models])
|
|
495
113
|
|
|
496
114
|
const groupedModels = useMemo(() => groupModelsByProvider(models), [models])
|
|
497
|
-
const selectedModel = models.find((
|
|
498
|
-
|
|
499
|
-
const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat(
|
|
115
|
+
const selectedModel = models.find((model) => model.id === modelId)
|
|
116
|
+
const { send, stop, retry, isLoading, error, inFlight } = useStreamingChat({
|
|
500
117
|
chatId,
|
|
501
118
|
modelId,
|
|
502
119
|
onChatCreated,
|
|
503
|
-
)
|
|
120
|
+
})
|
|
504
121
|
|
|
505
|
-
// Sentinel where-clause yields an empty result when no chat is selected.
|
|
506
122
|
const queryWhere = useMemo(
|
|
507
123
|
() => ({ chatId: chatId ?? '__none__', userId }),
|
|
508
124
|
[chatId, userId],
|
|
509
125
|
)
|
|
510
|
-
const { records
|
|
126
|
+
const { records } = useQuery<AiMessageData>('ai-messages', {
|
|
511
127
|
where: queryWhere,
|
|
512
128
|
orderBy: 'createdAt',
|
|
513
129
|
orderDir: 'asc',
|
|
514
130
|
})
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
persistedRecords.map((r) => ({
|
|
524
|
-
id: r.recordId,
|
|
525
|
-
role: r.data.role,
|
|
526
|
-
content: r.data.content ?? '',
|
|
527
|
-
parts: r.data.parts,
|
|
528
|
-
})),
|
|
529
|
-
[persistedRecords],
|
|
131
|
+
const persisted = useMemo<RenderMessage[]>(
|
|
132
|
+
() => records.map((record) => ({
|
|
133
|
+
id: record.recordId,
|
|
134
|
+
role: record.data.role,
|
|
135
|
+
content: record.data.content ?? '',
|
|
136
|
+
parts: record.data.parts,
|
|
137
|
+
})),
|
|
138
|
+
[records],
|
|
530
139
|
)
|
|
531
|
-
const persistedIds = useMemo(
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
if (
|
|
539
|
-
|
|
540
|
-
// the worker-assigned recordId (from the X-Asst-Id response header) and
|
|
541
|
-
// matches the WS-broadcast persisted row. Dedup is purely id-based
|
|
542
|
-
// — there is no clock comparison, so client/server clock skew can't
|
|
543
|
-
// leave a duplicate stuck on screen.
|
|
544
|
-
if (persistedIds.has(m.id)) return false
|
|
545
|
-
if (m.serverId && persistedIds.has(m.serverId)) return false
|
|
546
|
-
return true
|
|
140
|
+
const persistedIds = useMemo(
|
|
141
|
+
() => new Set(persisted.map((message) => message.id)),
|
|
142
|
+
[persisted],
|
|
143
|
+
)
|
|
144
|
+
const messages = useMemo<RenderMessage[]>(() => {
|
|
145
|
+
const overlay = inFlight.filter((message) => {
|
|
146
|
+
if (message.forChatId !== chatId) return false
|
|
147
|
+
if (persistedIds.has(message.id)) return false
|
|
148
|
+
return !message.serverId || !persistedIds.has(message.serverId)
|
|
547
149
|
})
|
|
548
|
-
return [...persisted, ...
|
|
549
|
-
}, [
|
|
150
|
+
return [...persisted, ...overlay]
|
|
151
|
+
}, [chatId, inFlight, persisted, persistedIds])
|
|
550
152
|
|
|
551
|
-
const [input, setInput] = useState('')
|
|
552
|
-
|
|
553
|
-
// Auto-scroll only when the user is already pinned to the bottom.
|
|
554
|
-
const stickToBottomRef = useRef(true)
|
|
555
153
|
useEffect(() => {
|
|
556
|
-
const
|
|
557
|
-
if (!
|
|
558
|
-
const
|
|
559
|
-
const
|
|
560
|
-
stickToBottomRef.current =
|
|
154
|
+
const element = scrollRef.current
|
|
155
|
+
if (!element) return
|
|
156
|
+
const trackPosition = () => {
|
|
157
|
+
const bottomGap = element.scrollHeight - element.scrollTop - element.clientHeight
|
|
158
|
+
stickToBottomRef.current = bottomGap < 80
|
|
561
159
|
}
|
|
562
|
-
|
|
563
|
-
return () =>
|
|
160
|
+
element.addEventListener('scroll', trackPosition, { passive: true })
|
|
161
|
+
return () => element.removeEventListener('scroll', trackPosition)
|
|
564
162
|
}, [])
|
|
565
163
|
|
|
566
164
|
useEffect(() => {
|
|
567
|
-
const
|
|
568
|
-
if (!
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
}, [messages, isLoading])
|
|
165
|
+
const element = scrollRef.current
|
|
166
|
+
if (!element || !stickToBottomRef.current) return
|
|
167
|
+
element.scrollTo({
|
|
168
|
+
top: element.scrollHeight,
|
|
169
|
+
behavior: isLoading ? 'auto' : 'smooth',
|
|
170
|
+
})
|
|
171
|
+
}, [isLoading, messages])
|
|
575
172
|
|
|
576
|
-
// Auto-grow textarea, capped at 200px.
|
|
577
173
|
useEffect(() => {
|
|
578
|
-
const
|
|
579
|
-
if (!
|
|
174
|
+
const element = inputRef.current
|
|
175
|
+
if (!element) return
|
|
580
176
|
if (!input) {
|
|
581
|
-
|
|
177
|
+
element.style.height = ''
|
|
582
178
|
return
|
|
583
179
|
}
|
|
584
|
-
const
|
|
585
|
-
|
|
586
|
-
|
|
180
|
+
const frame = requestAnimationFrame(() => {
|
|
181
|
+
element.style.height = 'auto'
|
|
182
|
+
element.style.height = `${Math.min(element.scrollHeight, 200)}px`
|
|
587
183
|
})
|
|
588
|
-
return () => cancelAnimationFrame(
|
|
184
|
+
return () => cancelAnimationFrame(frame)
|
|
589
185
|
}, [input])
|
|
590
186
|
|
|
591
187
|
const canSend = input.trim().length > 0 && !isLoading && !disabled
|
|
592
188
|
|
|
593
189
|
function submit() {
|
|
594
190
|
if (!canSend) return
|
|
595
|
-
const
|
|
191
|
+
const content = input.trim()
|
|
596
192
|
setInput('')
|
|
597
|
-
void send(
|
|
193
|
+
void send(content)
|
|
598
194
|
}
|
|
599
195
|
|
|
600
|
-
function
|
|
601
|
-
//
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
if (e.key === 'Enter' && !e.shiftKey) {
|
|
606
|
-
e.preventDefault()
|
|
196
|
+
function handleKeyDown(event: KeyboardEvent<HTMLTextAreaElement>) {
|
|
197
|
+
// Enter commits an active IME composition; it must not also send it.
|
|
198
|
+
if (event.nativeEvent.isComposing || event.keyCode === 229) return
|
|
199
|
+
if (event.key === 'Enter' && !event.shiftKey) {
|
|
200
|
+
event.preventDefault()
|
|
607
201
|
submit()
|
|
608
202
|
}
|
|
609
203
|
}
|
|
610
204
|
|
|
611
|
-
function
|
|
205
|
+
function selectPrompt(prompt: string) {
|
|
612
206
|
setInput(prompt)
|
|
613
207
|
inputRef.current?.focus()
|
|
614
208
|
}
|
|
615
209
|
|
|
616
|
-
|
|
210
|
+
function selectModel(id: string) {
|
|
211
|
+
setModelId(id)
|
|
212
|
+
try {
|
|
213
|
+
window.localStorage.setItem(MODEL_STORAGE_KEY, id)
|
|
214
|
+
} catch {
|
|
215
|
+
// Storage can be unavailable in privacy modes; selection still works.
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
617
219
|
const lastMessage = messages[messages.length - 1]
|
|
618
220
|
const streamingAssistantId =
|
|
619
221
|
isLoading && lastMessage?.role === 'assistant' ? lastMessage.id : null
|
|
620
222
|
const waitingForAssistant = isLoading && lastMessage?.role === 'user'
|
|
621
|
-
|
|
622
|
-
const outerPx = compact ? 'px-4' : 'px-6'
|
|
223
|
+
const horizontalPadding = compact ? 'px-4' : 'px-6'
|
|
623
224
|
const textSize = compact ? 'text-[13px]' : 'text-[14px]'
|
|
624
225
|
const turnGap = compact ? 'gap-6' : 'gap-8'
|
|
625
226
|
|
|
@@ -629,27 +230,25 @@ export function ChatPanel({
|
|
|
629
230
|
>
|
|
630
231
|
{header && <div className="shrink-0">{header}</div>}
|
|
631
232
|
|
|
632
|
-
{/* min-h-0 is required on a flex-1 child so overflow-y-auto actually
|
|
633
|
-
activates instead of growing the parent. */}
|
|
634
233
|
<div
|
|
635
234
|
ref={scrollRef}
|
|
636
235
|
role="log"
|
|
637
236
|
aria-live="polite"
|
|
638
237
|
aria-atomic="false"
|
|
639
238
|
aria-label="Assistant conversation"
|
|
640
|
-
className={`min-h-0 flex-1 overflow-y-auto ${
|
|
239
|
+
className={`min-h-0 flex-1 overflow-y-auto ${horizontalPadding} py-8`}
|
|
641
240
|
>
|
|
642
241
|
{messages.length === 0 ? (
|
|
643
|
-
<EmptyState prompts={
|
|
242
|
+
<EmptyState prompts={emptyStatePrompts} onPick={selectPrompt} />
|
|
644
243
|
) : (
|
|
645
244
|
<div className={`mx-auto flex max-w-[44rem] flex-col ${turnGap}`}>
|
|
646
|
-
{messages.map((
|
|
245
|
+
{messages.map((message) => (
|
|
647
246
|
<MessageTurn
|
|
648
|
-
key={
|
|
649
|
-
role={
|
|
650
|
-
content={
|
|
651
|
-
parts={
|
|
652
|
-
isStreaming={
|
|
247
|
+
key={message.id}
|
|
248
|
+
role={message.role}
|
|
249
|
+
content={message.content}
|
|
250
|
+
parts={message.parts}
|
|
251
|
+
isStreaming={message.id === streamingAssistantId}
|
|
653
252
|
/>
|
|
654
253
|
))}
|
|
655
254
|
{waitingForAssistant && <ThinkingIndicator />}
|
|
@@ -658,7 +257,7 @@ export function ChatPanel({
|
|
|
658
257
|
</div>
|
|
659
258
|
|
|
660
259
|
{error && (
|
|
661
|
-
<div className={`mx-auto mb-2 w-full max-w-[44rem] ${
|
|
260
|
+
<div className={`mx-auto mb-2 w-full max-w-[44rem] ${horizontalPadding}`} role="alert">
|
|
662
261
|
<div className="flex items-start gap-2 rounded-md border border-destructive/30 bg-destructive/5 px-3 py-2 text-destructive">
|
|
663
262
|
<AlertCircle className="mt-0.5 h-4 w-4 shrink-0" aria-hidden="true" />
|
|
664
263
|
<div className="flex-1 text-[13px] leading-relaxed">{error.message}</div>
|
|
@@ -673,10 +272,10 @@ export function ChatPanel({
|
|
|
673
272
|
</div>
|
|
674
273
|
)}
|
|
675
274
|
|
|
676
|
-
<div className={`shrink-0 border-t border-border/60 ${
|
|
275
|
+
<div className={`shrink-0 border-t border-border/60 ${horizontalPadding} pb-4 pt-3`}>
|
|
677
276
|
<form
|
|
678
|
-
onSubmit={(
|
|
679
|
-
|
|
277
|
+
onSubmit={(event) => {
|
|
278
|
+
event.preventDefault()
|
|
680
279
|
submit()
|
|
681
280
|
}}
|
|
682
281
|
className="mx-auto w-full max-w-[44rem]"
|
|
@@ -686,24 +285,22 @@ export function ChatPanel({
|
|
|
686
285
|
ref={inputRef}
|
|
687
286
|
rows={1}
|
|
688
287
|
value={input}
|
|
689
|
-
onChange={(
|
|
690
|
-
onKeyDown={
|
|
288
|
+
onChange={(event) => setInput(event.target.value)}
|
|
289
|
+
onKeyDown={handleKeyDown}
|
|
691
290
|
disabled={disabled}
|
|
692
291
|
placeholder={disabled ? 'Creating chat…' : 'Message the assistant…'}
|
|
693
292
|
className="block w-full resize-none bg-transparent px-4 pt-3 pb-12 leading-[1.5] text-foreground placeholder:text-muted-foreground/60 outline-none disabled:cursor-not-allowed"
|
|
694
293
|
/>
|
|
695
294
|
|
|
696
295
|
<div className="pointer-events-none absolute inset-x-2 bottom-2 flex items-center justify-between">
|
|
697
|
-
{groupedModels && modelId && selectedModel ? (
|
|
296
|
+
{groupedModels.length > 0 && modelId && selectedModel ? (
|
|
698
297
|
<ModelPicker
|
|
699
298
|
grouped={groupedModels}
|
|
700
299
|
modelId={modelId}
|
|
701
300
|
label={selectedModel.label}
|
|
702
|
-
onChange={
|
|
301
|
+
onChange={selectModel}
|
|
703
302
|
/>
|
|
704
|
-
) :
|
|
705
|
-
<span />
|
|
706
|
-
)}
|
|
303
|
+
) : <span />}
|
|
707
304
|
|
|
708
305
|
{isLoading ? (
|
|
709
306
|
<button
|
|
@@ -732,316 +329,30 @@ export function ChatPanel({
|
|
|
732
329
|
)
|
|
733
330
|
}
|
|
734
331
|
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
type MessagePart =
|
|
745
|
-
| { type: 'text'; text: string }
|
|
746
|
-
| ToolInvocationPart
|
|
747
|
-
|
|
748
|
-
interface MessageTurnProps {
|
|
749
|
-
role: 'user' | 'assistant' | 'system'
|
|
750
|
-
content: string
|
|
751
|
-
parts?: unknown[]
|
|
752
|
-
isStreaming: boolean
|
|
753
|
-
}
|
|
754
|
-
|
|
755
|
-
// memo: every streamed chunk and composer keystroke re-renders ChatPanel and
|
|
756
|
-
// with it the whole turn list. Finished turns receive referentially stable
|
|
757
|
-
// props (see the `persisted` memo), so memo lets them skip the re-render —
|
|
758
|
-
// and with it a full ReactMarkdown re-parse per turn per chunk. Only the
|
|
759
|
-
// actively streaming row's props change, so only it re-renders.
|
|
760
|
-
const MessageTurn = memo(function MessageTurn({ role, content, parts, isStreaming }: MessageTurnProps) {
|
|
761
|
-
const orderedParts: MessagePart[] = useMemo(() => {
|
|
762
|
-
if (Array.isArray(parts) && parts.length > 0) {
|
|
763
|
-
const cast = parts as MessagePart[]
|
|
764
|
-
const hasText = cast.some((p) => (p as { type?: string }).type === 'text')
|
|
765
|
-
if (hasText) return cast
|
|
766
|
-
if (content) return [...cast, { type: 'text', text: content }]
|
|
767
|
-
return cast
|
|
768
|
-
}
|
|
769
|
-
if (content) return [{ type: 'text', text: content }]
|
|
770
|
-
return []
|
|
771
|
-
}, [parts, content])
|
|
772
|
-
|
|
773
|
-
if (role === 'user') {
|
|
774
|
-
return (
|
|
775
|
-
<div className="flex justify-end">
|
|
776
|
-
<div className="max-w-[85%] rounded-2xl bg-muted px-4 py-2.5 leading-relaxed text-foreground whitespace-pre-wrap break-words">
|
|
777
|
-
{content}
|
|
778
|
-
</div>
|
|
779
|
-
</div>
|
|
780
|
-
)
|
|
332
|
+
function initialModelId(models: ModelOption[]): string | undefined {
|
|
333
|
+
let saved: string | null = null
|
|
334
|
+
try {
|
|
335
|
+
saved = typeof window === 'undefined'
|
|
336
|
+
? null
|
|
337
|
+
: window.localStorage.getItem(MODEL_STORAGE_KEY)
|
|
338
|
+
} catch {
|
|
339
|
+
// Storage is optional.
|
|
781
340
|
}
|
|
782
|
-
|
|
783
|
-
|
|
784
|
-
|
|
785
|
-
const body = content.replace(/^Earlier conversation summary:\n?/, '')
|
|
786
|
-
return (
|
|
787
|
-
<div className="rounded-md border border-dashed border-border bg-muted/30 px-3 py-2 text-[12px] text-muted-foreground">
|
|
788
|
-
<div className="mb-1 font-medium tracking-tight text-foreground/80">
|
|
789
|
-
Earlier conversation summary
|
|
790
|
-
</div>
|
|
791
|
-
<div className="whitespace-pre-wrap leading-relaxed">{body}</div>
|
|
792
|
-
</div>
|
|
793
|
-
)
|
|
794
|
-
}
|
|
795
|
-
|
|
796
|
-
return (
|
|
797
|
-
<div className="flex flex-col gap-3">
|
|
798
|
-
{orderedParts.map((p, i) => {
|
|
799
|
-
if (p.type === 'text') {
|
|
800
|
-
const text = (p as { text: string }).text
|
|
801
|
-
if (!text) return null
|
|
802
|
-
// Markdown styling without `@tailwindcss/typography`: arbitrary
|
|
803
|
-
// descendant selectors (`[&_p]:my-2`) target the markdown's
|
|
804
|
-
// emitted HTML directly. Avoids a plugin dep on every scaffolded
|
|
805
|
-
// app — `prose` classes emit zero CSS unless typography is
|
|
806
|
-
// installed, which is what made paragraphs / line breaks
|
|
807
|
-
// collapse into one wall of text.
|
|
808
|
-
//
|
|
809
|
-
// Code blocks are always-dark (`bg-zinc-900`) so they stay
|
|
810
|
-
// readable under both light and dark host themes — matches the
|
|
811
|
-
// atom-one-dark hljs palette imported above. Inline `<code>`
|
|
812
|
-
// remains theme-aware via `bg-muted`.
|
|
813
|
-
return (
|
|
814
|
-
<div
|
|
815
|
-
key={i}
|
|
816
|
-
className="text-foreground leading-relaxed
|
|
817
|
-
[&_p]:my-2 [&_p]:[overflow-wrap:anywhere] [&>*:first-child]:mt-0 [&>*:last-child]:mb-0
|
|
818
|
-
[&_h1]:text-lg [&_h1]:font-semibold [&_h1]:my-3
|
|
819
|
-
[&_h2]:text-base [&_h2]:font-semibold [&_h2]:my-3
|
|
820
|
-
[&_h3]:font-semibold [&_h3]:my-2
|
|
821
|
-
[&_strong]:font-semibold [&_em]:italic
|
|
822
|
-
[&_a]:text-primary [&_a]:underline [&_a]:underline-offset-2
|
|
823
|
-
[&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-6
|
|
824
|
-
[&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-6
|
|
825
|
-
[&_li]:my-0.5
|
|
826
|
-
[&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-border [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground
|
|
827
|
-
[&_hr]:my-3 [&_hr]:border-border
|
|
828
|
-
[&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_code]:rounded [&_code]:text-[0.9em]
|
|
829
|
-
[&_pre]:bg-zinc-900 [&_pre]:text-zinc-100 [&_pre]:rounded-md [&_pre]:p-3 [&_pre]:my-2 [&_pre]:overflow-x-auto
|
|
830
|
-
[&_pre_code]:bg-transparent [&_pre_code]:p-0 [&_pre_code]:rounded-none [&_pre_code]:text-inherit
|
|
831
|
-
[&_table]:my-2 [&_table]:w-full [&_table]:border-collapse
|
|
832
|
-
[&_th]:border [&_th]:border-border [&_th]:px-2 [&_th]:py-1 [&_th]:font-semibold [&_th]:text-left
|
|
833
|
-
[&_td]:border [&_td]:border-border [&_td]:px-2 [&_td]:py-1"
|
|
834
|
-
>
|
|
835
|
-
<ReactMarkdown
|
|
836
|
-
remarkPlugins={REMARK_PLUGINS}
|
|
837
|
-
rehypePlugins={REHYPE_PLUGINS}
|
|
838
|
-
components={MARKDOWN_COMPONENTS}
|
|
839
|
-
>
|
|
840
|
-
{text}
|
|
841
|
-
</ReactMarkdown>
|
|
842
|
-
</div>
|
|
843
|
-
)
|
|
844
|
-
}
|
|
845
|
-
if (p.type === 'tool-invocation') {
|
|
846
|
-
const inv = (p as ToolInvocationPart).toolInvocation
|
|
847
|
-
return <ToolRow key={i} inv={inv} />
|
|
848
|
-
}
|
|
849
|
-
return null
|
|
850
|
-
})}
|
|
851
|
-
{isStreaming && <LiveIndicator />}
|
|
852
|
-
</div>
|
|
853
|
-
)
|
|
854
|
-
})
|
|
855
|
-
|
|
856
|
-
// ----- Empty state --------------------------------------------------------
|
|
857
|
-
|
|
858
|
-
function EmptyState({ prompts, onPick }: { prompts: string[]; onPick: (p: string) => void }) {
|
|
859
|
-
return (
|
|
860
|
-
<div className="mx-auto flex h-full max-w-[28rem] flex-col items-start justify-center gap-4 px-2">
|
|
861
|
-
<div className="space-y-1">
|
|
862
|
-
<h2 className="text-[17px] font-medium tracking-tight text-foreground">
|
|
863
|
-
How can I help?
|
|
864
|
-
</h2>
|
|
865
|
-
<p className="text-[13px] leading-relaxed text-muted-foreground">
|
|
866
|
-
I can query records, list schemas, and inspect collections on your behalf.
|
|
867
|
-
</p>
|
|
868
|
-
</div>
|
|
869
|
-
<div className="flex flex-col gap-0.5 pt-2">
|
|
870
|
-
<div className="pb-1 text-[10.5px] font-medium tracking-[0.08em] uppercase text-muted-foreground">
|
|
871
|
-
Try
|
|
872
|
-
</div>
|
|
873
|
-
{prompts.map((p) => (
|
|
874
|
-
<button
|
|
875
|
-
key={p}
|
|
876
|
-
type="button"
|
|
877
|
-
onClick={() => onPick(p)}
|
|
878
|
-
className="group flex items-center gap-2 py-1 text-left text-[13px] text-muted-foreground transition-colors hover:text-foreground"
|
|
879
|
-
>
|
|
880
|
-
<span className="h-px w-3 shrink-0 bg-border transition-colors group-hover:bg-muted-foreground" />
|
|
881
|
-
<span className="truncate">{p}</span>
|
|
882
|
-
</button>
|
|
883
|
-
))}
|
|
884
|
-
</div>
|
|
885
|
-
</div>
|
|
886
|
-
)
|
|
887
|
-
}
|
|
888
|
-
|
|
889
|
-
// ----- Pending + Live indicators -----------------------------------------
|
|
890
|
-
|
|
891
|
-
function ThinkingIndicator() {
|
|
892
|
-
return (
|
|
893
|
-
<div className="flex items-center gap-2 text-[13px] text-muted-foreground">
|
|
894
|
-
<EllipsisDots />
|
|
895
|
-
<span>Thinking</span>
|
|
896
|
-
</div>
|
|
897
|
-
)
|
|
898
|
-
}
|
|
899
|
-
|
|
900
|
-
function LiveIndicator() {
|
|
901
|
-
return (
|
|
902
|
-
<div className="flex items-center gap-2 text-[12px] text-muted-foreground" aria-live="polite">
|
|
903
|
-
<span className="relative flex h-1.5 w-1.5">
|
|
904
|
-
<span className="absolute inset-0 rounded-full bg-primary/50 animate-ping" />
|
|
905
|
-
<span className="relative h-1.5 w-1.5 rounded-full bg-primary" />
|
|
906
|
-
</span>
|
|
907
|
-
<span className="tracking-wide">Working</span>
|
|
908
|
-
</div>
|
|
909
|
-
)
|
|
341
|
+
return saved && models.some((model) => model.id === saved)
|
|
342
|
+
? saved
|
|
343
|
+
: models[0]?.id
|
|
910
344
|
}
|
|
911
345
|
|
|
912
|
-
function
|
|
913
|
-
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
</span>
|
|
919
|
-
)
|
|
920
|
-
}
|
|
921
|
-
|
|
922
|
-
// ----- Tool row ----------------------------------------------------------
|
|
923
|
-
|
|
924
|
-
type ToolInvocation = {
|
|
925
|
-
toolName: string
|
|
926
|
-
state: 'call' | 'result' | 'partial-call'
|
|
927
|
-
args?: unknown
|
|
928
|
-
result?: { success?: boolean } & Record<string, unknown>
|
|
929
|
-
}
|
|
930
|
-
|
|
931
|
-
// ----- Code block + copy button ------------------------------------------
|
|
932
|
-
|
|
933
|
-
// `pre` slot for ReactMarkdown — wraps the highlighted <pre><code> with a
|
|
934
|
-
// copy-to-clipboard button. Positioned absolutely; revealed on hover via
|
|
935
|
-
// `group-hover` so it doesn't add visual noise on every code block.
|
|
936
|
-
const CodeBlock: Components['pre'] = ({ children, node: _node, ref: _ref, ...props }) => {
|
|
937
|
-
const ref = useRef<HTMLPreElement>(null)
|
|
938
|
-
const [copied, setCopied] = useState(false)
|
|
939
|
-
const onCopy = () => {
|
|
940
|
-
const text = ref.current?.textContent ?? ''
|
|
941
|
-
if (!text) return
|
|
942
|
-
void navigator.clipboard.writeText(text).then(() => {
|
|
943
|
-
setCopied(true)
|
|
944
|
-
setTimeout(() => setCopied(false), 1500)
|
|
945
|
-
})
|
|
346
|
+
function groupModelsByProvider(models: ModelOption[]): Array<[string, ModelOption[]]> {
|
|
347
|
+
const groups = new Map<string, ModelOption[]>()
|
|
348
|
+
for (const model of models) {
|
|
349
|
+
const group = groups.get(model.provider) ?? []
|
|
350
|
+
group.push(model)
|
|
351
|
+
groups.set(model.provider, group)
|
|
946
352
|
}
|
|
947
|
-
return (
|
|
948
|
-
<div className="group relative">
|
|
949
|
-
<button
|
|
950
|
-
type="button"
|
|
951
|
-
onClick={onCopy}
|
|
952
|
-
aria-label={copied ? 'Copied' : 'Copy code'}
|
|
953
|
-
className="absolute right-2 top-2 inline-flex h-6 items-center gap-1 rounded-md border border-zinc-700 bg-zinc-800 px-2 text-[11px] font-medium text-zinc-200 opacity-0 transition-opacity hover:bg-zinc-700 focus-visible:opacity-100 group-hover:opacity-100"
|
|
954
|
-
>
|
|
955
|
-
{copied
|
|
956
|
-
? <Check className="h-3 w-3" aria-hidden="true" />
|
|
957
|
-
: <Copy className="h-3 w-3" aria-hidden="true" />}
|
|
958
|
-
<span>{copied ? 'Copied' : 'Copy'}</span>
|
|
959
|
-
</button>
|
|
960
|
-
<pre ref={ref} {...props}>{children}</pre>
|
|
961
|
-
</div>
|
|
962
|
-
)
|
|
353
|
+
return Array.from(groups.entries())
|
|
963
354
|
}
|
|
964
355
|
|
|
965
|
-
// Markdown links default to navigating in the same tab — which kills the
|
|
966
|
-
// chat session. Force new-tab + noopener for safety.
|
|
967
|
-
const ExternalLink: Components['a'] = ({ children, href, node: _node, ...props }) => {
|
|
968
|
-
return (
|
|
969
|
-
<a href={href} target="_blank" rel="noopener noreferrer" {...props}>
|
|
970
|
-
{children}
|
|
971
|
-
</a>
|
|
972
|
-
)
|
|
973
|
-
}
|
|
974
|
-
|
|
975
|
-
// Module-level so ReactMarkdown's props stay referentially stable across
|
|
976
|
-
// renders — inline literals would defeat memo(MessageTurn) and re-run the
|
|
977
|
-
// remark/rehype parse for every finished turn on every streamed chunk.
|
|
978
|
-
// (Defined after CodeBlock/ExternalLink: they are const components, so an
|
|
979
|
-
// earlier literal would hit their temporal dead zone at module init.)
|
|
980
|
-
const REMARK_PLUGINS = [remarkGfm, remarkBreaks]
|
|
981
|
-
const REHYPE_PLUGINS = [rehypeHighlight]
|
|
982
|
-
const MARKDOWN_COMPONENTS = { pre: CodeBlock, a: ExternalLink }
|
|
983
|
-
|
|
984
|
-
function ToolRow({ inv }: { inv: ToolInvocation }) {
|
|
985
|
-
const done = inv.state === 'result'
|
|
986
|
-
const failed = done && inv.result?.success === false
|
|
987
|
-
const running = !done
|
|
988
|
-
const { label, path } = describeTool(inv.toolName, inv.args as Record<string, unknown> | undefined)
|
|
989
|
-
|
|
990
|
-
return (
|
|
991
|
-
<div className={`flex items-center gap-2 text-[13px] leading-tight ${running ? 'animate-[pulse_2s_ease-in-out_infinite]' : ''}`}>
|
|
992
|
-
<span className="inline-flex h-4 w-4 shrink-0 items-center justify-center">
|
|
993
|
-
{running ? <Spinner /> : failed ? <FailDot /> : <Check className="h-3 w-3 shrink-0 text-foreground/60" aria-hidden="true" />}
|
|
994
|
-
</span>
|
|
995
|
-
<span className={running ? 'text-foreground' : 'text-muted-foreground'}>{label}</span>
|
|
996
|
-
{path && (
|
|
997
|
-
<code className="truncate font-mono text-[12.5px] text-foreground/70">{path}</code>
|
|
998
|
-
)}
|
|
999
|
-
{running && <EllipsisDots />}
|
|
1000
|
-
</div>
|
|
1001
|
-
)
|
|
1002
|
-
}
|
|
1003
|
-
|
|
1004
|
-
function Spinner() {
|
|
1005
|
-
return (
|
|
1006
|
-
<svg width="12" height="12" viewBox="0 0 12 12" className="animate-spin text-foreground/70">
|
|
1007
|
-
<circle cx="6" cy="6" r="4.5" fill="none" stroke="currentColor" strokeOpacity="0.2" strokeWidth="1.5" />
|
|
1008
|
-
<path d="M10.5 6 A 4.5 4.5 0 0 1 6 10.5" fill="none" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
|
|
1009
|
-
</svg>
|
|
1010
|
-
)
|
|
1011
|
-
}
|
|
1012
|
-
|
|
1013
|
-
function FailDot() {
|
|
1014
|
-
return <span className="h-1.5 w-1.5 rounded-full bg-destructive" aria-label="failed" />
|
|
1015
|
-
}
|
|
1016
|
-
|
|
1017
|
-
function describeTool(
|
|
1018
|
-
name: string,
|
|
1019
|
-
args?: Record<string, unknown>,
|
|
1020
|
-
): { label: string; path?: string } {
|
|
1021
|
-
const collection = typeof args?.collection === 'string' ? args.collection : undefined
|
|
1022
|
-
const recordId = typeof args?.recordId === 'string' ? args.recordId : undefined
|
|
1023
|
-
|
|
1024
|
-
switch (name) {
|
|
1025
|
-
case 'schema_list':
|
|
1026
|
-
return { label: 'Listing collections' }
|
|
1027
|
-
case 'schema_describe':
|
|
1028
|
-
return { label: 'Describing', path: collection }
|
|
1029
|
-
case 'records_query':
|
|
1030
|
-
return { label: 'Reading', path: collection }
|
|
1031
|
-
case 'records_get':
|
|
1032
|
-
return {
|
|
1033
|
-
label: 'Fetching',
|
|
1034
|
-
path: collection && recordId ? `${collection}/${recordId}` : collection,
|
|
1035
|
-
}
|
|
1036
|
-
case 'user_current':
|
|
1037
|
-
return { label: 'Checking current user' }
|
|
1038
|
-
default:
|
|
1039
|
-
return { label: 'Running', path: name }
|
|
1040
|
-
}
|
|
1041
|
-
}
|
|
1042
|
-
|
|
1043
|
-
// ----- Model picker ------------------------------------------------------
|
|
1044
|
-
|
|
1045
356
|
function ModelPicker({
|
|
1046
357
|
grouped,
|
|
1047
358
|
modelId,
|
|
@@ -1058,17 +369,17 @@ function ModelPicker({
|
|
|
1058
369
|
|
|
1059
370
|
useEffect(() => {
|
|
1060
371
|
if (!open) return
|
|
1061
|
-
|
|
1062
|
-
if (!containerRef.current?.contains(
|
|
372
|
+
const closeOutside = (event: MouseEvent) => {
|
|
373
|
+
if (!containerRef.current?.contains(event.target as Node)) setOpen(false)
|
|
1063
374
|
}
|
|
1064
|
-
|
|
1065
|
-
if (
|
|
375
|
+
const closeOnEscape = (event: globalThis.KeyboardEvent) => {
|
|
376
|
+
if (event.key === 'Escape') setOpen(false)
|
|
1066
377
|
}
|
|
1067
|
-
document.addEventListener('mousedown',
|
|
1068
|
-
document.addEventListener('keydown',
|
|
378
|
+
document.addEventListener('mousedown', closeOutside)
|
|
379
|
+
document.addEventListener('keydown', closeOnEscape)
|
|
1069
380
|
return () => {
|
|
1070
|
-
document.removeEventListener('mousedown',
|
|
1071
|
-
document.removeEventListener('keydown',
|
|
381
|
+
document.removeEventListener('mousedown', closeOutside)
|
|
382
|
+
document.removeEventListener('keydown', closeOnEscape)
|
|
1072
383
|
}
|
|
1073
384
|
}, [open])
|
|
1074
385
|
|
|
@@ -1076,7 +387,7 @@ function ModelPicker({
|
|
|
1076
387
|
<div ref={containerRef} className="pointer-events-auto relative inline-flex">
|
|
1077
388
|
<button
|
|
1078
389
|
type="button"
|
|
1079
|
-
onClick={() => setOpen((
|
|
390
|
+
onClick={() => setOpen((current) => !current)}
|
|
1080
391
|
className="inline-flex items-center gap-1 rounded-full px-2 py-1 text-[12px] text-muted-foreground transition-colors hover:text-foreground"
|
|
1081
392
|
aria-haspopup="menu"
|
|
1082
393
|
aria-expanded={open}
|
|
@@ -1090,28 +401,26 @@ function ModelPicker({
|
|
|
1090
401
|
role="menu"
|
|
1091
402
|
className="absolute bottom-full left-0 z-30 mb-2 w-64 max-h-[22rem] overflow-y-auto rounded-lg border border-border bg-popover text-popover-foreground shadow-lg"
|
|
1092
403
|
>
|
|
1093
|
-
{grouped.map(([provider, items],
|
|
404
|
+
{grouped.map(([provider, items], providerIndex) => (
|
|
1094
405
|
<div key={provider}>
|
|
1095
|
-
{
|
|
406
|
+
{providerIndex > 0 && <div className="border-t border-border/60" />}
|
|
1096
407
|
<div className="px-3 pt-2 pb-1 text-[10px] font-semibold uppercase tracking-wider text-muted-foreground">
|
|
1097
408
|
{provider}
|
|
1098
409
|
</div>
|
|
1099
|
-
{items.map((
|
|
1100
|
-
const active =
|
|
410
|
+
{items.map((model) => {
|
|
411
|
+
const active = model.id === modelId
|
|
1101
412
|
return (
|
|
1102
413
|
<button
|
|
1103
|
-
key={
|
|
414
|
+
key={model.id}
|
|
1104
415
|
type="button"
|
|
1105
416
|
role="menuitem"
|
|
1106
417
|
onClick={() => {
|
|
1107
|
-
onChange(
|
|
418
|
+
onChange(model.id)
|
|
1108
419
|
setOpen(false)
|
|
1109
420
|
}}
|
|
1110
|
-
className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-[12.5px] transition-colors ${
|
|
1111
|
-
active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'
|
|
1112
|
-
}`}
|
|
421
|
+
className={`flex w-full items-center justify-between gap-3 px-3 py-1.5 text-left text-[12.5px] transition-colors ${active ? 'bg-accent text-accent-foreground' : 'hover:bg-accent/50'}`}
|
|
1113
422
|
>
|
|
1114
|
-
<span className="truncate">{
|
|
423
|
+
<span className="truncate">{model.label}</span>
|
|
1115
424
|
{active && <Check className="h-3 w-3 shrink-0 text-foreground/60" aria-hidden="true" />}
|
|
1116
425
|
</button>
|
|
1117
426
|
)
|
|
@@ -1123,16 +432,3 @@ function ModelPicker({
|
|
|
1123
432
|
</div>
|
|
1124
433
|
)
|
|
1125
434
|
}
|
|
1126
|
-
|
|
1127
|
-
// ----- Utils -------------------------------------------------------------
|
|
1128
|
-
|
|
1129
|
-
function groupModelsByProvider(models?: ModelOption[]): Array<[string, ModelOption[]]> | null {
|
|
1130
|
-
if (!models || models.length === 0) return null
|
|
1131
|
-
const map = new Map<string, ModelOption[]>()
|
|
1132
|
-
for (const m of models) {
|
|
1133
|
-
const list = map.get(m.provider) ?? []
|
|
1134
|
-
list.push(m)
|
|
1135
|
-
map.set(m.provider, list)
|
|
1136
|
-
}
|
|
1137
|
-
return Array.from(map.entries())
|
|
1138
|
-
}
|