Skip to content

Commit 68df783

Browse files
committed
feat: support multimodal studio reference images
1 parent cc86730 commit 68df783

2 files changed

Lines changed: 84 additions & 4 deletions

File tree

frontend/src/studio/components/StudioCommandPanel.tsx

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -112,13 +112,15 @@ export const StudioCommandPanel = forwardRef<StudioCommandPanelHandle, StudioCom
112112
setIsImageModeOpen(true)
113113
return
114114
}
115+
const submittedAttachments = attachments
115116
setInput('')
116-
const runInput = appendStudioReferenceImages(next, attachments)
117+
clearAttachments()
118+
const runInput = appendStudioReferenceImages(next, submittedAttachments)
117119
try {
118120
await onRun(runInput)
119-
clearAttachments()
120121
} catch {
121122
setInput(next)
123+
retainAttachments(submittedAttachments)
122124
}
123125
inputRef.current?.focus()
124126
}

src/studio-agent/orchestration/studio-message-history.ts

Lines changed: 80 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,14 @@
11
import type OpenAI from 'openai'
2+
import type { ReferenceImage, VisionImageDetail } from '../../types'
23
import type { StudioAssistantMessage, StudioMessage, StudioToolPart } from '../domain/types'
34
import {
45
type StudioStoredAssistantPayload,
56
type StudioStoredAssistantToolCall,
67
} from './studio-provider-message'
78

9+
const REFERENCE_IMAGES_START = '[STUDIO_REFERENCE_IMAGES]'
10+
const REFERENCE_IMAGES_END = '[/STUDIO_REFERENCE_IMAGES]'
11+
812
export function buildStudioConversationMessages(input: {
913
messages: StudioMessage[]
1014
}): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
@@ -13,7 +17,7 @@ export function buildStudioConversationMessages(input: {
1317

1418
function toConversationMessages(message: StudioMessage): OpenAI.Chat.Completions.ChatCompletionMessageParam[] {
1519
if (message.role === 'user') {
16-
return [{ role: 'user', content: message.text }]
20+
return [{ role: 'user', content: buildUserMessageContent(message.text) }]
1721
}
1822

1923
if (message.role !== 'assistant') {
@@ -126,7 +130,81 @@ function flattenAssistantMessage(message: Extract<StudioMessage, { role: 'assist
126130
}
127131

128132
return sections.join('\n\n').trim()
129-
}
133+
}
134+
135+
function buildUserMessageContent(
136+
inputText: string,
137+
): string | Array<{ type: 'text'; text: string } | { type: 'image_url'; image_url: { url: string; detail: VisionImageDetail } }> {
138+
const parsed = parseReferenceImagesFromInput(inputText)
139+
if (parsed.referenceImages.length === 0) {
140+
return inputText
141+
}
142+
143+
return [
144+
{
145+
type: 'text',
146+
text: parsed.text || 'Use the provided reference images as context.',
147+
},
148+
...parsed.referenceImages.map((image) => ({
149+
type: 'image_url' as const,
150+
image_url: {
151+
url: image.url,
152+
detail: image.detail || 'auto',
153+
},
154+
})),
155+
]
156+
}
157+
158+
function parseReferenceImagesFromInput(inputText: string): {
159+
text: string
160+
referenceImages: ReferenceImage[]
161+
} {
162+
const startIndex = inputText.indexOf(REFERENCE_IMAGES_START)
163+
const endIndex = inputText.indexOf(REFERENCE_IMAGES_END)
164+
if (startIndex < 0 || endIndex < startIndex) {
165+
return {
166+
text: inputText,
167+
referenceImages: [],
168+
}
169+
}
170+
171+
const before = inputText.slice(0, startIndex).trimEnd()
172+
const after = inputText.slice(endIndex + REFERENCE_IMAGES_END.length).trimStart()
173+
const block = inputText.slice(startIndex + REFERENCE_IMAGES_START.length, endIndex)
174+
175+
return {
176+
text: [before, after].filter(Boolean).join('\n\n'),
177+
referenceImages: extractReferenceImages(block),
178+
}
179+
}
180+
181+
function extractReferenceImages(block: string): ReferenceImage[] {
182+
return block
183+
.split(/\r?\n/)
184+
.map((line) => line.trim())
185+
.filter((line) => line.startsWith('-'))
186+
.map(parseReferenceImageLine)
187+
.filter((image): image is ReferenceImage => Boolean(image))
188+
}
189+
190+
function parseReferenceImageLine(line: string): ReferenceImage | null {
191+
const match = line.match(/^-+\s*[^:]+:\s*(https?:\/\/\S+?)(?:\s+\(detail:\s*(auto|low|high)\))?\s*$/i)
192+
if (!match) {
193+
return null
194+
}
195+
196+
return {
197+
url: match[1],
198+
detail: normalizeDetail(match[2]),
199+
}
200+
}
201+
202+
function normalizeDetail(value: string | undefined): VisionImageDetail {
203+
if (value === 'low' || value === 'high' || value === 'auto') {
204+
return value
205+
}
206+
return 'auto'
207+
}
130208

131209

132210

0 commit comments

Comments
 (0)