mirror of
https://github.com/stablyai/orca.git
synced 2026-10-18 16:03:17 +00:00
Attaching a picture in mobile native chat gave no visible feedback: the image uploaded to the host and its path was pasted into the hidden PTY, so nothing on screen ever showed it was attached. Defer the TUI paste to send time and hold picked images in composer state, rendered as a thumbnail tray above the input (spinner while uploading, X to remove, image-only sends allowed). On send, each ready image's path is pasted bracketed into the agent TUI right before the message text, matching the desktop composer-attachment model. The terminal live-input surface keeps the immediate paste, where the TUI's own input line is the feedback.
90 lines
3.0 KiB
TypeScript
90 lines
3.0 KiB
TypeScript
// Why: import from 'buffer' (the npm polyfill), not 'node:buffer' — Metro
|
|
// can't resolve Node's builtin in a React Native bundle.
|
|
import { Buffer } from 'buffer'
|
|
import * as DocumentPicker from 'expo-document-picker'
|
|
import * as ImagePicker from 'expo-image-picker'
|
|
|
|
export type MobileImageSource = 'library' | 'files'
|
|
|
|
export type PickedMobileImage = {
|
|
// Raw base64 (no data: prefix); fed straight into the existing upload pipeline.
|
|
readonly base64: string
|
|
// Local asset URI (file://…) when the picker provides one — lets the composer
|
|
// render a thumbnail without holding a second base64 copy in memory.
|
|
readonly uri?: string
|
|
}
|
|
|
|
export class ImageLibraryPermissionError extends Error {
|
|
constructor() {
|
|
super('Photo library permission denied')
|
|
this.name = 'ImageLibraryPermissionError'
|
|
}
|
|
}
|
|
|
|
// Why: expo-document-picker returns a file URI, not base64. Read it through
|
|
// fetch + Buffer so we match the base64 contract the upload pipeline expects
|
|
// without pulling in expo-file-system.
|
|
async function readUriAsBase64(uri: string): Promise<string> {
|
|
const response = await fetch(uri)
|
|
const bytes = new Uint8Array(await response.arrayBuffer())
|
|
return Buffer.from(bytes).toString('base64')
|
|
}
|
|
|
|
async function pickFromLibrary(
|
|
requestPermission: typeof ImagePicker.requestMediaLibraryPermissionsAsync = ImagePicker.requestMediaLibraryPermissionsAsync,
|
|
launch: typeof ImagePicker.launchImageLibraryAsync = ImagePicker.launchImageLibraryAsync
|
|
): Promise<PickedMobileImage | null> {
|
|
const permission = await requestPermission()
|
|
// Why: `granted` covers full + limited iOS access; only a hard denial blocks us.
|
|
if (!permission.granted) {
|
|
throw new ImageLibraryPermissionError()
|
|
}
|
|
const result = await launch({
|
|
mediaTypes: ['images'],
|
|
base64: true,
|
|
allowsMultipleSelection: false,
|
|
quality: 1
|
|
})
|
|
if (result.canceled) {
|
|
return null
|
|
}
|
|
const asset = result.assets[0]
|
|
const base64 = asset?.base64 ?? (asset?.uri ? await readUriAsBase64(asset.uri) : null)
|
|
if (!base64) {
|
|
return null
|
|
}
|
|
return { base64, ...(asset?.uri ? { uri: asset.uri } : {}) }
|
|
}
|
|
|
|
async function pickFromFiles(
|
|
launch: typeof DocumentPicker.getDocumentAsync = DocumentPicker.getDocumentAsync
|
|
): Promise<PickedMobileImage | null> {
|
|
const result = await launch({
|
|
type: 'image/*',
|
|
multiple: false,
|
|
copyToCacheDirectory: true
|
|
})
|
|
if (result.canceled) {
|
|
return null
|
|
}
|
|
const asset = result.assets[0]
|
|
if (!asset?.uri) {
|
|
return null
|
|
}
|
|
return { base64: await readUriAsBase64(asset.uri), uri: asset.uri }
|
|
}
|
|
|
|
export async function pickMobileImage(
|
|
source: MobileImageSource,
|
|
deps?: {
|
|
readonly requestLibraryPermission?: typeof ImagePicker.requestMediaLibraryPermissionsAsync
|
|
readonly launchLibrary?: typeof ImagePicker.launchImageLibraryAsync
|
|
readonly launchFiles?: typeof DocumentPicker.getDocumentAsync
|
|
}
|
|
): Promise<PickedMobileImage | null> {
|
|
if (source === 'library') {
|
|
return pickFromLibrary(deps?.requestLibraryPermission, deps?.launchLibrary)
|
|
}
|
|
return pickFromFiles(deps?.launchFiles)
|
|
}
|