diff --git a/app/api/v1/public_api/imagine.py b/app/api/v1/public_api/imagine.py index b28d5eb..75a182b 100644 --- a/app/api/v1/public_api/imagine.py +++ b/app/api/v1/public_api/imagine.py @@ -1150,31 +1150,58 @@ async def public_imagine_workbench_edit(data: ImagineWorkbenchEditRequest, reque ] if effective_reference_items: - for item in effective_reference_items: - ref_parent = _extract_parent_post_id_from_url(str(item.get("parent_post_id") or "")) - if ref_parent and not str(item.get("source_image_url") or "").strip(): - item["parent_post_id"] = ref_parent - item["source_image_url"] = await _canonicalize_parent_source_image_url( - token, - ref_parent, - str(item.get("image_url") or "").strip(), - ) - if not str(item.get("image_url") or "").strip(): - item["image_url"] = item["source_image_url"] - - result = await edit_service.edit_with_reference_items( - token_mgr=token_mgr, - token=token, - model_info=model_info, - prompt=prompt, - reference_items=effective_reference_items, - root_parent_post_id=parent_post_id if use_parent_mode else "", - response_format="url", - stream=False, - return_all_images=True, - progress_cb=progress_cb, + first_ref = effective_reference_items[0] if effective_reference_items else {} + only_single_parent_ref = ( + len(effective_reference_items) == 1 + and bool(str(first_ref.get("parent_post_id") or "").strip()) ) - mode = "parent_post" if use_parent_mode else "upload" + if only_single_parent_ref: + single_parent_post_id = str(first_ref.get("parent_post_id") or "").strip() + single_source_image_url = str( + first_ref.get("source_image_url") + or first_ref.get("image_url") + or current_source_image_url_input + or "" + ).strip() + result = await edit_service.edit_with_parent_post( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + parent_post_id=single_parent_post_id, + source_image_url=single_source_image_url, + response_format="url", + stream=False, + return_all_images=True, + progress_cb=progress_cb, + ) + mode = "parent_post" + else: + for item in effective_reference_items: + ref_parent = _extract_parent_post_id_from_url(str(item.get("parent_post_id") or "")) + if ref_parent and not str(item.get("source_image_url") or "").strip(): + item["parent_post_id"] = ref_parent + item["source_image_url"] = await _canonicalize_parent_source_image_url( + token, + ref_parent, + str(item.get("image_url") or "").strip(), + ) + if not str(item.get("image_url") or "").strip(): + item["image_url"] = item["source_image_url"] + + result = await edit_service.edit_with_reference_items( + token_mgr=token_mgr, + token=token, + model_info=model_info, + prompt=prompt, + reference_items=effective_reference_items, + root_parent_post_id=parent_post_id if use_parent_mode else "", + response_format="url", + stream=False, + return_all_images=True, + progress_cb=progress_cb, + ) + mode = "parent_post" if use_parent_mode else "upload" elif use_parent_mode: if current_source_image_url_input and not ( current_source_image_url_input.startswith("http://") diff --git a/app/api/v1/public_api/video.py b/app/api/v1/public_api/video.py index 9649dcf..836203b 100644 --- a/app/api/v1/public_api/video.py +++ b/app/api/v1/public_api/video.py @@ -362,14 +362,17 @@ async def public_video_start(data: VideoStartRequest): raise HTTPException(status_code=400, detail="最多支持 7 张参考图") parent_post_refs = [item for item in reference_items if item.get("parent_post_id")] parent_post_id = str(parent_post_refs[0].get("parent_post_id") or "").strip() if parent_post_refs else "" + # 带 parent_post_id 的参考项按“基于已有 post 引用”处理,不再同时抽取 image_url, + # 避免单个 reference_item 既有 parent_post_id 又有 image_url 时被误判为冲突参数。 + pure_image_refs = [item for item in reference_items if not item.get("parent_post_id")] image_url = ( - str(reference_items[0].get("image_url") or "").strip() or None - if reference_items + str(pure_image_refs[0].get("image_url") or "").strip() or None + if pure_image_refs else None ) source_image_url = ( - str(reference_items[0].get("source_image_url") or "").strip() or None - if reference_items + str(pure_image_refs[0].get("source_image_url") or "").strip() or None + if pure_image_refs else None ) diff --git a/app/services/grok/services/image_edit.py b/app/services/grok/services/image_edit.py index fd5631b..04f5827 100644 --- a/app/services/grok/services/image_edit.py +++ b/app/services/grok/services/image_edit.py @@ -772,6 +772,7 @@ class ImageEditService: ) -> List[dict[str, str]]: prepared: List[dict[str, str]] = [] upload_service = UploadService() + primary_parent_consumed = False try: for item in reference_items: raw_source = str( @@ -794,13 +795,23 @@ class ImageEditService: attachment_id = "" resolved_url = "" resolved_id = "" + current_parent_post_id = str(item.get("parent_post_id") or "").strip() should_upload_for_attachment = _needs_image_edit_reference_upload( item, raw_source ) + if current_parent_post_id: + if not primary_parent_consumed: + primary_parent_consumed = True + should_upload_for_attachment = False + else: + should_upload_for_attachment = True if not should_upload_for_attachment: normalized_raw = _normalize_asset_url(raw_source) if normalized_raw and not _is_assets_content_url(normalized_raw): - should_upload_for_attachment = True + if current_parent_post_id and primary_parent_consumed: + should_upload_for_attachment = False + else: + should_upload_for_attachment = True if should_upload_for_attachment: file_id, file_uri = await upload_service.upload_file(raw_source, token) resolved_url = _normalize_asset_url(file_uri) @@ -808,10 +819,7 @@ class ImageEditService: resolved_url ) attachment_id = str(file_id or "").strip() or resolved_id - if raw_source.startswith("http://") or raw_source.startswith("https://"): - request_url = raw_source - else: - request_url = resolved_url + request_url = resolved_url mention_id = resolved_id or mention_id else: if raw_source.startswith("/users/") or raw_source.startswith("users/"): @@ -821,7 +829,7 @@ class ImageEditService: resolved_url = _normalize_asset_url(raw_source) resolved_id = _extract_image_post_id(resolved_url) mention_id = mention_id or resolved_id - attachment_id = resolved_id + attachment_id = "" prepared.append( { "source_url": raw_source, @@ -831,7 +839,7 @@ class ImageEditService: "resolved_id": resolved_id, "mention_id": mention_id, "attachment_id": attachment_id, - "parent_post_id": str(item.get("parent_post_id") or "").strip(), + "parent_post_id": current_parent_post_id if not should_upload_for_attachment else "", "mention_alias": mention_alias, } ) @@ -917,19 +925,9 @@ class ImageEditService: if str(item.get("request_url") or item.get("resolved_url") or "").strip() ] file_attachments = [ - str( - item.get("attachment_id") - or item.get("resolved_id") - or item.get("mention_id") - or "" - ).strip() + str(item.get("attachment_id") or "").strip() for item in prepared_refs - if str( - item.get("attachment_id") - or item.get("resolved_id") - or item.get("mention_id") - or "" - ).strip() + if str(item.get("attachment_id") or "").strip() ] if not request_urls: raise AppException( @@ -939,17 +937,23 @@ class ImageEditService: status_code=400, ) - effective_parent_post_id = str(root_parent_post_id or "").strip() - if not effective_parent_post_id: - first_parent = str( - prepared_refs[0].get("parent_post_id") or "" - ).strip() - if first_parent: - effective_parent_post_id = first_parent - if not effective_parent_post_id: - effective_parent_post_id = await VideoService().create_image_post( - current_token, request_urls[0] - ) + parent_ids = [ + str(item.get("parent_post_id") or "").strip() + for item in prepared_refs + if str(item.get("parent_post_id") or "").strip() + ] + unique_parent_ids = list(dict.fromkeys(parent_ids)) + effective_parent_post_id = "" + # 多张不同 parentPostId 的参考图不能再强行指定单个 parentPostId, + # 否则上游会把它视为互相冲突的编辑上下文并直接返回 400。 + if len(unique_parent_ids) == 1: + effective_parent_post_id = unique_parent_ids[0] + elif not unique_parent_ids: + effective_parent_post_id = str(root_parent_post_id or "").strip() + if not effective_parent_post_id: + effective_parent_post_id = await VideoService().create_image_post( + current_token, request_urls[0] + ) await self._emit_progress( progress_cb, "chat_request_start", 42, "已提交编辑请求" @@ -960,10 +964,13 @@ class ImageEditService: "imageEditModel": "imagine", "imageEditModelConfig": { "imageReferences": request_urls, - "parentPostId": effective_parent_post_id, }, } } + if effective_parent_post_id: + model_config_override["modelMap"]["imageEditModelConfig"][ + "parentPostId" + ] = effective_parent_post_id _log_final_image_edit_payload( prompt_text=prompt_text, file_attachments=file_attachments, diff --git a/app/services/grok/services/video.py b/app/services/grok/services/video.py index c49199d..6ae2476 100644 --- a/app/services/grok/services/video.py +++ b/app/services/grok/services/video.py @@ -496,6 +496,51 @@ class VideoService: await upload_service.close() return uploaded + async def _prepare_reference_video_inputs( + self, + token: str, + reference_items: list[dict[str, Any]], + ) -> tuple[str, list[dict[str, str]]]: + primary_parent_post_id = "" + prepared_items: list[dict[str, str]] = [] + upload_candidates: list[dict[str, Any]] = [] + + for index, item in enumerate(reference_items): + normalized_item = dict(item or {}) + source_url = await self._resolve_reference_source_url(token, normalized_item) + if not source_url: + raise ValidationException(f"第 {index + 1} 张参考图缺少可用来源") + + current_parent_post_id = str( + normalized_item.get("parent_post_id") or "" + ).strip() + mention_alias = str(normalized_item.get("mention_alias") or "").strip() + + if current_parent_post_id and not primary_parent_post_id: + primary_parent_post_id = current_parent_post_id + prepared_items.append( + { + "file_id": current_parent_post_id, + "asset_url": source_url, + "source_url": source_url, + "parent_post_id": current_parent_post_id, + "mention_alias": mention_alias, + "direct_parent": "1", + } + ) + continue + + normalized_item["source_image_url"] = source_url + upload_candidates.append(normalized_item) + + if upload_candidates: + uploaded_items = await self._upload_reference_items(token, upload_candidates) + for item in uploaded_items: + item["direct_parent"] = "0" + prepared_items.extend(uploaded_items) + + return primary_parent_post_id, prepared_items + async def generate_from_reference_items( self, token: str, @@ -507,7 +552,9 @@ class VideoService: preset: str = "normal", ) -> AsyncGenerator[bytes, None]: token_tag = _token_tag(token) - uploaded_refs = await self._upload_reference_items(token, reference_items) + primary_parent_post_id, uploaded_refs = await self._prepare_reference_video_inputs( + token, reference_items + ) if not uploaded_refs: raise ValidationException("至少需要 1 张参考图") @@ -539,13 +586,13 @@ class VideoService: file_attachments = [ str(item.get("file_id") or "").strip() for item in uploaded_refs - if str(item.get("file_id") or "").strip() + if str(item.get("file_id") or "").strip() and str(item.get("direct_parent") or "") != "1" ] message = f"{prompt_text} --mode=custom".strip() model_config_override = { "modelMap": { "videoGenModelConfig": { - "parentPostId": post_id, + "parentPostId": primary_parent_post_id or post_id, "aspectRatio": aspect_ratio, "videoLength": video_length, "resolutionName": resolution, diff --git a/app/static/common/js/parentpost-memory.js b/app/static/common/js/parentpost-memory.js index 4240e47..b3528c9 100644 --- a/app/static/common/js/parentpost-memory.js +++ b/app/static/common/js/parentpost-memory.js @@ -70,14 +70,28 @@ return raw; } + function isImageLikeUrl(value) { + const raw = normalizeUrl(value); + if (!raw) return false; + if (raw.startsWith('data:image/')) return true; + if (/\/imagine-public\/images\/[0-9a-fA-F-]{32,36}(?:\.[a-z0-9]+|[/?#]|$)/i.test(raw)) return true; + if (/\/imagine-public\/share-images\/[0-9a-fA-F-]{32,36}(?:\.[a-z0-9]+|[/?#]|$)/i.test(raw)) return true; + if (/\/v1\/files\/image\//i.test(raw)) return true; + if (/\/users\/.+\.(?:jpg|jpeg|png|webp|gif)(?:[?#].*)?$/i.test(raw)) return true; + if (/\.(?:jpg|jpeg|png|webp|gif)(?:[?#].*)?$/i.test(raw)) return true; + return false; + } + function remember(entry) { if (!entry || typeof entry !== 'object') return null; const parentPostId = extractParentPostId(entry.parentPostId || entry.parent_post_id || entry.id || ''); if (!isParentPostId(parentPostId)) return null; - const sourceImageUrl = normalizeUrl(entry.sourceImageUrl || entry.source_image_url || ''); - const imageUrl = normalizeUrl(entry.imageUrl || entry.image_url || entry.url || ''); + const rawSourceImageUrl = normalizeUrl(entry.sourceImageUrl || entry.source_image_url || ''); + const rawImageUrl = normalizeUrl(entry.imageUrl || entry.image_url || entry.url || ''); + const sourceImageUrl = isImageLikeUrl(rawSourceImageUrl) ? rawSourceImageUrl : ''; + const imageUrl = isImageLikeUrl(rawImageUrl) ? rawImageUrl : ''; const item = { parentPostId, sourceImageUrl: sourceImageUrl || imageUrl || buildImaginePublicUrl(parentPostId), @@ -114,8 +128,7 @@ if (!parentPostId) return null; const hit = getByParentPostId(parentPostId); if (hit) return hit; - const looksLikeUrl = raw.startsWith('http://') || raw.startsWith('https://') || raw.startsWith('/'); - const fallbackUrl = looksLikeUrl ? raw : buildImaginePublicUrl(parentPostId); + const fallbackUrl = isImageLikeUrl(raw) ? raw : buildImaginePublicUrl(parentPostId); return { parentPostId, sourceImageUrl: fallbackUrl, diff --git a/app/static/public/js/imagine_workbench.js b/app/static/public/js/imagine_workbench.js index 05f3071..4ab8970 100644 --- a/app/static/public/js/imagine_workbench.js +++ b/app/static/public/js/imagine_workbench.js @@ -159,6 +159,17 @@ return parentPostId ? buildImaginePublicUrl(parentPostId) : ''; } + function isImagePreviewUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return false; + if (raw.startsWith('data:image/')) return true; + if (/\/imagine-public\/images\/[0-9a-fA-F-]{32,36}(?:\.jpg|\/|$)/i.test(raw)) return true; + if (/\/v1\/files\/image\//i.test(raw)) return true; + if (/\/users\/.+\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(raw)) return true; + if (/\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(raw)) return true; + return false; + } + function pickPreviewUrl(hit, parentPostId) { const candidates = [ hit && hit.imageUrl, @@ -169,7 +180,7 @@ ]; for (const candidate of candidates) { const raw = String(candidate || '').trim(); - if (raw) return raw; + if (isImagePreviewUrl(raw)) return raw; } const source = pickSourceImageUrl(hit, parentPostId); return source || (parentPostId ? buildImaginePublicUrl(parentPostId) : ''); @@ -1438,15 +1449,23 @@ if (references.length) { body.image_references = references; body.reference_items = referenceItems; - const firstRef = references[0]; - if (firstRef.startsWith('data:image/')) { - body.image_base64 = firstRef; - } else { - body.image_url = firstRef; + const firstRefItem = referenceItems[0] || null; + const firstRef = firstRefItem + ? String(firstRefItem.source_image_url || firstRefItem.image_url || '').trim() + : ''; + if (firstRefItem && firstRefItem.parent_post_id) { + body.parent_post_id = firstRefItem.parent_post_id; + body.source_image_url = String(firstRefItem.source_image_url || firstRef || '').trim(); + } else if (firstRef) { + if (firstRef.startsWith('data:image/')) { + body.image_base64 = firstRef; + } else { + body.image_url = firstRef; + } } } - if (state.currentParentPostId) { + if (state.currentParentPostId && (!body.reference_items || !body.reference_items.length)) { body.parent_post_id = state.currentParentPostId; if (state.currentSourceImageUrl) { body.source_image_url = state.currentSourceImageUrl; diff --git a/app/static/public/js/video.js b/app/static/public/js/video.js index 6449098..189a5e4 100644 --- a/app/static/public/js/video.js +++ b/app/static/public/js/video.js @@ -712,6 +712,17 @@ return `https://imagine-public.x.ai/imagine-public/images/${parentPostId}.jpg`; } + function isImagePreviewUrl(value) { + const raw = String(value || '').trim(); + if (!raw) return false; + if (raw.startsWith('data:image/')) return true; + if (/\/imagine-public\/images\/[0-9a-fA-F-]{32,36}(?:\.[A-Za-z0-9]+|[/?#]|$)/i.test(raw)) return true; + if (/\/v1\/files\/image\//i.test(raw)) return true; + if (/\/users\/.+\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(raw)) return true; + if (/\.(?:jpg|jpeg|png|webp)(?:[?#].*)?$/i.test(raw)) return true; + return false; + } + function pickPreviewUrl(hit, parentPostId, fallbackValue = '') { const candidates = [ hit && hit.imageUrl, @@ -722,7 +733,7 @@ ]; for (const candidate of candidates) { const raw = String(candidate || '').trim(); - if (raw) return raw; + if (isImagePreviewUrl(raw)) return raw; } return pickSourceUrl(hit, parentPostId, fallbackValue); } @@ -923,6 +934,48 @@ syncPromptRichInputFromTextarea(); } + function appendReferenceItems(items, mode = 'upload') { + const candidates = (Array.isArray(items) ? items : []) + .filter((item) => item && (item.previewUrl || item.sourceUrl || item.url)) + .map((item) => ({ + ...item, + parentPostId: String(item.parentPostId || '').trim(), + sourceUrl: String(item.sourceUrl || item.url || '').trim(), + url: String(item.url || item.sourceUrl || '').trim() + })); + if (!candidates.length) return false; + + const merged = Array.isArray(referenceImages) ? referenceImages.slice() : []; + let appended = false; + for (const item of candidates) { + const itemParentId = String(item.parentPostId || '').trim(); + const itemSourceUrl = String(item.sourceUrl || item.url || '').trim(); + const exists = merged.some((current) => { + const currentParentId = String(current.parentPostId || '').trim(); + const currentSourceUrl = String(current.sourceUrl || current.url || '').trim(); + if (itemParentId && currentParentId && itemParentId === currentParentId) return true; + if (itemSourceUrl && currentSourceUrl && itemSourceUrl === currentSourceUrl) return true; + return false; + }); + if (exists) { + continue; + } + if (merged.length >= REFERENCE_LIMIT) { + toast(`最多支持 ${REFERENCE_LIMIT} 张参考图`, 'warning'); + break; + } + merged.push(item); + appended = true; + } + + if (!appended) return false; + setReferenceItems( + merged, + merged.some((item) => String(item.parentPostId || '').trim()) ? 'parent_post' : mode + ); + return true; + } + function hideReferenceMentionMenu() { activeMentionIndex = -1; if (!referenceMentionMenu) return; @@ -1322,6 +1375,7 @@ function applyParentPostReference(text, options = {}) { const silent = Boolean(options.silent); + const append = Boolean(options.append); const resolved = resolveReferenceByText(text); const raw = String(text || '').trim(); const fallbackId = extractParentPostId(raw); @@ -1336,16 +1390,21 @@ } return false; } - setReferenceItems([{ + const targetItem = { id: makeReferenceId('parent'), previewUrl: finalPreviewUrl, sourceUrl: finalSourceUrl || finalPreviewUrl, url: finalPreviewUrl, parentPostId: finalParentId, name: finalParentId || 'parentPostId' - }], 'parent_post'); + }; + if (append) { + appendReferenceItems([targetItem], 'parent_post'); + } else { + setReferenceItems([targetItem], 'parent_post'); + } if (!silent) { - toast('已使用 parentPostId 填充参考图', 'success'); + toast(append ? '已追加参考图' : '已使用 parentPostId 填充参考图', 'success'); } return true; } @@ -3299,7 +3358,7 @@ if (applyParentBtn) { applyParentBtn.addEventListener('click', () => { - applyParentPostReference(parentPostInput ? parentPostInput.value : ''); + applyParentPostReference(parentPostInput ? parentPostInput.value : '', { append: true }); }); } @@ -3307,7 +3366,7 @@ parentPostInput.addEventListener('keydown', (event) => { if (event.key === 'Enter') { event.preventDefault(); - applyParentPostReference(parentPostInput.value); + applyParentPostReference(parentPostInput.value, { append: true }); } }); parentPostInput.addEventListener('input', () => { @@ -3326,7 +3385,7 @@ event.preventDefault(); event.stopPropagation(); parentPostInput.value = text; - applyParentPostReference(text, { silent: true }); + applyParentPostReference(text, { silent: true, append: true }); }); } @@ -3368,13 +3427,13 @@ event.preventDefault(); event.stopPropagation(); imageUrlInput.value = text; - const applied = applyParentPostReference(text, { silent: true }); + const applied = applyParentPostReference(text, { silent: true, append: true }); if (!applied) { const resolved = resolveReferenceByText(text); if (resolved.parentPostId && parentPostInput) { parentPostInput.value = resolved.parentPostId; } - setReferenceItems([{ + appendReferenceItems([{ id: makeReferenceId('url'), previewUrl: resolved.url || resolved.sourceUrl || text, sourceUrl: resolved.sourceUrl || text, @@ -3409,7 +3468,7 @@ if (!isTypingInParentInput && !isTypingInImageUrlInput && (target instanceof HTMLInputElement || target instanceof HTMLTextAreaElement || target?.isContentEditable)) { return; } - const applied = applyParentPostReference(text, { silent: true }); + const applied = applyParentPostReference(text, { silent: true, append: true }); if (applied) { event.preventDefault(); } diff --git a/app/static/public/pages/imagine_workbench.html b/app/static/public/pages/imagine_workbench.html index 46d0cbf..96cf9d7 100644 --- a/app/static/public/pages/imagine_workbench.html +++ b/app/static/public/pages/imagine_workbench.html @@ -167,7 +167,7 @@ - + diff --git a/app/static/public/pages/video.html b/app/static/public/pages/video.html index 957b605..c49b3d7 100644 --- a/app/static/public/pages/video.html +++ b/app/static/public/pages/video.html @@ -363,7 +363,7 @@
- +