feat: imagine-workbench 支持多图参考并完善编辑回退

This commit is contained in:
XianYuDaXian committed 2026-02-28 16:40:48 +08:00
1 parent 703cb7e340
commit 537514f78c
5 files changed
+417 -80

No files matched your search

+34 -5
View File
@@ -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,
+28 -2
View File
@@ -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)
+95 -1
View File
@@ -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;
}
+250 -66
View File
@@ -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();
})();
+10 -6
View File
@@ -32,7 +32,7 @@
<div>
<h2 class="text-2xl font-semibold tracking-tight">图片编辑工作台</h2>
<p class="text-[var(--accents-4)] mt-1 text-sm">
首次上传图片后开始编辑链路。之后每次编辑都会沿用上一次输出的 parentPostId 循环编辑。
支持上传/粘贴/拖拽最多 3 张参考图进行编辑;编辑后沿用上一次输出的 parentPostId 继续链路。
</p>
</div>
<div id="editStatusText" class="status-chip">未开始</div>
@@ -45,9 +45,13 @@
<div class="panel-title">当前画面</div>
<div class="upload-row">
<input id="seedImageInput" class="hidden" type="file" accept="image/png,image/jpeg,image/webp,image/jpg">
<button id="selectSeedBtn" type="button" class="geist-button-outline">选择首图</button>
<span id="seedFileName" class="file-name">未选择文件</span>
<input id="seedImageInput" class="hidden" type="file" accept="image/*" multiple>
<button id="selectSeedBtn" type="button" class="geist-button-outline">添加参考图</button>
<span id="seedFileName" class="file-name">已添加 0/3 张</span>
</div>
<div id="referenceStrip" class="reference-strip">
<div class="reference-empty">可上传 / 粘贴 / 拖拽参考图(最多 3 张)</div>
</div>
<div class="parent-quick-row">
@@ -60,7 +64,7 @@
<div id="previewShell" class="preview-shell">
<img id="currentImage" class="current-image hidden" alt="current-image">
<div id="previewEmpty" class="preview-empty">请先上传首图</div>
<div id="previewEmpty" class="preview-empty">请先添加参考图</div>
<div id="dropHint" class="drop-hint">支持拖拽图片到此处,或按 Ctrl+V 粘贴图片</div>
</div>
@@ -107,7 +111,7 @@
<div class="mobile-fab" data-mobile-fab>
<div class="mobile-fab-panel">
<div class="mobile-fab-actions">
<button class="geist-button-outline" data-proxy-click="#selectSeedBtn" type="button">选择首图</button>
<button class="geist-button-outline" data-proxy-click="#selectSeedBtn" type="button">添加参考图</button>
<button class="geist-button-outline" data-proxy-click="#applyParentBtn" type="button">使用ID</button>
<button class="geist-button" data-proxy-click="#submitEditBtn" type="button">执行编辑</button>
<button class="geist-button-outline" data-proxy-click="#resetCycleBtn" type="button">重置链路</button>