mirror of
https://github.com/stablyai/orca.git
synced 2026-09-22 16:02:32 +00:00
* fix(native-chat): keep the attachments on a Claude turn that pasted images
A Claude turn carrying pasted images reached native chat with no images at all —
no thumbnails on mobile, and not even an attachment chip on desktop. Nothing
showed that the message had any.
Both carriers were being dropped:
- Claude records the paths in a companion turn marked `isMeta`, holding one
`[Image: source: <path>]` text block per image. The decoder treats an `isMeta`
user row as injected, filters it down to tool-result blocks, and returns null
when none remain — so the whole row went away.
- The prompt row's own `image` blocks are `{source: {type: 'base64'}}`, which
carry no url or path, so `imageRefBlock` drops them too.
With the companion gone, `isImageSourceUserTurn` could never fire and the fold in
`normalizeImageTranscriptMessages` was unreachable on the Claude path.
Surveying every transcript under `~/.claude/projects`: 238 of 241 image-source
rows are `isMeta`, across every versioned release (2.1.220 through 2.1.237); the
3 that are not carry no version field at all. 38 of those rows hold more than one
content block, which also defeated the single-block rule in
`isImageSourceUserTurn`.
Let image-source text survive the injected-turn filter, and recognize a turn
whose blocks are *all* markers rather than only a lone one. An ordinary injected
turn (a skill preamble, a compact summary) is still dropped, and a turn that
mixes prose with a marker is still not an image-source turn.
Carrying the paths keeps the payload small; decoding the base64 instead would put
hundreds of KB per image on the wire to mobile.
* fix(native-chat): preserve image companion ordering
* fix(native-chat): keep image companions turn-local
---------
Co-authored-by: Merge Sim <sim@local>
181 lines
6.0 KiB
TypeScript
181 lines
6.0 KiB
TypeScript
import {
|
|
stripAnsiEscapeSequences,
|
|
TERMINAL_CONTROL_CHARACTER_PATTERN
|
|
} from './ansi-escape-sequences'
|
|
import { isTextBlock, type NativeChatBlock, type NativeChatMessage } from './native-chat-types'
|
|
|
|
const IMAGE_SOURCE_MARKER = /^\[Image:\s*source:\s*(.+?)\]\s*$/
|
|
const IMAGE_PROMPT_MARKER = /\[Image #\d+\]/
|
|
const IMAGE_PROMPT_MARKERS = /\[Image #\d+\]/g
|
|
const IMAGE_PROMPT_MARKER_AT_START = /^[^\S\r\n]*\[Image #\d+\]/
|
|
const IMAGE_PROMPT_MARKER_AT_END = /\[Image #\d+\][^\S\r\n]*$/
|
|
const HORIZONTAL_WHITESPACE_START = /^[^\S\r\n]+/
|
|
const HORIZONTAL_WHITESPACE_END = /[^\S\r\n]+$/
|
|
|
|
export function imageSourcePathFromText(text: string): string | null {
|
|
return text.match(IMAGE_SOURCE_MARKER)?.[1]?.trim() ?? null
|
|
}
|
|
|
|
/** Every image-source path a user turn carries, or [] when it is not a pure
|
|
* image-source turn.
|
|
*
|
|
* Why not `soleText`: Claude records a multi-image paste as ONE companion message
|
|
* holding one `[Image: source: ...]` text block per image, so requiring a single
|
|
* block missed every multi-image turn. A turn qualifies only when it is all text
|
|
* and every block is a marker, so a real prompt is never mistaken for one. */
|
|
export function imageSourcePathsFromMessage(message: NativeChatMessage): string[] {
|
|
if (message.role !== 'user' || message.blocks.length === 0) {
|
|
return []
|
|
}
|
|
const paths: string[] = []
|
|
for (const block of message.blocks) {
|
|
if (!isTextBlock(block)) {
|
|
return []
|
|
}
|
|
const path = imageSourcePathFromText(block.text)
|
|
if (path === null) {
|
|
return []
|
|
}
|
|
paths.push(path)
|
|
}
|
|
return paths
|
|
}
|
|
|
|
export function isImageSourceUserTurn(message: NativeChatMessage): boolean {
|
|
return imageSourcePathsFromMessage(message).length > 0
|
|
}
|
|
|
|
export function stripImagePromptMarker(text: string): string {
|
|
const stripped = text.replace(IMAGE_PROMPT_MARKERS, '')
|
|
if (stripped === text) {
|
|
return text
|
|
}
|
|
let result = IMAGE_PROMPT_MARKER_AT_START.test(text)
|
|
? stripped.replace(HORIZONTAL_WHITESPACE_START, '')
|
|
: stripped
|
|
if (IMAGE_PROMPT_MARKER_AT_END.test(text)) {
|
|
result = result.replace(HORIZONTAL_WHITESPACE_END, '')
|
|
}
|
|
return result
|
|
}
|
|
|
|
/** Normalizes PTY-backed user text into the pending-echo comparison key. */
|
|
export function normalizeNativeChatUserText(text: string): string {
|
|
// Strip sequences first so their printable tails cannot survive a lone-control pass.
|
|
return stripImagePromptMarker(
|
|
stripAnsiEscapeSequences(text).replace(TERMINAL_CONTROL_CHARACTER_PATTERN, '')
|
|
)
|
|
.trim()
|
|
.replace(/\s+/g, ' ')
|
|
}
|
|
|
|
export function normalizedNativeChatUserMessageText(message: NativeChatMessage): string | null {
|
|
if (message.role !== 'user') {
|
|
return null
|
|
}
|
|
const normalized = normalizeNativeChatUserText(
|
|
message.blocks
|
|
.filter(isTextBlock)
|
|
.map((block) => block.text)
|
|
.join(' ')
|
|
)
|
|
return normalized || null
|
|
}
|
|
|
|
function stripImagePromptMarkersFromTextBlocks(
|
|
blocks: readonly NativeChatBlock[]
|
|
): NativeChatBlock[] {
|
|
let sawText = false
|
|
let next: NativeChatBlock[] | null = null
|
|
for (let index = 0; index < blocks.length; index += 1) {
|
|
const block = blocks[index]!
|
|
if (!isTextBlock(block)) {
|
|
next?.push(block)
|
|
continue
|
|
}
|
|
const isFirstText = !sawText
|
|
sawText = true
|
|
const text = stripImagePromptMarker(block.text)
|
|
if (!text.trim() && (text !== block.text || isFirstText)) {
|
|
next ??= blocks.slice(0, index)
|
|
continue
|
|
}
|
|
if (text !== block.text) {
|
|
next ??= blocks.slice(0, index)
|
|
next.push({ ...block, text })
|
|
continue
|
|
}
|
|
next?.push(block)
|
|
}
|
|
return next ?? (blocks as NativeChatBlock[])
|
|
}
|
|
|
|
export function hasImagePromptMarker(message: NativeChatMessage): boolean {
|
|
return message.blocks.some((block) => isTextBlock(block) && IMAGE_PROMPT_MARKER.test(block.text))
|
|
}
|
|
|
|
/** Claude records image paths as source turns followed by a prompt carrying
|
|
* image markers. Merge the whole run back into one native user turn. */
|
|
export function normalizeImageTranscriptMessages(
|
|
messages: readonly NativeChatMessage[]
|
|
): NativeChatMessage[] {
|
|
let normalized: NativeChatMessage[] | null = null
|
|
for (let index = 0; index < messages.length; index += 1) {
|
|
const message = messages[index]!
|
|
if (message.role !== 'user') {
|
|
normalized?.push(message)
|
|
continue
|
|
}
|
|
const messageImagePaths = imageSourcePathsFromMessage(message)
|
|
if (messageImagePaths.length > 0) {
|
|
normalized ??= messages.slice(0, index)
|
|
const imagePaths = [...messageImagePaths]
|
|
let nextIndex = index + 1
|
|
while (nextIndex < messages.length) {
|
|
const candidate = messages[nextIndex]!
|
|
const candidatePaths = imageSourcePathsFromMessage(candidate)
|
|
if (
|
|
candidate.role !== 'user' ||
|
|
candidate.source !== message.source ||
|
|
candidatePaths.length === 0
|
|
) {
|
|
break
|
|
}
|
|
imagePaths.push(...candidatePaths)
|
|
nextIndex += 1
|
|
}
|
|
const prompt = messages[nextIndex]
|
|
if (
|
|
prompt?.role === 'user' &&
|
|
prompt.source === message.source &&
|
|
hasImagePromptMarker(prompt)
|
|
) {
|
|
normalized.push({
|
|
...prompt,
|
|
blocks: [
|
|
...imagePaths.map((path) => ({ type: 'image-ref' as const, path })),
|
|
...stripImagePromptMarkersFromTextBlocks(prompt.blocks)
|
|
]
|
|
})
|
|
index = nextIndex
|
|
continue
|
|
}
|
|
// Only THIS turn's paths: `imagePaths` also holds the following source turns the
|
|
// fold scan looked at, and without a prompt to fold into they stay separate turns.
|
|
normalized.push({
|
|
...message,
|
|
blocks: messageImagePaths.map((path) => ({ type: 'image-ref' as const, path }))
|
|
})
|
|
continue
|
|
}
|
|
const blocks = stripImagePromptMarkersFromTextBlocks(message.blocks)
|
|
if (blocks === message.blocks) {
|
|
normalized?.push(message)
|
|
} else {
|
|
normalized ??= messages.slice(0, index)
|
|
normalized.push({ ...message, blocks })
|
|
}
|
|
}
|
|
return normalized ?? (messages as NativeChatMessage[])
|
|
}
|