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