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