diff --git a/app/api/v1/public_api/imagine.py b/app/api/v1/public_api/imagine.py index 1816fb5..b234c02 100644 --- a/app/api/v1/public_api/imagine.py +++ b/app/api/v1/public_api/imagine.py @@ -234,6 +234,31 @@ def _normalize_image_input(image_base64: str, image_url: str) -> str: ) +def _normalize_image_references(image_references: Optional[List[str]]) -> List[str]: + refs = image_references or [] + if not isinstance(refs, list): + raise HTTPException(status_code=400, detail="image_references must be a list") + + cleaned: List[str] = [] + for raw in refs: + item = str(raw or "").strip() + if not item: + continue + cleaned.append(item) + + if len(cleaned) > 3: + raise HTTPException(status_code=400, detail="image_references supports at most 3 images") + + normalized: List[str] = [] + for item in cleaned: + if item.startswith("data:"): + normalized.append(_normalize_image_input(image_base64=item, image_url="")) + else: + normalized.append(_normalize_image_input(image_base64="", image_url=item)) + + return normalized + + async def _clean_sessions(now: float) -> None: expired = [ key @@ -992,6 +1017,7 @@ class ImagineWorkbenchEditRequest(BaseModel): source_image_url: Optional[str] = None image_base64: Optional[str] = None image_url: Optional[str] = None + image_references: Optional[List[str]] = None stream: Optional[bool] = False @@ -1062,16 +1088,19 @@ async def public_imagine_workbench_edit(data: ImagineWorkbenchEditRequest, reque ) mode = "parent_post" else: - image_input = _normalize_image_input( - image_base64=str(data.image_base64 or ""), - image_url=str(data.image_url or ""), - ) + image_inputs = _normalize_image_references(data.image_references) + if not image_inputs: + image_input = _normalize_image_input( + image_base64=str(data.image_base64 or ""), + image_url=str(data.image_url or ""), + ) + image_inputs = [image_input] result = await edit_service.edit( token_mgr=token_mgr, token=token, model_info=model_info, prompt=prompt, - images=[image_input], + images=image_inputs, n=1, response_format="url", stream=False, diff --git a/app/services/grok/services/image_edit.py b/app/services/grok/services/image_edit.py index 8d1df16..7085fbf 100644 --- a/app/services/grok/services/image_edit.py +++ b/app/services/grok/services/image_edit.py @@ -89,6 +89,18 @@ def _is_upload_network_error(exc: Exception) -> bool: return False +def _normalize_fallback_image_url(url: str) -> str: + """下载失败时的兜底 URL 规范化。""" + raw = str(url or "").strip() + if not raw: + return "" + if raw.startswith("http://") or raw.startswith("https://"): + return raw + if raw.startswith("/"): + return f"https://assets.grok.com{raw}" + return f"https://assets.grok.com/{raw}" + + class ImageEditService: """Image edit orchestration service.""" @@ -652,7 +664,14 @@ class ImageStreamProcessor(BaseProcessor): if urls := _collect_images(mr): for url in urls: if self.response_format == "url": - processed = await self.process_url(url, "image") + try: + processed = await self.process_url(url, "image") + except Exception as e: + logger.warning( + "Image stream URL resolve failed, fallback to raw URL: " + f"error={e}" + ) + processed = _normalize_fallback_image_url(url) if processed: final_images.append(processed) continue @@ -797,7 +816,14 @@ class ImageCollectProcessor(BaseProcessor): if urls := _collect_images(mr): for url in urls: if self.response_format == "url": - processed = await self.process_url(url, "image") + try: + processed = await self.process_url(url, "image") + except Exception as e: + logger.warning( + "Image collect URL resolve failed, fallback to raw URL: " + f"error={e}" + ) + processed = _normalize_fallback_image_url(url) if processed: images.append(processed) progress = min(90, 64 + len(images) * 12) diff --git a/app/static/public/css/imagine_workbench.css b/app/static/public/css/imagine_workbench.css index 40c755e..a9981c3 100644 --- a/app/static/public/css/imagine_workbench.css +++ b/app/static/public/css/imagine_workbench.css @@ -91,6 +91,89 @@ body { flex-wrap: wrap; } +.reference-strip { + margin-top: 10px; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + min-height: 44px; +} + +.reference-empty { + font-size: 12px; + color: var(--workbench-muted); +} + +.reference-item { + width: 64px; + height: 64px; + border-radius: 12px; + border: 1px solid var(--workbench-border); + background: #f3f4f6; + overflow: hidden; + position: relative; + cursor: pointer; +} + +.reference-item.is-primary { + box-shadow: 0 0 0 2px rgba(59, 130, 246, 0.35); + border-color: #60a5fa; +} + +.reference-thumb { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.reference-primary-badge { + position: absolute; + left: 4px; + bottom: 4px; + font-size: 10px; + line-height: 1; + padding: 3px 5px; + border-radius: 999px; + background: rgba(15, 23, 42, 0.78); + color: #fff; + pointer-events: none; +} + +.reference-remove-btn { + position: absolute; + top: 4px; + right: 4px; + width: 18px; + height: 18px; + border: none; + border-radius: 999px; + background: rgba(15, 23, 42, 0.78); + color: #fff; + font-size: 12px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + +.reference-add-slot { + width: 64px; + height: 64px; + border-radius: 12px; + border: 1px dashed var(--workbench-border); + background: #fff; + color: var(--workbench-muted); + font-size: 22px; + line-height: 1; + display: inline-flex; + align-items: center; + justify-content: center; + cursor: pointer; +} + .parent-quick-row { margin-top: 10px; } @@ -110,7 +193,7 @@ body { .file-name { font-size: 12px; color: var(--workbench-muted); - word-break: break-all; + word-break: normal; } .preview-shell { @@ -428,6 +511,17 @@ html[data-theme='dark'] .history-item { border-color: #324154; } +html[data-theme='dark'] .reference-item { + background: #111821; + border-color: #324154; +} + +html[data-theme='dark'] .reference-add-slot { + background: #111821; + border-color: #324154; + color: #b7c4d4; +} + html[data-theme='dark'] .history-line { color: #b7c4d4; } diff --git a/app/static/public/js/imagine_workbench.js b/app/static/public/js/imagine_workbench.js index 91a0c8f..2c920a1 100644 --- a/app/static/public/js/imagine_workbench.js +++ b/app/static/public/js/imagine_workbench.js @@ -2,6 +2,7 @@ const seedImageInput = document.getElementById('seedImageInput'); const selectSeedBtn = document.getElementById('selectSeedBtn'); const seedFileName = document.getElementById('seedFileName'); + const referenceStrip = document.getElementById('referenceStrip'); const parentPostInput = document.getElementById('parentPostInput'); const applyParentBtn = document.getElementById('applyParentBtn'); const previewShell = document.getElementById('previewShell'); @@ -20,11 +21,11 @@ const editProgressWrap = document.getElementById('editProgressWrap'); const editProgressBar = document.getElementById('editProgressBar'); const editProgressText = document.getElementById('editProgressText'); + const REFERENCE_LIMIT = 3; const state = { editing: false, - seedImageBase64: '', - seedFileName: '', + referenceImages: [], currentImageUrl: '', currentParentPostId: '', currentSourceImageUrl: '', @@ -317,29 +318,214 @@ }); } - async function applySeedImageFile(file, source) { - if (!file) return; - if (state.editing) { - toast('编辑进行中,暂时不能替换首图', 'warning'); + function buildRefId() { + return `ref_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`; + } + + function getPrimaryReference() { + if (!state.referenceImages.length) return null; + return state.referenceImages.find((item) => item.isPrimary) || state.referenceImages[0]; + } + + function normalizeReferenceOrder() { + if (!state.referenceImages.length) return; + const primary = getPrimaryReference(); + const primaryId = primary ? primary.id : ''; + state.referenceImages = state.referenceImages.map((item) => ({ + ...item, + isPrimary: item.id === primaryId, + })); + state.referenceImages.sort((a, b) => { + if (a.isPrimary && !b.isPrimary) return -1; + if (!a.isPrimary && b.isPrimary) return 1; + return a.createdAt - b.createdAt; + }); + } + + function updateReferenceSummary() { + if (!seedFileName) return; + seedFileName.textContent = `已添加 ${state.referenceImages.length}/${REFERENCE_LIMIT} 张`; + } + + function renderReferenceStrip() { + if (!referenceStrip) return; + referenceStrip.innerHTML = ''; + if (!state.referenceImages.length) { + const empty = document.createElement('div'); + empty.className = 'reference-empty'; + empty.textContent = '可上传 / 粘贴 / 拖拽参考图(最多 3 张)'; + referenceStrip.appendChild(empty); + updateReferenceSummary(); return; } - const mimeType = String(file.type || ''); - if (mimeType && !mimeType.startsWith('image/')) { - toast('仅支持图片文件', 'warning'); - return; - } - const dataUrl = await readFileAsDataUrl(file); - if (!dataUrl.startsWith('data:image/')) { - throw new Error('图片格式不受支持'); - } - state.seedImageBase64 = dataUrl; - state.seedFileName = file.name || source || '未命名图片'; - clearHistory(); - resetCycle(true); - setStatus('', '已载入首图,可开始编辑'); - updateMeta(); - toast(`${source || '图片'}已载入,链路已重置`, 'success'); + state.referenceImages.forEach((item) => { + const card = document.createElement('div'); + card.className = `reference-item${item.isPrimary ? ' is-primary' : ''}`; + card.title = item.isPrimary ? `主图:${item.name}` : `设为主图:${item.name}`; + card.dataset.id = item.id; + card.setAttribute('role', 'button'); + card.setAttribute('tabindex', '0'); + + const img = document.createElement('img'); + img.className = 'reference-thumb'; + img.src = item.data; + img.alt = item.name || 'reference'; + img.loading = 'lazy'; + card.appendChild(img); + + if (item.isPrimary) { + const badge = document.createElement('div'); + badge.className = 'reference-primary-badge'; + badge.textContent = '主图'; + card.appendChild(badge); + } + + const removeBtn = document.createElement('button'); + removeBtn.type = 'button'; + removeBtn.className = 'reference-remove-btn'; + removeBtn.textContent = '×'; + removeBtn.title = '删除'; + removeBtn.dataset.id = item.id; + removeBtn.addEventListener('click', (event) => { + event.preventDefault(); + event.stopPropagation(); + removeReferenceImage(item.id); + }); + card.appendChild(removeBtn); + + card.addEventListener('click', () => { + setPrimaryReference(item.id); + }); + card.addEventListener('keydown', (event) => { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault(); + setPrimaryReference(item.id); + } + }); + referenceStrip.appendChild(card); + }); + + if (state.referenceImages.length < REFERENCE_LIMIT && seedImageInput) { + const addSlot = document.createElement('button'); + addSlot.type = 'button'; + addSlot.className = 'reference-add-slot'; + addSlot.title = '继续添加'; + addSlot.textContent = '+'; + addSlot.addEventListener('click', () => seedImageInput.click()); + referenceStrip.appendChild(addSlot); + } + updateReferenceSummary(); + } + + function setPrimaryReference(id) { + if (!id) return; + let changed = false; + let primaryData = ''; + state.referenceImages = state.referenceImages.map((item) => { + const isPrimary = item.id === id; + if (isPrimary) { + primaryData = String(item.data || ''); + } + const next = { ...item, isPrimary }; + if (next.isPrimary !== item.isPrimary) changed = true; + return next; + }); + if (!changed) return; + normalizeReferenceOrder(); + renderReferenceStrip(); + if (!state.currentParentPostId && primaryData) { + setPreview(primaryData); + setStatus('', '已切换主图'); + } + } + + function removeReferenceImage(id) { + const before = state.referenceImages.length; + state.referenceImages = state.referenceImages.filter((item) => item.id !== id); + if (state.referenceImages.length === before) return; + normalizeReferenceOrder(); + renderReferenceStrip(); + if (!state.referenceImages.length) { + setStatus('', '参考图已清空'); + } + } + + function clearReferenceImages() { + state.referenceImages = []; + if (seedImageInput) seedImageInput.value = ''; + renderReferenceStrip(); + } + + function pickImageFilesFromDataTransfer(dataTransfer) { + if (!dataTransfer) return []; + const files = []; + const pushIfImage = (file) => { + if (!file) return; + if (!String(file.type || '').startsWith('image/')) return; + files.push(file); + }; + if (dataTransfer.files && dataTransfer.files.length) { + Array.from(dataTransfer.files).forEach(pushIfImage); + } + if (!files.length && dataTransfer.items && dataTransfer.items.length) { + Array.from(dataTransfer.items).forEach((item) => { + if (!item || item.kind !== 'file') return; + const file = item.getAsFile ? item.getAsFile() : null; + pushIfImage(file); + }); + } + return files; + } + + async function addReferenceFiles(files, source) { + if (!Array.isArray(files) || !files.length) return 0; + if (state.editing) { + toast('编辑进行中,暂时不能修改参考图', 'warning'); + return 0; + } + + const slotsLeft = Math.max(0, REFERENCE_LIMIT - state.referenceImages.length); + if (slotsLeft <= 0) { + toast(`最多支持 ${REFERENCE_LIMIT} 张参考图`, 'warning'); + return 0; + } + const targets = files.slice(0, slotsLeft); + const ignoredCount = Math.max(0, files.length - targets.length); + + let added = 0; + for (const file of targets) { + const mimeType = String(file.type || ''); + if (mimeType && !mimeType.startsWith('image/')) continue; + const dataUrl = await readFileAsDataUrl(file); + if (!dataUrl.startsWith('data:image/')) continue; + state.referenceImages.push({ + id: buildRefId(), + name: file.name || source || '未命名图片', + mime: file.type || '', + data: dataUrl, + source: source || 'upload', + isPrimary: false, + createdAt: Date.now() + added, + }); + added += 1; + } + if (added > 0 && !state.referenceImages.some((item) => item.isPrimary)) { + state.referenceImages[0].isPrimary = true; + } + normalizeReferenceOrder(); + renderReferenceStrip(); + + if (added > 0) { + clearHistory(); + resetCycle(false); + setStatus('', '已载入参考图,可开始编辑'); + } + + if (ignoredCount > 0) { + toast(`最多支持 ${REFERENCE_LIMIT} 张参考图,已忽略超出部分`, 'warning'); + } + return added; } function setDragActive(active) { @@ -347,29 +533,6 @@ previewShell.classList.toggle('dragover', Boolean(active)); } - function pickImageFileFromDataTransfer(dataTransfer) { - if (!dataTransfer) return null; - if (dataTransfer.files && dataTransfer.files.length) { - for (const file of dataTransfer.files) { - if (file && String(file.type || '').startsWith('image/')) { - return file; - } - } - } - if (dataTransfer.items && dataTransfer.items.length) { - for (const item of dataTransfer.items) { - if (!item) continue; - if (item.kind === 'file') { - const file = item.getAsFile ? item.getAsFile() : null; - if (file && String(file.type || '').startsWith('image/')) { - return file; - } - } - } - } - return null; - } - function hasFiles(dataTransfer) { if (!dataTransfer) return false; if (dataTransfer.files && dataTransfer.files.length > 0) return true; @@ -385,9 +548,7 @@ if (currentMode) { currentMode.textContent = state.currentModeValue || '-'; } - if (seedFileName) { - seedFileName.textContent = state.seedFileName || '未选择文件'; - } + updateReferenceSummary(); } function setPreview(url) { @@ -544,8 +705,9 @@ } updateMeta(); - if (keepSeedPreview && state.seedImageBase64) { - setPreview(state.seedImageBase64); + const primary = getPrimaryReference(); + if (keepSeedPreview && primary && primary.data) { + setPreview(primary.data); } else { setPreview(''); } @@ -691,8 +853,8 @@ return; } - if (!state.currentParentPostId && !state.seedImageBase64) { - toast('请先上传首图', 'warning'); + if (!state.currentParentPostId && !state.referenceImages.length) { + toast('请先添加参考图', 'warning'); return; } @@ -711,13 +873,28 @@ prompt, }; + const references = state.referenceImages + .slice(0, REFERENCE_LIMIT) + .map((item) => String(item.data || '').trim()) + .filter(Boolean); + if (references.length) { + body.image_references = references; + const firstRef = references[0]; + if (firstRef.startsWith('data:image/')) { + body.image_base64 = firstRef; + } else { + body.image_url = firstRef; + } + } + if (state.currentParentPostId) { body.parent_post_id = state.currentParentPostId; if (state.currentSourceImageUrl) { body.source_image_url = state.currentSourceImageUrl; } - } else { - body.image_base64 = state.seedImageBase64; + } else if (!references.length) { + toast('请先添加参考图', 'warning'); + return; } setEditing(true); @@ -822,11 +999,14 @@ }); seedImageInput.addEventListener('change', async (event) => { - const file = event.target && event.target.files ? event.target.files[0] : null; - if (!file) return; + const files = event.target && event.target.files ? Array.from(event.target.files) : []; + if (!files.length) return; try { - await applySeedImageFile(file, '本地图像'); + const added = await addReferenceFiles(files, 'upload'); + if (added > 0) { + toast(`已添加 ${added} 张参考图`, 'success'); + } } catch (e) { toast(String(e.message || e), 'error'); } finally { @@ -891,11 +1071,14 @@ document.addEventListener('paste', async (event) => { const dataTransfer = event.clipboardData; - const file = pickImageFileFromDataTransfer(dataTransfer); - if (file) { + const files = pickImageFilesFromDataTransfer(dataTransfer); + if (files.length) { event.preventDefault(); try { - await applySeedImageFile(file, '粘贴图片'); + const added = await addReferenceFiles(files, 'paste'); + if (added > 0) { + toast(`已粘贴 ${added} 张参考图`, 'success'); + } } catch (e) { toast(String(e.message || e), 'error'); } @@ -946,13 +1129,16 @@ event.preventDefault(); dragCounter = 0; setDragActive(false); - const file = pickImageFileFromDataTransfer(event.dataTransfer); - if (!file) { + const files = pickImageFilesFromDataTransfer(event.dataTransfer); + if (!files.length) { toast('未检测到可用图片文件', 'warning'); return; } try { - await applySeedImageFile(file, '拖拽图片'); + const added = await addReferenceFiles(files, 'drop'); + if (added > 0) { + toast(`已拖入 ${added} 张参考图`, 'success'); + } } catch (e) { toast(String(e.message || e), 'error'); } @@ -999,6 +1185,7 @@ function init() { ensureInlineSubmitButton(); bindEvents(); + renderReferenceStrip(); renderHistory(); resetCycle(false); updateMeta(); @@ -1006,6 +1193,3 @@ init(); })(); - - - diff --git a/app/static/public/pages/imagine_workbench.html b/app/static/public/pages/imagine_workbench.html index 00c54ae..c8390b7 100644 --- a/app/static/public/pages/imagine_workbench.html +++ b/app/static/public/pages/imagine_workbench.html @@ -32,7 +32,7 @@
- 首次上传图片后开始编辑链路。之后每次编辑都会沿用上一次输出的 parentPostId 循环编辑。 + 支持上传/粘贴/拖拽最多 3 张参考图进行编辑;编辑后沿用上一次输出的 parentPostId 继续链路。