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