@ouraihub/aether-worker 0.0.0-stage → 0.1.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.
- package/LICENSE +789 -0
- package/migrations/0001.sql +22 -0
- package/package.json +38 -3
- package/src/app.ts +363 -0
- package/src/cloudflare.ts +49 -0
- package/src/context.ts +55 -0
- package/src/conversations/action.ts +257 -0
- package/src/conversations/index.ts +480 -0
- package/src/conversations/model.ts +427 -0
- package/src/conversations/routes.ts +101 -0
- package/src/coordinator-helpers.ts +131 -0
- package/src/coordinator.ts +488 -0
- package/src/credentials.ts +171 -0
- package/src/files.ts +117 -0
- package/src/host.ts +25 -0
- package/src/identity.ts +227 -0
- package/src/images/index.ts +73 -0
- package/src/index.ts +30 -0
- package/src/logger.ts +98 -0
- package/src/mcp/auth.ts +398 -0
- package/src/mcp/client.ts +491 -0
- package/src/mcp/common.ts +124 -0
- package/src/mcp/index.ts +292 -0
- package/src/mcp/oauth.ts +343 -0
- package/src/mcp/types.ts +81 -0
- package/src/outbound.ts +320 -0
- package/src/providers/catalog.ts +63 -0
- package/src/providers/http.ts +88 -0
- package/src/providers/index.ts +445 -0
- package/src/providers/model-options.ts +28 -0
- package/src/runtime-input.ts +44 -0
- package/src/runtime-tools.ts +298 -0
- package/src/runtime.ts +476 -0
- package/src/search/index.ts +497 -0
- package/src/skills/index.ts +1 -0
- package/src/skills/persistence.ts +153 -0
- package/src/skills/service.ts +250 -0
- package/src/storage.ts +293 -0
- package/src/testing/fakes.ts +97 -0
- package/README.md +0 -3
|
@@ -0,0 +1,480 @@
|
|
|
1
|
+
import type {
|
|
2
|
+
Conversation,
|
|
3
|
+
ConversationSummary,
|
|
4
|
+
ImagePart,
|
|
5
|
+
ImageRef,
|
|
6
|
+
RunAccepted,
|
|
7
|
+
RunRequest,
|
|
8
|
+
} from '@ouraihub/aether-contracts'
|
|
9
|
+
import type { RunInput, RunSummary } from '@ouraihub/aether-core/types'
|
|
10
|
+
import { KitFailure, badRequest } from '@ouraihub/aether-core/error'
|
|
11
|
+
import { canonicalJson } from '@ouraihub/aether-core/guard'
|
|
12
|
+
import { decodeImage, parseImage } from '@ouraihub/aether-core/images'
|
|
13
|
+
import type { Actor, WorkerContext } from '../context.ts'
|
|
14
|
+
import { handleConversationRequest } from './routes.ts'
|
|
15
|
+
import { retryCas } from '../storage.ts'
|
|
16
|
+
import type { DocumentStore, RetryOptions, StoredDocument } from '../storage.ts'
|
|
17
|
+
import { authoritative, fingerprint, resolveAction } from './action.ts'
|
|
18
|
+
import type { PreparedAction, ReservationInput } from './action.ts'
|
|
19
|
+
import {
|
|
20
|
+
COLLECTION,
|
|
21
|
+
checkId,
|
|
22
|
+
checkText,
|
|
23
|
+
conflict,
|
|
24
|
+
emptyUsage,
|
|
25
|
+
historyPath,
|
|
26
|
+
idle,
|
|
27
|
+
imageRefs,
|
|
28
|
+
notFound,
|
|
29
|
+
parseDocument,
|
|
30
|
+
project,
|
|
31
|
+
target,
|
|
32
|
+
visibleText,
|
|
33
|
+
} from './model.ts'
|
|
34
|
+
import type { ConversationDocument, StoredTurn } from './model.ts'
|
|
35
|
+
export type { PreparedAction, ReservationInput } from './action.ts'
|
|
36
|
+
export type { ConversationDocument, StoredImage, StoredTurn } from './model.ts'
|
|
37
|
+
export { IMAGE_TTL_DAYS } from './model.ts'
|
|
38
|
+
|
|
39
|
+
/** Persist this before reservation; it contains identity only, never provider credentials. */
|
|
40
|
+
export interface AcceptedHandle {
|
|
41
|
+
runId: string
|
|
42
|
+
conversationId: string
|
|
43
|
+
turnId: string
|
|
44
|
+
reservationId: string
|
|
45
|
+
}
|
|
46
|
+
export interface TurnReservation {
|
|
47
|
+
handle: AcceptedHandle
|
|
48
|
+
accepted: RunAccepted
|
|
49
|
+
input: ReservationInput
|
|
50
|
+
runInput: RunInput
|
|
51
|
+
}
|
|
52
|
+
export interface ConversationOptions {
|
|
53
|
+
store: DocumentStore
|
|
54
|
+
now?: () => number
|
|
55
|
+
newId?: () => string
|
|
56
|
+
/** CAS retry schedule; tests inject sleep/random to make contention deterministic. */
|
|
57
|
+
retry?: RetryOptions
|
|
58
|
+
}
|
|
59
|
+
export class ConversationService {
|
|
60
|
+
private readonly store: DocumentStore
|
|
61
|
+
private readonly now: () => number
|
|
62
|
+
private readonly newId: () => string
|
|
63
|
+
private readonly retry: RetryOptions
|
|
64
|
+
constructor(options: ConversationOptions) {
|
|
65
|
+
this.store = options.store
|
|
66
|
+
this.now = options.now ?? Date.now
|
|
67
|
+
this.newId = options.newId ?? (() => crypto.randomUUID())
|
|
68
|
+
this.retry = options.retry ?? {}
|
|
69
|
+
}
|
|
70
|
+
/** Reloads and re-applies `attempt` on revision conflicts; see `retryCas`. */
|
|
71
|
+
private update<T>(attempt: Parameters<typeof retryCas<T>>[0]): Promise<T> {
|
|
72
|
+
return retryCas(attempt, () => conflict('conversation_contended'), this.retry)
|
|
73
|
+
}
|
|
74
|
+
private async load(
|
|
75
|
+
id: string,
|
|
76
|
+
actor: Actor,
|
|
77
|
+
): Promise<{ revision: number; document: ConversationDocument }> {
|
|
78
|
+
checkId('conversationId', id)
|
|
79
|
+
const stored = await this.store.get(actor, COLLECTION, id)
|
|
80
|
+
if (!stored) throw notFound('conversation')
|
|
81
|
+
return { revision: stored.revision, document: parseDocument(stored.value, id) }
|
|
82
|
+
}
|
|
83
|
+
private async *documents(actor: Actor): AsyncGenerator<StoredDocument> {
|
|
84
|
+
let after: string | undefined
|
|
85
|
+
while (true) {
|
|
86
|
+
const page = await this.store.list(actor, COLLECTION, {
|
|
87
|
+
...(after === undefined ? {} : { after }),
|
|
88
|
+
limit: 200,
|
|
89
|
+
})
|
|
90
|
+
for (const stored of page) yield stored
|
|
91
|
+
if (page.length < 200) return
|
|
92
|
+
const last = page.at(-1)?.key
|
|
93
|
+
if (last === undefined || (after !== undefined && last <= after))
|
|
94
|
+
throw new KitFailure('internal', 'Invalid document pagination')
|
|
95
|
+
after = last
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
async list(scope: string | null, actor: Actor): Promise<ConversationSummary[]> {
|
|
99
|
+
if (scope !== null) checkText('scope', scope, 200)
|
|
100
|
+
const summaries: ConversationSummary[] = []
|
|
101
|
+
for await (const stored of this.documents(actor)) {
|
|
102
|
+
const summary = parseDocument(stored.value, stored.key).summary
|
|
103
|
+
if (scope === null || summary.scope === scope) summaries.push(summary)
|
|
104
|
+
}
|
|
105
|
+
return summaries
|
|
106
|
+
.sort((a, b) => b.updatedAt - a.updatedAt || b.id.localeCompare(a.id))
|
|
107
|
+
.slice(0, 200)
|
|
108
|
+
}
|
|
109
|
+
async get(id: string, actor: Actor): Promise<Conversation> {
|
|
110
|
+
return project((await this.load(id, actor)).document)
|
|
111
|
+
}
|
|
112
|
+
async resolve(request: RunRequest, actor: Actor): Promise<PreparedAction> {
|
|
113
|
+
const snapshot = structuredClone(request)
|
|
114
|
+
const document = snapshot.conversationId
|
|
115
|
+
? (await this.load(snapshot.conversationId, actor)).document
|
|
116
|
+
: null
|
|
117
|
+
return resolveAction(snapshot, document)
|
|
118
|
+
}
|
|
119
|
+
plan(prepared: PreparedAction, runId: string): AcceptedHandle {
|
|
120
|
+
checkId('runId', runId)
|
|
121
|
+
const conversationId = prepared.request.conversationId ?? `${runId}-conversation`
|
|
122
|
+
checkId('conversationId', conversationId)
|
|
123
|
+
return { runId, conversationId, turnId: runId, reservationId: runId }
|
|
124
|
+
}
|
|
125
|
+
async reserve(
|
|
126
|
+
prepared: PreparedAction,
|
|
127
|
+
validatedInput: RunInput,
|
|
128
|
+
runId: string,
|
|
129
|
+
actor: Actor,
|
|
130
|
+
validate?: (input: ReservationInput, execution: RunInput) => RunInput,
|
|
131
|
+
): Promise<TurnReservation> {
|
|
132
|
+
const handle = this.plan(prepared, runId)
|
|
133
|
+
return this.update(async (cas) => {
|
|
134
|
+
const stored = await this.store.get(actor, COLLECTION, handle.conversationId)
|
|
135
|
+
if (prepared.request.conversationId && !stored) throw conflict('preparation_changed')
|
|
136
|
+
if (!prepared.request.conversationId && stored) throw conflict('run_identity_exists')
|
|
137
|
+
const document = stored ? parseDocument(stored.value, stored.key) : null
|
|
138
|
+
let input: ReservationInput
|
|
139
|
+
try {
|
|
140
|
+
input = authoritative(prepared.request, prepared.action, prepared.selectionGuard, document)
|
|
141
|
+
} catch (error) {
|
|
142
|
+
if (error instanceof KitFailure && error.details?.reason === 'turn_running') throw error
|
|
143
|
+
throw conflict('preparation_changed')
|
|
144
|
+
}
|
|
145
|
+
if (
|
|
146
|
+
(await fingerprint(
|
|
147
|
+
prepared.request,
|
|
148
|
+
prepared.action,
|
|
149
|
+
prepared.selectionGuard,
|
|
150
|
+
document,
|
|
151
|
+
input,
|
|
152
|
+
)) !== prepared.expected
|
|
153
|
+
)
|
|
154
|
+
throw conflict('preparation_changed')
|
|
155
|
+
const runInput = validate ? validate(input, validatedInput) : validatedInput
|
|
156
|
+
if (
|
|
157
|
+
runInput.runId !== runId ||
|
|
158
|
+
runInput.input !== input.input ||
|
|
159
|
+
canonicalJson(runInput.images) !== canonicalJson(input.images)
|
|
160
|
+
)
|
|
161
|
+
throw conflict('validated_input_changed')
|
|
162
|
+
if (
|
|
163
|
+
prepared.requiresSourceModel &&
|
|
164
|
+
(!input.sourceModel ||
|
|
165
|
+
runInput.connection.endpointId !== input.sourceModel.endpointId ||
|
|
166
|
+
runInput.connection.modelId !== input.sourceModel.modelId)
|
|
167
|
+
)
|
|
168
|
+
throw new KitFailure('needs_config', 'Source model unavailable', {
|
|
169
|
+
reason: 'source_model_unavailable',
|
|
170
|
+
})
|
|
171
|
+
const now = this.now(),
|
|
172
|
+
source =
|
|
173
|
+
document && prepared.action.kind !== 'reply'
|
|
174
|
+
? target(document, prepared.action.sourceId)
|
|
175
|
+
: null
|
|
176
|
+
const parentId =
|
|
177
|
+
prepared.action.kind === 'reply'
|
|
178
|
+
? prepared.action.parentId
|
|
179
|
+
: prepared.action.kind === 'translation'
|
|
180
|
+
? (source?.id ?? null)
|
|
181
|
+
: (source?.parentId ?? null)
|
|
182
|
+
const turn: StoredTurn = {
|
|
183
|
+
id: handle.turnId,
|
|
184
|
+
seq: (document?.turns.at(-1)?.seq ?? 0) + 1,
|
|
185
|
+
runId,
|
|
186
|
+
reservationId: handle.reservationId,
|
|
187
|
+
parentId,
|
|
188
|
+
questionId: prepared.action.kind === 'retry' && source ? source.questionId : handle.turnId,
|
|
189
|
+
supersedesId: prepared.action.kind === 'retry' ? (source?.id ?? null) : null,
|
|
190
|
+
kind: prepared.action.kind === 'translation' ? 'translation' : (source?.kind ?? 'reply'),
|
|
191
|
+
status: 'running',
|
|
192
|
+
input: input.input,
|
|
193
|
+
messages: [],
|
|
194
|
+
modelId: runInput.connection.modelId,
|
|
195
|
+
endpointId: runInput.connection.endpointId,
|
|
196
|
+
skills: structuredClone(prepared.request.skills),
|
|
197
|
+
errorCode: null,
|
|
198
|
+
usage: emptyUsage(),
|
|
199
|
+
contextUsage: null,
|
|
200
|
+
reasoningEffort: runInput.reasoningEffort,
|
|
201
|
+
cost: null,
|
|
202
|
+
createdAt: now,
|
|
203
|
+
images:
|
|
204
|
+
prepared.action.kind === 'retry' && source
|
|
205
|
+
? structuredClone(source.images)
|
|
206
|
+
: input.images.map((image) => ({ ...image, createdAt: now })),
|
|
207
|
+
generated: [],
|
|
208
|
+
}
|
|
209
|
+
const next: ConversationDocument = document ?? {
|
|
210
|
+
version: 1,
|
|
211
|
+
summary: {
|
|
212
|
+
id: handle.conversationId,
|
|
213
|
+
title: [...input.input.replace(/[\r\n]/gu, '')].slice(0, 40).join(''),
|
|
214
|
+
scope: prepared.request.scope ?? null,
|
|
215
|
+
selectedTurnId: null,
|
|
216
|
+
createdAt: now,
|
|
217
|
+
updatedAt: now,
|
|
218
|
+
},
|
|
219
|
+
turns: [],
|
|
220
|
+
}
|
|
221
|
+
next.turns.push(turn)
|
|
222
|
+
next.summary.updatedAt = now
|
|
223
|
+
const checked = parseDocument(next, handle.conversationId)
|
|
224
|
+
await cas(() =>
|
|
225
|
+
this.store.compareAndSet(
|
|
226
|
+
actor,
|
|
227
|
+
COLLECTION,
|
|
228
|
+
handle.conversationId,
|
|
229
|
+
stored?.revision ?? null,
|
|
230
|
+
checked,
|
|
231
|
+
),
|
|
232
|
+
)
|
|
233
|
+
return {
|
|
234
|
+
handle,
|
|
235
|
+
accepted: { runId, conversationId: handle.conversationId, turnId: handle.turnId },
|
|
236
|
+
input,
|
|
237
|
+
runInput,
|
|
238
|
+
}
|
|
239
|
+
})
|
|
240
|
+
}
|
|
241
|
+
private checkHandle(document: ConversationDocument, handle: AcceptedHandle): StoredTurn {
|
|
242
|
+
if (document.summary.id !== handle.conversationId) throw notFound('turn')
|
|
243
|
+
const turn = target(document, handle.turnId)
|
|
244
|
+
if (turn.runId !== handle.runId || turn.reservationId !== handle.reservationId)
|
|
245
|
+
throw notFound('turn')
|
|
246
|
+
if (turn.status !== 'running') throw conflict('turn_finished')
|
|
247
|
+
return turn
|
|
248
|
+
}
|
|
249
|
+
async finish(handle: AcceptedHandle, summary: RunSummary, actor: Actor): Promise<Conversation> {
|
|
250
|
+
return this.update(async (cas) => {
|
|
251
|
+
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
252
|
+
turn = this.checkHandle(document, handle)
|
|
253
|
+
turn.status = summary.status
|
|
254
|
+
turn.messages = structuredClone(summary.messages)
|
|
255
|
+
turn.usage = structuredClone(summary.usage)
|
|
256
|
+
turn.cost = structuredClone(summary.cost)
|
|
257
|
+
turn.contextUsage = structuredClone(summary.contextUsage)
|
|
258
|
+
turn.errorCode = summary.status === 'failed' ? (summary.error?.code ?? 'internal') : null
|
|
259
|
+
document.summary.updatedAt = this.now()
|
|
260
|
+
if (summary.status === 'completed' && turn.supersedesId === null)
|
|
261
|
+
document.summary.selectedTurnId = turn.id
|
|
262
|
+
const checked = parseDocument(document, document.summary.id)
|
|
263
|
+
await cas(() =>
|
|
264
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, checked),
|
|
265
|
+
)
|
|
266
|
+
return project(checked)
|
|
267
|
+
})
|
|
268
|
+
}
|
|
269
|
+
/** Only the coordinator, having established interruption, may recover a planned run. */
|
|
270
|
+
async recover(handle: AcceptedHandle, actor: Actor): Promise<Conversation> {
|
|
271
|
+
return this.update(async (cas) => {
|
|
272
|
+
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
273
|
+
turn = this.checkHandle(document, handle)
|
|
274
|
+
turn.status = 'failed'
|
|
275
|
+
turn.errorCode = 'interrupted'
|
|
276
|
+
turn.contextUsage = null
|
|
277
|
+
document.summary.updatedAt = this.now()
|
|
278
|
+
await cas(() =>
|
|
279
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
|
|
280
|
+
)
|
|
281
|
+
return project(document)
|
|
282
|
+
})
|
|
283
|
+
}
|
|
284
|
+
async addGenerated(
|
|
285
|
+
handle: AcceptedHandle,
|
|
286
|
+
images: readonly ImagePart[],
|
|
287
|
+
actor: Actor,
|
|
288
|
+
): Promise<ImageRef[]> {
|
|
289
|
+
for (const image of images) parseImage(image)
|
|
290
|
+
return this.update(async (cas) => {
|
|
291
|
+
const { document, revision } = await this.load(handle.conversationId, actor),
|
|
292
|
+
turn = this.checkHandle(document, handle),
|
|
293
|
+
first = turn.generated.length
|
|
294
|
+
const now = this.now()
|
|
295
|
+
turn.generated.push(...images.map((image) => ({ ...image, createdAt: now })))
|
|
296
|
+
await cas(() =>
|
|
297
|
+
this.store.compareAndSet(actor, COLLECTION, handle.conversationId, revision, document),
|
|
298
|
+
)
|
|
299
|
+
return imageRefs(handle.conversationId, handle.turnId, 'generated', turn.generated).slice(
|
|
300
|
+
first,
|
|
301
|
+
)
|
|
302
|
+
})
|
|
303
|
+
}
|
|
304
|
+
async purgeImages(beforeMs: number, actor: Actor): Promise<number> {
|
|
305
|
+
if (!Number.isSafeInteger(beforeMs) || beforeMs < 0) throw badRequest('beforeMs', 'invalid')
|
|
306
|
+
let total = 0
|
|
307
|
+
for await (const listed of this.documents(actor)) {
|
|
308
|
+
// The listed revision is tried first; after a conflict the document is reloaded, and one
|
|
309
|
+
// deleted meanwhile has nothing left to purge.
|
|
310
|
+
let current: StoredDocument | null = listed
|
|
311
|
+
total += await this.update(async (cas) => {
|
|
312
|
+
current ??= await this.store.get(actor, COLLECTION, listed.key)
|
|
313
|
+
const stored = current
|
|
314
|
+
current = null
|
|
315
|
+
if (stored === null) return 0
|
|
316
|
+
const document = parseDocument(stored.value, stored.key)
|
|
317
|
+
let count = 0
|
|
318
|
+
for (const turn of document.turns)
|
|
319
|
+
for (const image of [...turn.images, ...turn.generated])
|
|
320
|
+
if (image.data !== null && image.createdAt < beforeMs) {
|
|
321
|
+
image.data = null
|
|
322
|
+
count++
|
|
323
|
+
}
|
|
324
|
+
if (count)
|
|
325
|
+
await cas(() =>
|
|
326
|
+
this.store.compareAndSet(actor, COLLECTION, stored.key, stored.revision, document),
|
|
327
|
+
)
|
|
328
|
+
return count
|
|
329
|
+
})
|
|
330
|
+
}
|
|
331
|
+
return total
|
|
332
|
+
}
|
|
333
|
+
async rename(id: string, title: string, actor: Actor): Promise<ConversationSummary> {
|
|
334
|
+
checkText('title', title, 200, true)
|
|
335
|
+
return this.update(async (cas) => {
|
|
336
|
+
const { document, revision } = await this.load(id, actor)
|
|
337
|
+
idle(document)
|
|
338
|
+
document.summary.title = title.trim()
|
|
339
|
+
await cas(() => this.store.compareAndSet(actor, COLLECTION, id, revision, document))
|
|
340
|
+
return document.summary
|
|
341
|
+
})
|
|
342
|
+
}
|
|
343
|
+
async delete(id: string, actor: Actor): Promise<void> {
|
|
344
|
+
const { document, revision } = await this.load(id, actor)
|
|
345
|
+
idle(document)
|
|
346
|
+
await this.store.remove(actor, COLLECTION, id, revision)
|
|
347
|
+
}
|
|
348
|
+
async editTurn(
|
|
349
|
+
id: string,
|
|
350
|
+
turnId: string,
|
|
351
|
+
text: string,
|
|
352
|
+
expectedText: string,
|
|
353
|
+
actor: Actor,
|
|
354
|
+
): Promise<Conversation> {
|
|
355
|
+
checkText('text', text, 24_000, true)
|
|
356
|
+
const { document, revision } = await this.load(id, actor)
|
|
357
|
+
idle(document)
|
|
358
|
+
const turn = target(document, turnId)
|
|
359
|
+
if (document.turns.some((t) => t.parentId === turnId || t.supersedesId === turnId))
|
|
360
|
+
throw conflict('turn_has_dependents')
|
|
361
|
+
if (visibleText(turn) !== expectedText) throw conflict('text_changed')
|
|
362
|
+
turn.messages = [{ assistant: { text: text.trim(), tool_calls: [] } }]
|
|
363
|
+
turn.contextUsage = null
|
|
364
|
+
document.summary.updatedAt = this.now()
|
|
365
|
+
await this.store.compareAndSet(actor, COLLECTION, id, revision, document)
|
|
366
|
+
return project(document)
|
|
367
|
+
}
|
|
368
|
+
async selectTurn(id: string, turnId: string, actor: Actor): Promise<Conversation> {
|
|
369
|
+
const { document, revision } = await this.load(id, actor)
|
|
370
|
+
idle(document)
|
|
371
|
+
if (target(document, turnId).status !== 'completed') throw conflict('turn_not_completed')
|
|
372
|
+
document.summary.selectedTurnId = turnId
|
|
373
|
+
document.summary.updatedAt = this.now()
|
|
374
|
+
await this.store.compareAndSet(actor, COLLECTION, id, revision, document)
|
|
375
|
+
return project(document)
|
|
376
|
+
}
|
|
377
|
+
async removeTurn(id: string, turnId: string, actor: Actor): Promise<Conversation> {
|
|
378
|
+
const { document, revision } = await this.load(id, actor)
|
|
379
|
+
idle(document)
|
|
380
|
+
const removed = target(document, turnId)
|
|
381
|
+
const siblings = document.turns.filter(
|
|
382
|
+
(t) => t.id !== turnId && t.kind === 'reply' && t.questionId === removed.questionId,
|
|
383
|
+
)
|
|
384
|
+
const sibling = siblings.find((t) => t.seq > removed.seq) ?? siblings.at(-1),
|
|
385
|
+
successor = sibling?.id ?? removed.parentId
|
|
386
|
+
const deleted = new Set(
|
|
387
|
+
document.turns
|
|
388
|
+
.filter((t) => t.id === turnId || (t.kind === 'translation' && t.parentId === turnId))
|
|
389
|
+
.map((t) => t.id),
|
|
390
|
+
)
|
|
391
|
+
document.turns = document.turns.filter((t) => !deleted.has(t.id))
|
|
392
|
+
for (const turn of document.turns) {
|
|
393
|
+
if (turn.parentId !== null && deleted.has(turn.parentId))
|
|
394
|
+
turn.parentId = successor === turn.id ? removed.parentId : successor
|
|
395
|
+
if (turn.supersedesId !== null && deleted.has(turn.supersedesId))
|
|
396
|
+
turn.supersedesId = sibling && sibling.id !== turn.id ? sibling.id : null
|
|
397
|
+
}
|
|
398
|
+
if (document.summary.selectedTurnId !== null && deleted.has(document.summary.selectedTurnId)) {
|
|
399
|
+
let next =
|
|
400
|
+
successor === null
|
|
401
|
+
? document.turns.findLast((t) => t.kind === 'reply' && t.parentId === null)
|
|
402
|
+
: document.turns.find((t) => t.id === successor)
|
|
403
|
+
document.summary.selectedTurnId = null
|
|
404
|
+
const seen = new Set<string>()
|
|
405
|
+
while (next) {
|
|
406
|
+
if (seen.has(next.id)) throw conflict('history_cycle')
|
|
407
|
+
seen.add(next.id)
|
|
408
|
+
const head = next.id
|
|
409
|
+
document.summary.selectedTurnId = head
|
|
410
|
+
next = document.turns.findLast((t) => t.kind === 'reply' && t.parentId === head)
|
|
411
|
+
}
|
|
412
|
+
}
|
|
413
|
+
document.summary.updatedAt = this.now()
|
|
414
|
+
const checked = parseDocument(document, id)
|
|
415
|
+
await this.store.compareAndSet(actor, COLLECTION, id, revision, checked)
|
|
416
|
+
return project(checked)
|
|
417
|
+
}
|
|
418
|
+
async fork(id: string, turnId: string, actor: Actor): Promise<Conversation> {
|
|
419
|
+
const { document } = await this.load(id, actor)
|
|
420
|
+
idle(document)
|
|
421
|
+
const path = historyPath(document, turnId),
|
|
422
|
+
forkId = this.newId(),
|
|
423
|
+
ids = new Map(path.map((t) => [t.id, this.newId()])),
|
|
424
|
+
questions = new Map<string, string>()
|
|
425
|
+
for (const turn of path)
|
|
426
|
+
if (!questions.has(turn.questionId))
|
|
427
|
+
questions.set(turn.questionId, ids.get(turn.questionId) ?? this.newId())
|
|
428
|
+
const now = this.now(),
|
|
429
|
+
selected = ids.get(turnId)
|
|
430
|
+
if (!selected) throw notFound('turn')
|
|
431
|
+
const turns = path.map((turn, index): StoredTurn => {
|
|
432
|
+
const newId = ids.get(turn.id),
|
|
433
|
+
questionId = questions.get(turn.questionId)
|
|
434
|
+
if (!newId || !questionId) throw conflict('history_changed')
|
|
435
|
+
return {
|
|
436
|
+
...structuredClone(turn),
|
|
437
|
+
id: newId,
|
|
438
|
+
seq: index + 1,
|
|
439
|
+
parentId: turn.parentId ? (ids.get(turn.parentId) ?? null) : null,
|
|
440
|
+
questionId,
|
|
441
|
+
supersedesId: turn.supersedesId ? (ids.get(turn.supersedesId) ?? null) : null,
|
|
442
|
+
}
|
|
443
|
+
})
|
|
444
|
+
const fork: ConversationDocument = {
|
|
445
|
+
version: 1,
|
|
446
|
+
summary: {
|
|
447
|
+
...document.summary,
|
|
448
|
+
id: forkId,
|
|
449
|
+
selectedTurnId: selected,
|
|
450
|
+
createdAt: now,
|
|
451
|
+
updatedAt: now,
|
|
452
|
+
},
|
|
453
|
+
turns,
|
|
454
|
+
}
|
|
455
|
+
await this.store.compareAndSet(actor, COLLECTION, forkId, null, fork)
|
|
456
|
+
return project(fork)
|
|
457
|
+
}
|
|
458
|
+
async handle(request: Request, context: WorkerContext): Promise<Response | null> {
|
|
459
|
+
return handleConversationRequest(
|
|
460
|
+
this,
|
|
461
|
+
request,
|
|
462
|
+
context,
|
|
463
|
+
async (id, turnId, kind, index, actor) => {
|
|
464
|
+
const { document } = await this.load(id, actor),
|
|
465
|
+
turn = target(document, turnId),
|
|
466
|
+
image = (kind === 'input' ? turn.images : turn.generated)[index]
|
|
467
|
+
if (!image || image.data === null) throw notFound('image')
|
|
468
|
+
const bytes = decodeImage({ mime: image.mime, data: image.data })
|
|
469
|
+
return new Response(bytes, {
|
|
470
|
+
headers: {
|
|
471
|
+
'Content-Type': image.mime,
|
|
472
|
+
'Cache-Control': 'private, no-store',
|
|
473
|
+
'X-Content-Type-Options': 'nosniff',
|
|
474
|
+
'Content-Security-Policy': "default-src 'none'",
|
|
475
|
+
},
|
|
476
|
+
})
|
|
477
|
+
},
|
|
478
|
+
)
|
|
479
|
+
}
|
|
480
|
+
}
|