fix(desktop): restore visual selection history cards

Restore visual-selection prompts from persisted transcript history as annotated screenshot attachments instead of exposing the model-facing selector prompt after reopening a session.

Tested: cd desktop && bun run test -- --run src/stores/chatStore.test.ts -t "restores visual selection history" --reporter verbose
Tested: bun run check:desktop
Confidence: high
Scope-risk: narrow
This commit is contained in:
程序员阿江(Relakkes) 2026-06-11 00:36:39 +08:00
parent 5dfddd1132
commit cf7e48e540
2 changed files with 112 additions and 2 deletions

View File

@ -567,6 +567,56 @@ describe('chatStore history mapping', () => {
])
})
it('restores visual selection history as annotated screenshot attachment without exposing model prompt', () => {
const modelPrompt = [
'请根据截图中编号 1 的蓝色标注修改本地前端。',
'目标元素:<time>',
'Selector#root > main > section > ol > li:nth-of-type(1) > article > div:nth-of-type(1) > time',
'DOM 路径body:nth-child(2) > div:nth-child(1) > main:nth-child(1) > section:nth-child(1) > ol:nth-child(4) > li:nth-child(1) > article:nth-child(1) > div:nth-child(3) > time:nth-child(2)',
'页面标题Todo Desk Board',
'页面 URLhttp://127.0.0.1:47931/',
'当前文本06/10 21:12',
'用户注释:',
'这里的时间加上年份',
'请优先依据截图里的编号标注定位元素selector 只作为辅助线索。',
].join('\n')
const mapped = mapHistoryMessagesToUiMessages([
{
id: 'selection-user-1',
type: 'user',
timestamp: '2026-06-10T16:20:00.000Z',
content: [
{ type: 'text', text: modelPrompt },
{
type: 'image',
source: {
media_type: 'image/png',
data: 'SELECTIONPNG',
},
},
],
} as MessageEntry,
])
expect(mapped).toMatchObject([
{
id: 'selection-user-1',
type: 'user_text',
content: '',
modelContent: modelPrompt,
attachments: [{
type: 'image',
name: '<time>',
data: 'data:image/png;base64,SELECTIONPNG',
mimeType: 'image/png',
note: '这里的时间加上年份',
quote: '#root > main > section > ol > li:nth-of-type(1) > article > div:nth-of-type(1) > time',
}],
},
])
})
it('restores /goal local command output from transcript history', () => {
const messages: MessageEntry[] = [
{

View File

@ -1955,6 +1955,14 @@ function isTeammateMessage(text: string): boolean {
const SIMPLE_IMAGE_SOURCE_RE = /^\[Image source: (.+)\]$/
const DETAILED_IMAGE_SOURCE_RE = /^\[Image: source: (.+?)(?:, original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.)?\]$/
const IMAGE_RESIZE_METADATA_RE = /^\[Image: original \d+x\d+, displayed at \d+x\d+\. Multiply coordinates by \d+(?:\.\d+)? to map to original image\.\]$/
const VISUAL_SELECTION_PROMPT_HEADER = '请根据截图中编号 1 的蓝色标注修改本地前端。'
const VISUAL_SELECTION_PROMPT_FOOTER = '请优先依据截图里的编号标注定位元素selector 只作为辅助线索。'
type VisualSelectionHistoryDisplay = {
displayName: string
selector?: string
note?: string
}
function getHistoryImageMediaType(block: UserHistoryBlock): string {
const mediaType = block.source?.media_type ?? block.mimeType ?? block.media_type
@ -1981,6 +1989,50 @@ function isGeneratedImageMetadataText(text: string): boolean {
return Boolean(extractImageMetadataSourcePath(text)) || IMAGE_RESIZE_METADATA_RE.test(text.trim())
}
function parseVisualSelectionHistoryPrompt(text: string): VisualSelectionHistoryDisplay | null {
const lines = text.replace(/\r\n?/g, '\n').split('\n')
if (lines[0]?.trim() !== VISUAL_SELECTION_PROMPT_HEADER) return null
let displayName: string | undefined
let selector: string | undefined
let note: string | undefined
for (let index = 1; index < lines.length; index += 1) {
const line = lines[index]?.trim() ?? ''
if (line.startsWith('目标元素:')) {
displayName = line.slice('目标元素:'.length).trim()
} else if (line.startsWith('Selector')) {
selector = line.slice('Selector'.length).trim()
} else if (line === '用户注释:') {
const noteLines: string[] = []
for (let noteIndex = index + 1; noteIndex < lines.length; noteIndex += 1) {
const noteLine = lines[noteIndex] ?? ''
if (noteLine.trim() === VISUAL_SELECTION_PROMPT_FOOTER) break
noteLines.push(noteLine)
}
const trimmedNote = noteLines.join('\n').trim()
note = trimmedNote || undefined
break
}
}
return displayName
? {
displayName,
...(selector ? { selector } : {}),
...(note ? { note } : {}),
}
: null
}
function applyVisualSelectionHistoryDisplay(attachments: UIAttachment[], display: VisualSelectionHistoryDisplay): void {
const imageAttachment = attachments.find((attachment) => attachment.type === 'image')
if (!imageAttachment) return
imageAttachment.name = display.displayName
if (display.selector) imageAttachment.quote = display.selector
if (display.note) imageAttachment.note = display.note
}
function normalizeHistoryImageAttachment(block: UserHistoryBlock): UIAttachment {
const mediaType = getHistoryImageMediaType(block)
return {
@ -2745,13 +2797,21 @@ export function mapHistoryMessagesToUiMessages(
if (visibleTextParts.length > 0 || attachments.length > 0) {
const visibleText = visibleTextParts.join('\n')
const modelText = modelTextParts.join('\n')
const visualSelectionDisplay =
msg.type === 'user' && hasImageBlock
? parseVisualSelectionHistoryPrompt(modelText)
: null
if (visualSelectionDisplay) {
applyVisualSelectionHistoryDisplay(attachments, visualSelectionDisplay)
}
const parsed = extractLeadingFileReferences(visibleText)
const modelContent = modelText !== visibleText ? modelText : parsed.modelContent
const userContent = visualSelectionDisplay ? '' : parsed.content
const modelContent = visualSelectionDisplay || modelText !== visibleText ? modelText : parsed.modelContent
const allAttachments = [...(parsed.attachments ?? []), ...attachments]
uiMessages.push({
id: msg.id || nextId(),
type: 'user_text',
content: parsed.content,
content: userContent,
...(msg.id ? { transcriptMessageId: msg.id } : {}),
...(modelContent ? { modelContent } : {}),
attachments: allAttachments.length > 0 ? allAttachments : undefined,