mirror of
https://github.com/wu736139669/hapi.git
synced 2026-11-07 01:21:37 +00:00
* feat(web): ingest GET /share?url=&text=&title= deep links Native companions that cannot POST via Web Share Target can open the same session picker by synthesizing the IndexedDB transfer client-side. When id is present, the existing SW path still wins. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): preserve share deep-link whitespace; scrub content beside id Keep GET content strings verbatim when non-empty (match POST form-data). When id is present with leftover url/text/title, replace to ?id= only so payload does not linger in the address bar. Query-param contract stays — fragments would break shipped native companions. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): ingest /share deep links via URL fragment, not query Shared url/text/title must not appear on the HTTP request line — hub Hono logger (and any access log) records path+query. Native companions open /share#url=&text=&title=; the client reads the fragment, scrubs it, and continues with the existing ?id= picker path. Query validateSearch keeps only id/error (Web Share Target redirect). Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): keep /share hash ingest across StrictMode remount Capture the fragment in useState and reuse a single putShareTransfer promise so the first effect's scrub + cleanup cancel does not lose the deep-link under React.StrictMode. Co-authored-by: Cursor <cursoragent@cursor.com> * feat(web): fetch companion fileUrl into /share transfer files Native shares cannot put binaries in the hash fragment. Companions host a one-shot CORS URL and pass fileUrl/fileName/fileType; the share page fetches bytes into the same IndexedDB files[] as Web Share Target POST. * fix(web): cap share fileUrl fetch at the composer upload ceiling Stream fileUrl downloads with Content-Length and body size checks matching MAX_UPLOAD_BYTES so a crafted deep link cannot buffer unbounded bytes into IndexedDB. Align native deep-link docs on the fileUrl hand-off vs POST. Co-authored-by: Cursor <cursoragent@cursor.com> * fix(web): cast streamed fileUrl chunks to BlobPart for tsc Co-authored-by: Cursor <cursoragent@cursor.com> --------- Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
9.2 KiB
TypeScript
243 lines
9.2 KiB
TypeScript
import type { AttachmentAdapter, PendingAttachment, CompleteAttachment, Attachment } from '@assistant-ui/react'
|
|
import type { ApiClient } from '@/api/client'
|
|
import type { AttachmentMetadata } from '@/types/api'
|
|
import { isImageMimeType } from '@/lib/fileAttachments'
|
|
import { randomId } from '@/lib/randomId'
|
|
import { getRestoredUploadMetadata } from '@/lib/composer-attachment-drafts'
|
|
import type { AttachmentDraftHandoff } from '@/lib/composer-draft-transfer'
|
|
|
|
/** Composer / share upload ceiling — keep deep-link fetch in sync. */
|
|
export const MAX_UPLOAD_BYTES = 50 * 1024 * 1024
|
|
const MAX_PREVIEW_BYTES = 5 * 1024 * 1024
|
|
|
|
type PendingUploadAttachment = PendingAttachment & {
|
|
path?: string
|
|
previewUrl?: string
|
|
uploadSessionId?: string
|
|
}
|
|
|
|
export function createAttachmentAdapter(
|
|
api: ApiClient,
|
|
sessionId: string,
|
|
resolveSessionId?: () => Promise<string>,
|
|
// Always hand off after resume merges into a new session id — even when
|
|
// the pick is cancelled — so the caller can navigate off a deleted source.
|
|
// Cancellation is re-checked at transfer save time via isCancelled().
|
|
onSessionResolved?: (sessionId: string, pending: AttachmentDraftHandoff) => Promise<void>,
|
|
): AttachmentAdapter {
|
|
const cancelledAttachmentIds = new Set<string>()
|
|
|
|
const deleteUpload = async (path?: string, uploadSessionId = sessionId) => {
|
|
if (!path) return
|
|
try {
|
|
await api.deleteUploadFile(uploadSessionId, path)
|
|
} catch {
|
|
// Best effort cleanup
|
|
}
|
|
}
|
|
|
|
return {
|
|
// assistant-ui uses the exact "*" sentinel for an allow-all adapter.
|
|
// "*/*" is forwarded to MIME matching and rejects every file before
|
|
// this adapter's add() method can run.
|
|
accept: '*',
|
|
|
|
async *add({ file }): AsyncGenerator<PendingAttachment> {
|
|
// Upload paths are scoped to the session that created them. An
|
|
// inactive composer may resume into a different session id, so its
|
|
// persisted file must follow the normal resolve/transfer flow and
|
|
// be uploaded again by the resumed composer. Pathless restored
|
|
// metadata still supplies a stable id so draft merge cannot
|
|
// duplicate the same File across persistence passes.
|
|
const restored = getRestoredUploadMetadata(file)
|
|
if (!resolveSessionId && restored?.path) {
|
|
yield {
|
|
id: restored.id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType: file.type || 'application/octet-stream',
|
|
file,
|
|
status: { type: 'requires-action', reason: 'composer-send' },
|
|
path: restored.path,
|
|
previewUrl: restored.previewUrl,
|
|
uploadSessionId: restored.uploadSessionId,
|
|
} as PendingUploadAttachment
|
|
return
|
|
}
|
|
|
|
const id = restored?.id ?? randomId()
|
|
const contentType = file.type || 'application/octet-stream'
|
|
|
|
try {
|
|
let previewUrl: string | undefined
|
|
if (isImageMimeType(contentType) && file.size <= MAX_PREVIEW_BYTES) {
|
|
try {
|
|
previewUrl = await fileToDataUrl(file)
|
|
} catch {
|
|
// Preview generation is optional; retry the read for the upload payload below.
|
|
}
|
|
}
|
|
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'running', reason: 'uploading', progress: 0 },
|
|
previewUrl
|
|
} as PendingUploadAttachment
|
|
|
|
if (cancelledAttachmentIds.has(id)) {
|
|
return
|
|
}
|
|
|
|
if (file.size > MAX_UPLOAD_BYTES) {
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'incomplete', reason: 'error' }
|
|
}
|
|
return
|
|
}
|
|
|
|
const uploadSessionId = resolveSessionId ? await resolveSessionId() : sessionId
|
|
// Resume may already have merged the source session away. Always
|
|
// hand off with a live cancellation predicate so transfer can
|
|
// drop this id (even if already persisted on the source draft).
|
|
if (uploadSessionId !== sessionId && onSessionResolved) {
|
|
await onSessionResolved(uploadSessionId, {
|
|
id,
|
|
file,
|
|
previewUrl,
|
|
isCancelled: () => cancelledAttachmentIds.has(id),
|
|
})
|
|
return
|
|
}
|
|
if (cancelledAttachmentIds.has(id)) {
|
|
return
|
|
}
|
|
|
|
const content = previewUrl
|
|
? base64FromDataUrl(previewUrl)
|
|
: await fileToBase64(file)
|
|
|
|
if (cancelledAttachmentIds.has(id)) {
|
|
return
|
|
}
|
|
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'running', reason: 'uploading', progress: 50 },
|
|
previewUrl
|
|
} as PendingUploadAttachment
|
|
|
|
const result = await api.uploadFile(uploadSessionId, file.name, content, contentType)
|
|
if (cancelledAttachmentIds.has(id)) {
|
|
if (result.success && result.path) {
|
|
await deleteUpload(result.path, uploadSessionId)
|
|
}
|
|
return
|
|
}
|
|
|
|
if (!result.success || !result.path) {
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'incomplete', reason: 'error' }
|
|
}
|
|
return
|
|
}
|
|
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'requires-action', reason: 'composer-send' },
|
|
path: result.path,
|
|
previewUrl,
|
|
uploadSessionId,
|
|
} as PendingUploadAttachment
|
|
|
|
} catch {
|
|
yield {
|
|
id,
|
|
type: 'file',
|
|
name: file.name,
|
|
contentType,
|
|
file,
|
|
status: { type: 'incomplete', reason: 'error' }
|
|
}
|
|
}
|
|
},
|
|
|
|
async remove(attachment: Attachment): Promise<void> {
|
|
cancelledAttachmentIds.add(attachment.id)
|
|
const path = (attachment as PendingUploadAttachment).path
|
|
const uploadSessionId = (attachment as PendingUploadAttachment).uploadSessionId
|
|
await deleteUpload(path, uploadSessionId)
|
|
},
|
|
|
|
async send(attachment: PendingAttachment): Promise<CompleteAttachment> {
|
|
const pending = attachment as PendingUploadAttachment
|
|
const path = pending.path
|
|
|
|
// Build AttachmentMetadata to be sent with the message
|
|
const metadata: AttachmentMetadata | undefined = path ? {
|
|
id: attachment.id,
|
|
filename: attachment.name,
|
|
mimeType: attachment.contentType ?? 'application/octet-stream',
|
|
size: attachment.file?.size ?? 0,
|
|
path,
|
|
previewUrl: pending.previewUrl
|
|
} : undefined
|
|
|
|
return {
|
|
id: attachment.id,
|
|
type: attachment.type,
|
|
name: attachment.name,
|
|
contentType: attachment.contentType,
|
|
status: { type: 'complete' },
|
|
// Store metadata as JSON in the text content for extraction by assistant-runtime
|
|
content: metadata ? [{ type: 'text', text: JSON.stringify({ __attachmentMetadata: metadata }) }] : []
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
async function fileToBase64(file: File): Promise<string> {
|
|
return base64FromDataUrl(await fileToDataUrl(file))
|
|
}
|
|
|
|
function base64FromDataUrl(dataUrl: string): string {
|
|
const separatorIndex = dataUrl.indexOf(',')
|
|
const base64 = separatorIndex >= 0 ? dataUrl.slice(separatorIndex + 1) : ''
|
|
if (!base64) {
|
|
throw new Error('Failed to read file')
|
|
}
|
|
return base64
|
|
}
|
|
|
|
async function fileToDataUrl(file: File): Promise<string> {
|
|
return new Promise((resolve, reject) => {
|
|
const reader = new FileReader()
|
|
reader.onload = () => {
|
|
resolve(reader.result as string)
|
|
}
|
|
reader.onerror = reject
|
|
reader.readAsDataURL(file)
|
|
})
|
|
}
|