deepspace 0.7.0 → 0.8.1

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.
Files changed (63) hide show
  1. package/CHANGELOG.md +37 -0
  2. package/README.md +22 -8
  3. package/dist/cli.js +9812 -4674
  4. package/dist/cli.js.map +1 -1
  5. package/dist/feature-installer.js +638 -0
  6. package/dist/feature-installer.js.map +1 -0
  7. package/dist/index.d.ts +107 -117
  8. package/dist/index.js +610 -779
  9. package/dist/index.js.map +1 -1
  10. package/dist/server.d.ts +403 -167
  11. package/dist/server.js +498 -367
  12. package/dist/server.js.map +1 -1
  13. package/dist/testing.d.ts +115 -0
  14. package/dist/testing.js +3 -3
  15. package/dist/testing.js.map +1 -1
  16. package/dist/worker.d.ts +397 -160
  17. package/dist/worker.js +506 -367
  18. package/dist/worker.js.map +1 -1
  19. package/features/ai-chat/feature.json +2 -0
  20. package/features/ai-chat/src/AiChatPage.tsx +2 -3
  21. package/features/ai-chat/src/ChatPanel.messages.tsx +344 -0
  22. package/features/ai-chat/src/ChatPanel.stream.ts +397 -0
  23. package/features/ai-chat/src/ChatPanel.tsx +158 -862
  24. package/features/ai-chat/src/__tests__/ChatPanel.stream.test.ts +163 -0
  25. package/features/canvas/src/CanvasListPage.tsx +1 -1
  26. package/features/docs/feature.json +4 -8
  27. package/features/docs/src/DocsEditorPage.tsx +33 -326
  28. package/features/docs/src/DocsListPage.tsx +33 -29
  29. package/features/docs/src/InviteDialog.tsx +11 -40
  30. package/features/docs/src/__tests__/docs-presence-access.test.ts +71 -0
  31. package/features/docs/src/docs-library-types.ts +28 -3
  32. package/features/docs/src/docs-ui.css +1 -4
  33. package/features/docs/src/use-docs-presence-access.ts +330 -0
  34. package/features/file-attachments/feature.json +2 -2
  35. package/features/file-attachments/src/AttachmentPreview.tsx +428 -0
  36. package/features/file-attachments/src/AttachmentsPage.tsx +100 -462
  37. package/features/file-attachments/src/useFilePreview.ts +54 -68
  38. package/features/file-manager/src/FileManagerPage.tsx +1 -1
  39. package/features/integration-test/src/integration.spec.ts +1 -1
  40. package/features/items/feature.json +1 -5
  41. package/features/items/src/ItemsPage.tsx +207 -135
  42. package/features/landing/feature.json +3 -0
  43. package/features/landing/src/LandingPage.tsx +4 -18
  44. package/features/leaderboard/feature.json +1 -5
  45. package/features/leaderboard/src/LeaderboardPage.tsx +333 -198
  46. package/features/messaging/feature.json +0 -4
  47. package/features/messaging/src/ChatMultiPage.tsx +1 -3
  48. package/features/messaging/src/ChatPage.tsx +1 -3
  49. package/features/messaging/src/components/chat/MessageActionSheet.tsx +40 -16
  50. package/features/search-bar/feature.json +8 -3
  51. package/features/search-bar/src/SearchOverlay.tsx +108 -252
  52. package/features/search-bar/src/__tests__/search-model.test.ts +40 -0
  53. package/features/search-bar/src/search-model.ts +223 -0
  54. package/features/tasks/src/TasksPage.tsx +3 -3
  55. package/features/topbar/src/Topbar.tsx +0 -17
  56. package/package.json +2 -3
  57. package/features/docs/src/docs-favorites.ts +0 -18
  58. package/features/docs/src/use-yjs-room-with-awareness.ts +0 -289
  59. package/features/file-attachments/src/attachments-constants.ts +0 -9
  60. package/features/items/src/items-constants.ts +0 -10
  61. package/features/leaderboard/src/leaderboard-constants.ts +0 -21
  62. package/features/messaging/src/components/chat/ReactionPicker.tsx +0 -2
  63. package/scripts/add-feature.cjs +0 -807
