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
|
@@ -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
|
+
}
|