Files
orca/src/shared/native-chat-image-transcript-markers.ts
T

91 lines
2.8 KiB
TypeScript

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+\]\s*/
function soleText(message: NativeChatMessage): string | null {
return message.blocks.length === 1 && isTextBlock(message.blocks[0])
? message.blocks[0].text
: null
}
export function imageSourcePathFromText(text: string): string | null {
return text.match(IMAGE_SOURCE_MARKER)?.[1]?.trim() ?? null
}
export function stripImagePromptMarker(text: string): string {
return text.replace(IMAGE_PROMPT_MARKER, '')
}
function stripFirstImagePromptMarker(blocks: readonly NativeChatBlock[]): NativeChatBlock[] {
let stripped = false
const next: NativeChatBlock[] = []
for (const block of blocks) {
if (!stripped && isTextBlock(block)) {
stripped = true
const text = stripImagePromptMarker(block.text)
if (text.trim().length > 0) {
next.push({ ...block, text })
}
continue
}
next.push(block)
}
return next
}
function imagePromptMarkerStartsMessage(message: NativeChatMessage): boolean {
const firstText = message.blocks.find(isTextBlock)
return firstText ? IMAGE_PROMPT_MARKER.test(firstText.text) : false
}
/** Claude records an attached image as two user transcript turns:
* `[Image: source: /path]` and then `[Image #1] prompt`. Merge them back into
* one native turn so the UI keeps the same chip+text shape as the optimistic
* send and does not show raw TUI marker text after a view remount. */
export function normalizeImageTranscriptMessages(
messages: readonly NativeChatMessage[]
): NativeChatMessage[] {
const normalized: NativeChatMessage[] = []
for (let index = 0; index < messages.length; index += 1) {
const message = messages[index]!
if (message.role !== 'user') {
normalized.push(message)
continue
}
const imagePath = imageSourcePathFromText(soleText(message) ?? '')
const next = messages[index + 1]
if (
imagePath &&
next?.role === 'user' &&
next.source === message.source &&
imagePromptMarkerStartsMessage(next)
) {
normalized.push({
...next,
blocks: [
{ type: 'image-ref', path: imagePath },
...stripFirstImagePromptMarker(next.blocks)
]
})
index += 1
continue
}
// A lone `[Image: source: /path]` turn (no following `[Image #1]` prompt —
// e.g. an image sent with no caption) still renders as an image chip rather
// than the raw marker text.
if (imagePath) {
normalized.push({
...message,
blocks: [{ type: 'image-ref', path: imagePath }]
})
continue
}
normalized.push({
...message,
blocks: stripFirstImagePromptMarker(message.blocks)
})
}
return normalized
}