@@ -0,0 +1,397 @@
1
+ import {
2
+ useCallback,
3
+ useEffect,
4
+ useRef,
5
+ useState,
6
+ type Dispatch,
7
+ type SetStateAction,
8
+ } from 'react'
9
+ import {
10
+ decodeAiStreamChunk,
11
+ getAuthToken,
12
+ parseSseLine,
13
+ type AiStreamAction,
14
+ } from 'deepspace'
15
+
16
+ export type ToolInvocation = {
17
+ toolName: string
18
+ state: 'call' | 'result'
19
+ args?: unknown
20
+ result?: unknown
21
+ }
22
+
23
+ export type ToolInvocationPart = {
24
+ type: 'tool-invocation'
25
+ toolInvocation: ToolInvocation
26
+ toolCallId: string
27
+ }
28
+
29
+ export type MessagePart =
30
+ | { type: 'text'; text: string }
31
+ | ToolInvocationPart
32
+
33
+ export type InFlightMessage = {
34
+ id: string
35
+ role: 'user' | 'assistant'
36
+ content: string
37
+ parts: MessagePart[]
38
+ /** The persisted assistant record id returned in `X-Asst-Id`. */
39
+ serverId?: string
40
+ /** Prevents an old chat's overlay from leaking into the active chat. */
41
+ forChatId: string
42
+ }
43
+
44
+ type UseStreamingChatOptions = {
45
+ chatId: string | null
46
+ modelId?: string
47
+ onChatCreated?: (id: string) => void
48
+ }
49
+
50
+ /**
51
+ * Applies one decoded stream action to the in-flight overlay.
52
+ *
53
+ * This is deliberately pure: the SDK owns wire decoding, this reducer owns
54
+ * overlay semantics, and the hook below owns transport and React lifecycle.
55
+ */
56
+ export function reduceStreamMessages(
57
+ messages: InFlightMessage[],
58
+ action: AiStreamAction,
59
+ assistantId: string,
60
+ ): InFlightMessage[] {
61
+ switch (action.type) {
62
+ case 'append-text':
63
+ return messages.map((message) => {
64
+ if (message.id !== assistantId) return message
65
+ const last = message.parts[message.parts.length - 1]
66
+ const parts: MessagePart[] = last?.type === 'text'
67
+ ? [
68
+ ...message.parts.slice(0, -1),
69
+ { type: 'text', text: last.text + action.delta },
70
+ ]
71
+ : [...message.parts, { type: 'text', text: action.delta }]
72
+ return {
73
+ ...message,
74
+ content: message.content + action.delta,
75
+ parts,
76
+ }
77
+ })
78
+
79
+ case 'upsert-tool-call':
80
+ return upsertToolInvocation(messages, assistantId, action.toolCallId, {
81
+ toolName: action.toolName,
82
+ state: 'call',
83
+ args: action.input,
84
+ })
85
+
86
+ case 'finalize-tool-call':
87
+ return finalizeToolInvocation(
88
+ messages,
89
+ assistantId,
90
+ action.toolCallId,
91
+ action.result,
92
+ )
93
+
94
+ case 'fail-tool-input':
95
+ return upsertToolInvocation(messages, assistantId, action.toolCallId, {
96
+ toolName: action.toolName,
97
+ state: 'result',
98
+ args: action.input,
99
+ result: { success: false, error: action.errorText },
100
+ })
101
+
102
+ case 'fail-tool-output':
103
+ return finalizeToolInvocation(messages, assistantId, action.toolCallId, {
104
+ success: false,
105
+ error: action.errorText,
106
+ })
107
+
108
+ case 'stream-error':
109
+ case 'abort':
110
+ // Preserve partial text or a tool row the user has already seen, but do
111
+ // not leave an empty failed/stopped assistant occupying layout space.
112
+ return messages.filter(
113
+ (message) => message.id !== assistantId || message.parts.length > 0,
114
+ )
115
+
116
+ default: {
117
+ const exhaustive: never = action
118
+ return exhaustive
119
+ }
120
+ }
121
+ }
122
+
123
+ function upsertToolInvocation(
124
+ messages: InFlightMessage[],
125
+ assistantId: string,
126
+ toolCallId: string,
127
+ invocation: ToolInvocation,
128
+ ): InFlightMessage[] {
129
+ const part: ToolInvocationPart = {
130
+ type: 'tool-invocation',
131
+ toolInvocation: invocation,
132
+ toolCallId,
133
+ }
134
+
135
+ return messages.map((message) => {
136
+ if (message.id !== assistantId) return message
137
+ const index = message.parts.findIndex(
138
+ (candidate) =>
139
+ candidate.type === 'tool-invocation' && candidate.toolCallId === toolCallId,
140
+ )
141
+ if (index < 0) return { ...message, parts: [...message.parts, part] }
142
+
143
+ const parts = message.parts.slice()
144
+ parts[index] = part
145
+ return { ...message, parts }
146
+ })
147
+ }
148
+
149
+ function finalizeToolInvocation(
150
+ messages: InFlightMessage[],
151
+ assistantId: string,
152
+ toolCallId: string,
153
+ result: unknown,
154
+ ): InFlightMessage[] {
155
+ return messages.map((message) => {
156
+ if (message.id !== assistantId) return message
157
+ const parts = message.parts.map((part): MessagePart => {
158
+ if (part.type !== 'tool-invocation' || part.toolCallId !== toolCallId) {
159
+ return part
160
+ }
161
+ return {
162
+ ...part,
163
+ toolInvocation: {
164
+ ...part.toolInvocation,
165
+ state: 'result',
166
+ result,
167
+ },
168
+ }
169
+ })
170
+ return { ...message, parts }
171
+ })
172
+ }
173
+
174
+ export function useStreamingChat({
175
+ chatId,
176
+ modelId,
177
+ onChatCreated,
178
+ }: UseStreamingChatOptions) {
179
+ const [inFlight, setInFlight] = useState<InFlightMessage[]>([])
180
+ const [isLoading, setIsLoading] = useState(false)
181
+ const [error, setError] = useState<Error | null>(null)
182
+ const isLoadingRef = useRef(false)
183
+ const abortRef = useRef<AbortController | null>(null)
184
+ const assistantIdRef = useRef<string | null>(null)
185
+ const lastSendRef = useRef<string | null>(null)
186
+ const previousChatIdRef = useRef<string | null>(chatId)
187
+
188
+ useEffect(() => {
189
+ const previousChatId = previousChatIdRef.current
190
+ previousChatIdRef.current = chatId
191
+ // `null -> id` is the auto-create promotion during the first send. Every
192
+ // transition away from a real id is a genuine switch and aborts its turn.
193
+ if (previousChatId !== null && previousChatId !== chatId) {
194
+ abortRef.current?.abort()
195
+ setInFlight([])
196
+ }
197
+ }, [chatId])
198
+
199
+ useEffect(() => () => abortRef.current?.abort(), [])
200
+
201
+ const send = useCallback(
202
+ async (content: string) => {
203
+ // Claim the slot synchronously so two first sends cannot create two chats.
204
+ if (isLoadingRef.current) return
205
+ isLoadingRef.current = true
206
+ setIsLoading(true)
207
+ setError(null)
208
+ lastSendRef.current = content
209
+
210
+ const controller = new AbortController()
211
+ abortRef.current = controller
212
+ let assistantId: string | null = null
213
+ try {
214
+ let activeChatId = chatId
215
+ if (!activeChatId) {
216
+ activeChatId = await createChat(controller.signal)
217
+ if (controller.signal.aborted) return
218
+ onChatCreated?.(activeChatId)
219
+ }
220
+
221
+ const localTimestamp = Date.now()
222
+ const userMessageId =
223
+ `usr-${localTimestamp}-${Math.random().toString(36).slice(2, 8)}`
224
+ assistantId = `asst-pending-${localTimestamp}`
225
+ assistantIdRef.current = assistantId
226
+ setInFlight([
227
+ {
228
+ id: userMessageId,
229
+ role: 'user',
230
+ content,
231
+ parts: [],
232
+ forChatId: activeChatId,
233
+ },
234
+ {
235
+ id: assistantId,
236
+ role: 'assistant',
237
+ content: '',
238
+ parts: [],
239
+ forChatId: activeChatId,
240
+ },
241
+ ])
242
+
243
+ const token = await getAuthToken()
244
+ const response = await fetch('/api/ai/chat', {
245
+ method: 'POST',
246
+ headers: authHeaders(token),
247
+ body: JSON.stringify({
248
+ chatId: activeChatId,
249
+ userMessageId,
250
+ content,
251
+ modelId,
252
+ }),
253
+ signal: controller.signal,
254
+ })
255
+ if (!response.ok || !response.body) {
256
+ const detail = response.body
257
+ ? await response.text().catch(() => '')
258
+ : ''
259
+ throw new Error(detail || `Request failed: ${response.status}`)
260
+ }
261
+
262
+ const serverId = response.headers.get('X-Asst-Id')
263
+ if (serverId) {
264
+ setInFlight((messages) =>
265
+ messages.map((message) =>
266
+ message.id === assistantId ? { ...message, serverId } : message,
267
+ ),
268
+ )
269
+ }
270
+
271
+ await consumeStream(response.body, assistantId, setInFlight, setError)
272
+ } catch (cause) {
273
+ const streamError = toError(cause)
274
+ // A local abort does not necessarily deliver an SSE `abort` event.
275
+ // Apply the same overlay rule immediately for stop and transport errors.
276
+ if (assistantId) {
277
+ const failedAssistantId = assistantId
278
+ setInFlight((messages) =>
279
+ reduceStreamMessages(messages, { type: 'abort' }, failedAssistantId),
280
+ )
281
+ }
282
+ if (streamError.name !== 'AbortError') {
283
+ console.error('[chat] STREAM error', {
284
+ name: streamError.name,
285
+ message: streamError.message,
286
+ })
287
+ setError(streamError)
288
+ }
289
+ } finally {
290
+ if (assistantId && assistantIdRef.current === assistantId) {
291
+ assistantIdRef.current = null
292
+ }
293
+ if (abortRef.current === controller) abortRef.current = null
294
+ isLoadingRef.current = false
295
+ setIsLoading(false)
296
+ }
297
+ },
298
+ [chatId, modelId, onChatCreated],
299
+ )
300
+
301
+ const stop = useCallback(() => {
302
+ abortRef.current?.abort()
303
+ const assistantId = assistantIdRef.current
304
+ if (assistantId) {
305
+ setInFlight((messages) =>
306
+ reduceStreamMessages(messages, { type: 'abort' }, assistantId),
307
+ )
308
+ }
309
+ }, [])
310
+
311
+ const retry = useCallback(() => {
312
+ if (lastSendRef.current) void send(lastSendRef.current)
313
+ }, [send])
314
+
315
+ return { send, stop, retry, isLoading, error, inFlight }
316
+ }
317
+
318
+ async function createChat(signal: AbortSignal): Promise<string> {
319
+ const token = await getAuthToken()
320
+ const response = await fetch('/api/ai/chats', {
321
+ method: 'POST',
322
+ headers: authHeaders(token),
323
+ body: JSON.stringify({}),
324
+ signal,
325
+ })
326
+ if (!response.ok) throw new Error(`Failed to create chat: ${response.status}`)
327
+ const data = (await response.json()) as { chat?: { recordId?: unknown } }
328
+ if (typeof data.chat?.recordId !== 'string') {
329
+ throw new Error('Failed to create chat: response is missing chat.recordId')
330
+ }
331
+ return data.chat.recordId
332
+ }
333
+
334
+ function authHeaders(token: string | null): Record<string, string> {
335
+ return {
336
+ 'Content-Type': 'application/json',
337
+ ...(token ? { Authorization: `Bearer ${token}` } : {}),
338
+ }
339
+ }
340
+
341
+ async function consumeStream(
342
+ body: ReadableStream<Uint8Array>,
343
+ assistantId: string,
344
+ setInFlight: Dispatch<SetStateAction<InFlightMessage[]>>,
345
+ setError: Dispatch<SetStateAction<Error | null>>,
346
+ ) {
347
+ const reader = body.getReader()
348
+ const decoder = new TextDecoder()
349
+ let buffer = ''
350
+
351
+ while (true) {
352
+ const { done, value } = await reader.read()
353
+ if (done) break
354
+ buffer += decoder.decode(value, { stream: true })
355
+ const lines = buffer.split('\n')
356
+ buffer = lines.pop() ?? ''
357
+ for (const line of lines) {
358
+ applySseLine(line, assistantId, setInFlight, setError)
359
+ }
360
+ }
361
+
362
+ buffer += decoder.decode()
363
+ applySseLine(buffer, assistantId, setInFlight, setError)
364
+ }
365
+
366
+ function applySseLine(
367
+ line: string,
368
+ assistantId: string,
369
+ setInFlight: Dispatch<SetStateAction<InFlightMessage[]>>,
370
+ setError: Dispatch<SetStateAction<Error | null>>,
371
+ ) {
372
+ const chunk = parseSseLine(line)
373
+ if (!chunk) return
374
+ const action = decodeAiStreamChunk(chunk)
375
+ if (!action) return
376
+
377
+ if (action.type === 'stream-error') {
378
+ console.error('[chat] STREAM error', action.errorText)
379
+ setError(new Error(action.errorText))
380
+ } else if (action.type === 'fail-tool-input') {
381
+ console.error('[chat] STREAM tool-input-error', {
382
+ toolCallId: action.toolCallId,
383
+ errorText: action.errorText,
384
+ })
385
+ } else if (action.type === 'fail-tool-output') {
386
+ console.error('[chat] STREAM tool-output-error', {
387
+ toolCallId: action.toolCallId,
388
+ errorText: action.errorText,
389
+ })
390
+ }
391
+
392
+ setInFlight((messages) => reduceStreamMessages(messages, action, assistantId))
393
+ }
394
+
395
+ function toError(cause: unknown): Error {
396
+ return cause instanceof Error ? cause : new Error(String(cause))
397
+ }