mirror of
https://github.com/stablyai/orca.git
synced 2026-11-13 17:37:40 +00:00
fix(mobile): send iPhone library photos as JPEG named .jpg, not HEIC named .png (#36989)
* fix(mobile): save iPhone library photos as JPEG named by their bytes The iOS photo picker handed back the asset's HEIC bytes as-is, and the host named every pasted image .png, so agents read a HEIC file as a PNG and failed. Ask the picker for the compatible representation (JPEG), and name pasted images by their signature so the extension always matches the bytes. * fix(mobile): keep unknown pasted bytes on the .png name A .heic name falls outside the shared image MIME table, so chat previews and agent prompts would drop the image. The picker now sends JPEG, so leave HEIC detection out. * refactor(mobile): keep the pasted image extension list module-private
This commit is contained in:
@@ -22,7 +22,7 @@ export function mobilePickedFileFallbackName(mime: string, index: number): strin
|
||||
return extension ? `attachment-${index}.${extension}` : `attachment-${index}`
|
||||
}
|
||||
|
||||
// Images keep the host's orca-paste-*.png name, which transcript readers recognize as a pasted image.
|
||||
// Images take the host's orca-paste-* name, which transcript readers recognize as a pasted image.
|
||||
export function isPickedImageMime(mime: string | null | undefined): boolean {
|
||||
return mime?.trim().toLowerCase().startsWith('image/') ?? false
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ const expo = vi.hoisted(() => ({
|
||||
vi.mock('expo-clipboard', () => ({ getImageAsync: expo.getImageAsync }))
|
||||
vi.mock('expo-document-picker', () => ({ getDocumentAsync: expo.getDocumentAsync }))
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
|
||||
launchImageLibraryAsync: expo.launchImageLibraryAsync,
|
||||
requestMediaLibraryPermissionsAsync: expo.requestMediaLibraryPermissionsAsync
|
||||
}))
|
||||
@@ -92,7 +93,9 @@ describe('how the pickers are launched', () => {
|
||||
expect.objectContaining({
|
||||
mediaTypes: ['images'],
|
||||
base64: false,
|
||||
allowsMultipleSelection: false
|
||||
allowsMultipleSelection: false,
|
||||
// Agents can't read the HEIC iOS hands back by default.
|
||||
preferredAssetRepresentationMode: 'compatible'
|
||||
})
|
||||
)
|
||||
// Omit selectionLimit for single picks; zero means unlimited.
|
||||
|
||||
@@ -51,7 +51,10 @@ export function nativeMediaDeviceDeps(registry: MediaHandleRegistry): NativeMedi
|
||||
base64: false,
|
||||
allowsMultipleSelection: multiple,
|
||||
...(multiple ? { selectionLimit: limit, orderedSelection: true } : {}),
|
||||
quality: 1
|
||||
quality: 1,
|
||||
// iOS otherwise hands back HEIC as-is, which agents can't read; Compatible gives JPEG.
|
||||
preferredAssetRepresentationMode:
|
||||
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
|
||||
}),
|
||||
// DocumentPicker has no selection cap; enforce remaining room again before staging and minting.
|
||||
launchFiles: ({ multiple }) =>
|
||||
|
||||
@@ -44,6 +44,7 @@ vi.mock('expo-keep-awake', () => ({
|
||||
}
|
||||
}))
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
|
||||
launchImageLibraryAsync: device.picker.launchImageLibraryAsync
|
||||
}))
|
||||
vi.mock('expo-file-system', () => ({
|
||||
|
||||
@@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, vi } from 'vitest'
|
||||
import { CLIPBOARD_IMAGE_MAX_SOURCE_BYTES } from '../../../src/shared/clipboard-image'
|
||||
|
||||
vi.mock('expo-image-picker', () => ({
|
||||
UIImagePickerPreferredAssetRepresentationMode: { Compatible: 'compatible' },
|
||||
requestMediaLibraryPermissionsAsync: vi.fn(),
|
||||
launchImageLibraryAsync: vi.fn()
|
||||
}))
|
||||
@@ -78,7 +79,9 @@ describe('pickMobileImage', () => {
|
||||
base64: Buffer.from(bytes).toString('base64'),
|
||||
uri: 'file:///x.jpg'
|
||||
})
|
||||
expect(launchLibrary).toHaveBeenCalledWith(expect.objectContaining({ base64: false }))
|
||||
expect(launchLibrary).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ base64: false, preferredAssetRepresentationMode: 'compatible' })
|
||||
)
|
||||
expect(fetchSpy).not.toHaveBeenCalled()
|
||||
expect(file.close).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
@@ -90,7 +90,10 @@ async function* pickFromLibrary(
|
||||
base64: false,
|
||||
allowsMultipleSelection: multiple,
|
||||
...(multiple ? { selectionLimit: 0, orderedSelection: true } : {}),
|
||||
quality: 1
|
||||
quality: 1,
|
||||
// iOS otherwise hands back HEIC as-is, which agents can't read; Compatible gives JPEG.
|
||||
preferredAssetRepresentationMode:
|
||||
ImagePicker.UIImagePickerPreferredAssetRepresentationMode.Compatible
|
||||
})
|
||||
if (result.canceled) {
|
||||
return
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
timestampMs
|
||||
} from '../ai-vault/session-scanner-values'
|
||||
import { claudeContentBlocks, toolResultOutput } from './transcript-record-blocks'
|
||||
import { AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE } from '../../shared/agent-session-attachments'
|
||||
|
||||
export function decodeGrokTranscriptLine(
|
||||
line: string,
|
||||
@@ -209,11 +210,16 @@ function normalizeGrokUserQueryBlock(block: NativeChatBlock): NativeChatBlock[]
|
||||
]
|
||||
}
|
||||
|
||||
const GROK_PASTED_IMAGE_QUERY = new RegExp(
|
||||
String.raw`^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?` +
|
||||
AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE +
|
||||
String.raw`)([\s\S]*)$`,
|
||||
'i'
|
||||
)
|
||||
|
||||
function splitGrokPastedImageQuery(text: string): { path: string; query: string } | null {
|
||||
// Grok 0.2.93 concatenates clipboard temp paths with prompts; recover the attachment separately.
|
||||
const match = text.match(
|
||||
/^((?:[a-z]:[\\/]|\/|[\\/]{2}[^\\/\r\n]+[\\/][^\\/\r\n]+[\\/])(?:.*?[\\/])?orca-paste-[^\\/\r\n]+?\.png)([\s\S]*)$/i
|
||||
)
|
||||
const match = text.match(GROK_PASTED_IMAGE_QUERY)
|
||||
if (!match?.[1]) {
|
||||
return null
|
||||
}
|
||||
|
||||
@@ -36,6 +36,7 @@ describe('decodeGrokTranscriptLine', () => {
|
||||
it.each([
|
||||
'C:\\Users\\me\\AppData\\Local\\Temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
|
||||
'/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
|
||||
'/tmp/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.jpg',
|
||||
'C:\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
|
||||
'/orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png',
|
||||
'\\\\server\\temp\\orca-paste-1783675302563-2207c073-535f-4b83-a181-61127c8bbd68.png'
|
||||
|
||||
@@ -32,6 +32,13 @@ describe('saveClipboardImageBufferAsTempFile', () => {
|
||||
expect(writeFileMock).toHaveBeenCalledWith(savedPath, Buffer.from([1, 2, 3]))
|
||||
})
|
||||
|
||||
it('names a JPEG phone pick by its bytes, not as a .png', async () => {
|
||||
const jpeg = Buffer.from([0xff, 0xd8, 0xff, 0xe0, 0, 0x10, 0x4a, 0x46, 0x49, 0x46, 0, 1])
|
||||
const savedPath = await saveClipboardImageBufferAsTempFile(jpeg)
|
||||
|
||||
expect(basename(savedPath)).toMatch(/^orca-paste-\d+-uuid-1\.jpg$/)
|
||||
})
|
||||
|
||||
it('writes a terminal-backed composer paste into the never-swept paste folder', async () => {
|
||||
const savedPath = await saveClipboardImageBufferAsTempFile(Buffer.from([1, 2, 3]), {
|
||||
forNativeChatDraft: true
|
||||
|
||||
@@ -10,11 +10,12 @@ import {
|
||||
sanitizeAgentSessionAttachmentName,
|
||||
type AgentSessionAttachmentClipboardTarget
|
||||
} from '../../shared/agent-session-attachments'
|
||||
import { detectImageFileSignature } from '../image-file-signature'
|
||||
import { nativeChatPasteFolder } from './native-chat-paste-files'
|
||||
|
||||
export type SaveClipboardImageAsTempFileArgs = {
|
||||
connectionId?: string | null
|
||||
/** A phone-picked file's own name; without it the paste is saved as a .png. */
|
||||
/** A phone-picked file's own name; without it the paste is named by its image bytes. */
|
||||
fileName?: string
|
||||
runtimeEnvironmentId?: string | null
|
||||
/** With `runtimeEnvironmentId`: store the image as an attachment of this structured chat. */
|
||||
@@ -49,7 +50,15 @@ export async function saveClipboardImageBufferAsTempFile(
|
||||
// A picked file keeps its own name, so the paste gets a folder to stay unique.
|
||||
const tempPath = args?.fileName
|
||||
? path.join(folder, `orca-paste-${randomUUID()}`, shellInertFileName(args.fileName))
|
||||
: path.join(folder, agentSessionPastedImageName(Date.now(), randomUUID()))
|
||||
: path.join(
|
||||
folder,
|
||||
// Phone library picks are often JPEG; agents trust the extension over the bytes.
|
||||
agentSessionPastedImageName(
|
||||
Date.now(),
|
||||
randomUUID(),
|
||||
detectImageFileSignature(buffer)?.extension
|
||||
)
|
||||
)
|
||||
if (args?.fileName) {
|
||||
await fs.mkdir(path.dirname(tempPath), { recursive: true })
|
||||
}
|
||||
|
||||
@@ -122,9 +122,20 @@ export type AgentSessionAttachmentClipboardTarget = Omit<
|
||||
'environmentId'
|
||||
>
|
||||
|
||||
/** `orca-paste-…png`, the name every pasted image gets, so it reads as "Pasted image". */
|
||||
export function agentSessionPastedImageName(now: number, unique: string): string {
|
||||
return `orca-paste-${now}-${unique}.png`
|
||||
/** Extensions a pasted image's name can carry; the host picks one from the image's bytes. */
|
||||
const AGENT_SESSION_PASTED_IMAGE_EXTENSIONS = ['png', 'jpg', 'gif', 'webp'] as const
|
||||
type AgentSessionPastedImageExtension = (typeof AGENT_SESSION_PASTED_IMAGE_EXTENSIONS)[number]
|
||||
|
||||
/** Matches the `.png`, `.jpg`… suffix of a pasted image's name. */
|
||||
export const AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE = `\\.(?:${AGENT_SESSION_PASTED_IMAGE_EXTENSIONS.join('|')})`
|
||||
|
||||
/** `orca-paste-…<ext>`, the name every pasted image gets, so it reads as "Pasted image". */
|
||||
export function agentSessionPastedImageName(
|
||||
now: number,
|
||||
unique: string,
|
||||
extension: AgentSessionPastedImageExtension = 'png'
|
||||
): string {
|
||||
return `orca-paste-${now}-${unique}.${extension}`
|
||||
}
|
||||
|
||||
/** `orca-paste-…txt`, the name a long text paste saved as a file gets, so it reads as "Pasted text". */
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE } from './agent-session-attachments'
|
||||
import { getRuntimePathBasename } from './cross-platform-path'
|
||||
import type { NativeChatImageRefBlock } from './native-chat/native-chat-types'
|
||||
|
||||
@@ -11,10 +12,15 @@ export const NATIVE_CHAT_IMAGE_LABEL_ENGLISH: NativeChatImageLabelCopy = {
|
||||
imageCount: (count) => `${count} images`
|
||||
}
|
||||
|
||||
const PASTED_IMAGE_NAME = new RegExp(
|
||||
`^orca-paste-.+${AGENT_SESSION_PASTED_IMAGE_SUFFIX_SOURCE}$`,
|
||||
'i'
|
||||
)
|
||||
|
||||
/** Use friendly UI labels for Orca clipboard-image temp filenames. */
|
||||
export function isNativeChatPastedImagePath(path: string): boolean {
|
||||
const base = path.split(/[\\/]/).findLast(Boolean) ?? path
|
||||
return /^orca-paste-.+\.png$/i.test(base)
|
||||
return PASTED_IMAGE_NAME.test(base)
|
||||
}
|
||||
|
||||
/** What a chat image is called where it is not drawn: its alt text, else its file's name. */
|
||||
|
||||
Reference in New Issue
Block a user