diff --git a/README.md b/README.md
index 56343c949..0363662d8 100644
--- a/README.md
+++ b/README.md
@@ -197,7 +197,7 @@ docker compose up -d
| :-- | :-- |
| `app` | `app_key`, `app_url`, `api_key`, `webui_enabled`, `webui_key` |
| `logging` | `file_level`, `max_files` |
-| `features` | `temporary`, `memory`, `stream`, `thinking`, `auto_chat_mode_fallback`, `thinking_summary`, `dynamic_statsig`, `enable_nsfw`, `show_search_sources`, `custom_instruction`, `image_format`, `video_format` |
+| `features` | `temporary`, `memory`, `stream`, `thinking`, `auto_chat_mode_fallback`, `thinking_summary`, `dynamic_statsig`, `enable_nsfw`, `show_search_sources`, `custom_instruction`, `image_format`, `imagine_public_image_proxy`, `video_format` |
| `proxy.egress` | `mode`, `proxy_url`, `proxy_pool`, `resource_proxy_url`, `resource_proxy_pool`, `skip_ssl_verify` |
| `proxy.clearance` | `mode`, `cf_cookies`, `user_agent`, `browser`, `flaresolverr_url`, `timeout_sec`, `refresh_interval` |
| `retry` | `reset_session_status_codes`, `max_retries`, `on_codes` |
@@ -216,6 +216,7 @@ docker compose up -d
| 配置项 | 可选值 |
| :-- | :-- |
| `features.image_format` | `grok_url`, `local_url`, `grok_md`, `local_md`, `base64` |
+| `features.imagine_public_image_proxy` | `true`, `false` |
| `features.video_format` | `grok_url`, `local_url`, `grok_html`, `local_html` |
diff --git a/app/dataplane/reverse/protocol/xai_image_edit.py b/app/dataplane/reverse/protocol/xai_image_edit.py
index e11368682..ae0e86c1c 100644
--- a/app/dataplane/reverse/protocol/xai_image_edit.py
+++ b/app/dataplane/reverse/protocol/xai_image_edit.py
@@ -28,7 +28,6 @@ def build_image_edit_payload(
"enableImageStreaming": True,
"imageGenerationCount": IMAGE_EDIT_GENERATION_COUNT,
"forceConcise": False,
- "toolOverrides": {"imageGen": True},
"enableSideBySide": True,
"sendFinalMetadata": True,
"isReasoning": False,
diff --git a/app/dataplane/reverse/transport/assets.py b/app/dataplane/reverse/transport/assets.py
index f8b056a45..b786447ff 100644
--- a/app/dataplane/reverse/transport/assets.py
+++ b/app/dataplane/reverse/transport/assets.py
@@ -7,8 +7,23 @@
import asyncio
from typing import Any, AsyncGenerator, Dict, Optional
-from app.platform.logging.logger import logger
+from app.control.proxy.models import ProxyFeedback, ProxyFeedbackKind, ProxyScope, RequestKind
+from app.dataplane.proxy import get_proxy_runtime
+from app.dataplane.reverse.protocol.xai_assets import (
+ ASSETS_LIST_URL,
+ asset_delete_url,
+ infer_content_type,
+ resolve_download_url,
+)
+from app.dataplane.reverse.transport._proxy_feedback import upstream_feedback
+from app.dataplane.reverse.transport.http import (
+ delete_json,
+ get_bytes_stream,
+ get_json,
+)
from app.platform.config.snapshot import get_config
+from app.platform.errors import UpstreamError
+from app.platform.logging.logger import logger
# Global semaphores — limit concurrent transport calls across all callers.
# Lazily initialised so the event loop is guaranteed to be running on first use.
@@ -28,21 +43,6 @@ def _get_delete_sem() -> asyncio.Semaphore:
n = max(1, int(get_config("batch.asset_delete_concurrency", 50)))
_delete_sem = asyncio.Semaphore(n)
return _delete_sem
-from app.platform.errors import UpstreamError
-from app.control.proxy.models import ProxyFeedback, ProxyFeedbackKind, ProxyScope, RequestKind
-from app.dataplane.reverse.transport._proxy_feedback import upstream_feedback
-from app.dataplane.proxy import get_proxy_runtime
-from app.dataplane.reverse.protocol.xai_assets import (
- ASSETS_LIST_URL,
- asset_delete_url,
- infer_content_type,
- resolve_download_url,
-)
-from app.dataplane.reverse.transport.http import (
- delete_json,
- get_bytes_stream,
- get_json,
-)
# ------------------------------------------------------------------
@@ -174,16 +174,24 @@ async def download_asset(
url, origin, referer = resolve_download_url(file_path)
content_type = infer_content_type(url)
+ if content_type and content_type.startswith("video/"):
+ accept = "video/mp4,video/*,*/*;q=0.8"
+ elif content_type and content_type.startswith("image/"):
+ accept = "image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8"
+ else:
+ accept = "*/*"
+
extra: Dict[str, str] = {
+ "Accept": accept,
"Cache-Control": "no-cache",
"Pragma": "no-cache",
"Priority": "u=0, i",
+ "Sec-Fetch-Dest": "document",
"Sec-Fetch-Mode": "navigate",
+ "Sec-Fetch-Site": "none",
"Sec-Fetch-User": "?1",
"Upgrade-Insecure-Requests": "1",
}
- if content_type:
- extra["Content-Type"] = content_type
proxy = await get_proxy_runtime()
lease = await proxy.acquire(scope=ProxyScope.ASSET, kind=RequestKind.HTTP)
diff --git a/app/dataplane/reverse/transport/http.py b/app/dataplane/reverse/transport/http.py
index 7df524f83..ad9686a34 100644
--- a/app/dataplane/reverse/transport/http.py
+++ b/app/dataplane/reverse/transport/http.py
@@ -248,6 +248,9 @@ async def get_bytes_stream(
)
if extra_headers:
headers.update(extra_headers)
+ if headers.get("Sec-Fetch-Mode") == "navigate":
+ headers.pop("Content-Type", None)
+ headers.pop("Origin", None)
kwargs = build_session_kwargs(lease=lease)
session = ResettableSession(**kwargs)
@@ -259,7 +262,6 @@ async def get_bytes_stream(
stream=True,
allow_redirects=True,
)
-
if response.status_code != 200:
try:
body = (response.content).decode("utf-8", "replace")[:400]
diff --git a/app/products/openai/chat.py b/app/products/openai/chat.py
index 457bac655..7a551b13e 100644
--- a/app/products/openai/chat.py
+++ b/app/products/openai/chat.py
@@ -4,6 +4,7 @@
import base64
import re
from typing import Any, AsyncGenerator
+from urllib.parse import urlparse
import orjson
@@ -213,6 +214,14 @@ def _save_image(raw: bytes, mime: str, image_id: str) -> str:
return save_local_image(raw, mime, image_id)
+def _is_imagine_public_url(url: str) -> bool:
+ try:
+ host = urlparse(url or "").hostname or ""
+ except Exception:
+ return False
+ return host.startswith("imagine-public")
+
+
async def _resolve_image(token: str, url: str, image_id: str) -> str:
"""Return the image embed text for the response body based on image_format config.
@@ -226,10 +235,15 @@ async def _resolve_image(token: str, url: str, image_id: str) -> str:
cfg = get_config()
fmt = _normalize_image_format(cfg.get_str("features.image_format", "grok_url"))
+ proxy_imagine_public = (
+ _is_imagine_public_url(url)
+ and cfg.get_bool("features.imagine_public_image_proxy", False)
+ )
+
# Formats that don't need downloading
- if fmt == "grok_url":
+ if fmt == "grok_url" and not proxy_imagine_public:
return url
- if fmt == "grok_md":
+ if fmt == "grok_md" and not proxy_imagine_public:
return f""
# Formats that require downloading
@@ -254,9 +268,9 @@ async def _resolve_image(token: str, url: str, image_id: str) -> str:
else f"/v1/files/image?id={file_id}"
)
- if fmt == "local_url":
+ if fmt in {"grok_url", "local_url"}:
return local_url
- return f"" # local_md
+ return f"" # grok_md / local_md
def _normalize_image_format(value: str | None) -> str:
diff --git a/app/products/openai/images.py b/app/products/openai/images.py
index 1c8dc8845..652767ff2 100644
--- a/app/products/openai/images.py
+++ b/app/products/openai/images.py
@@ -8,6 +8,7 @@
import time
from dataclasses import dataclass
from typing import Any, AsyncGenerator, Awaitable, Callable
+from urllib.parse import urlparse
import orjson
@@ -25,6 +26,7 @@
StreamAdapter,
build_chat_payload,
classify_line,
+ raise_for_stream_error,
)
from app.dataplane.reverse.protocol.xai_assets import infer_content_type, resolve_asset_reference, resolve_download_url
from app.dataplane.reverse.protocol.xai_image_edit import (
@@ -106,6 +108,21 @@ def _progress_reason(label: str, progress: int, *, completed: int | None = None,
return reason
+def _progress_reason_delta(
+ label: str,
+ progress: int,
+ *,
+ completed: int | None = None,
+ total: int | None = None,
+) -> str:
+ return _progress_reason(
+ label,
+ progress,
+ completed=completed,
+ total=total,
+ ) + "\n"
+
+
def _append_reason_update(
updates: list[str],
label: str,
@@ -171,6 +188,14 @@ def _extract_image_file_id(url: str) -> str:
return hashlib.sha1(url.encode("utf-8")).hexdigest()[:32]
+def _is_imagine_public_url(url: str) -> bool:
+ try:
+ host = urlparse(url or "").hostname or ""
+ except Exception:
+ return False
+ return host.startswith("imagine-public")
+
+
def _save_image(raw: bytes, mime: str, file_id: str) -> str:
return save_local_image(raw, mime, file_id)
@@ -196,6 +221,13 @@ async def _resolve_image_output(
blob_b64: str | None = None,
) -> _ImageOutput:
fmt = _normalize_response_format(response_format)
+ cfg = get_config()
+ if (
+ fmt == "url"
+ and _is_imagine_public_url(url)
+ and not cfg.get_bool("features.imagine_public_image_proxy", False)
+ ):
+ return _ImageOutput(api_value=url, markdown_value=f"")
if fmt == "url" and not _app_url():
return _ImageOutput(api_value=url, markdown_value=f"")
@@ -323,7 +355,7 @@ async def _sse_stream() -> AsyncGenerator[str, None]:
completed=len(completed_ids),
total=n,
)
- chunk = make_thinking_chunk(response_id, model, reason)
+ chunk = make_thinking_chunk(response_id, model, reason + "\n")
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
continue
if not ev.get("is_final"):
@@ -335,7 +367,7 @@ async def _sse_stream() -> AsyncGenerator[str, None]:
if chat_format and aggregate > last_progress:
last_progress = aggregate
reason = _progress_reason("图片", aggregate, completed=len(completed_ids), total=n)
- chunk = make_thinking_chunk(response_id, model, reason)
+ chunk = make_thinking_chunk(response_id, model, reason + "\n")
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
image = await _resolve_image_output(
token=token,
@@ -505,7 +537,12 @@ async def _progress(idx: int, progress: int) -> None:
chunk = make_thinking_chunk(
response_id,
spec.model_name,
- _progress_reason("图片", aggregate, completed=completed, total=n),
+ _progress_reason_delta(
+ "图片",
+ aggregate,
+ completed=completed,
+ total=n,
+ ),
)
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
@@ -567,10 +604,17 @@ async def _progress(idx: int, progress: int) -> None:
# Image editing
# ---------------------------------------------------------------------------
-_EDIT_MAX_REFERENCES = 5
+_EDIT_MAX_REFERENCES = 7
_EDIT_DEFAULT_SIZE = "1024x1024"
_EDIT_MAX_N = 2
_EDIT_MAX_ATTEMPTS = 2
+_EDIT_IMAGE_PLACEHOLDER_RE = re.compile(r"@IMAGE(\d+)\b", re.IGNORECASE)
+
+
+@dataclass(slots=True)
+class _EditReference:
+ file_id: str
+ content_url: str
def _normalize_edit_inputs(image_inputs: list[str]) -> list[str]:
@@ -592,11 +636,16 @@ def _normalize_edit_size(size: str) -> str:
return _EDIT_DEFAULT_SIZE
-async def _prepare_edit_reference(token: str, image_input: str, index: int) -> str:
+async def _prepare_edit_reference(
+ token: str, image_input: str, index: int
+) -> _EditReference:
"""Upload one edit reference and resolve it to the upstream content URL."""
try:
file_id, file_uri = await upload_from_input(token, image_input)
- return resolve_uploaded_asset_reference(token, file_id, file_uri)
+ return _EditReference(
+ file_id=file_id,
+ content_url=resolve_uploaded_asset_reference(token, file_id, file_uri),
+ )
except ValidationError as exc:
raise ValidationError(exc.message, param=f"image.{index}") from exc
except UpstreamError as exc:
@@ -609,9 +658,11 @@ async def _prepare_edit_reference(token: str, image_input: str, index: int) -> s
raise UpstreamError(f"Image edit reference {index + 1} upload failed: {exc}") from exc
-async def _prepare_edit_references(token: str, image_inputs: list[str]) -> list[str]:
+async def _prepare_edit_references(
+ token: str, image_inputs: list[str]
+) -> list[_EditReference]:
"""Upload edit references concurrently and preserve caller order."""
- results: list[str | None] = [None] * len(image_inputs)
+ results: list[_EditReference | None] = [None] * len(image_inputs)
async def _runner(index: int, image_input: str) -> None:
results[index] = await _prepare_edit_reference(token, image_input, index)
@@ -623,6 +674,20 @@ async def _runner(index: int, image_input: str) -> None:
return [result for result in results if result is not None]
+def _replace_edit_image_placeholders(
+ prompt: str, references: list[_EditReference]
+) -> str:
+ """Replace @IMAGE1-style placeholders with uploaded asset IDs."""
+
+ def _replace(match: re.Match[str]) -> str:
+ image_number = int(match.group(1))
+ if image_number < 1 or image_number > len(references):
+ return match.group(0)
+ return f"@{references[image_number - 1].file_id}"
+
+ return _EDIT_IMAGE_PLACEHOLDER_RE.sub(_replace, prompt)
+
+
def _extract_edit_prompt_and_inputs(messages: list[dict]) -> tuple[str, list[str]]:
"""Extract the final prompt and ordered image references from messages."""
prompt = ""
@@ -755,6 +820,7 @@ async def _collect_edit_final_urls(
obj = orjson.loads(data)
except Exception:
continue
+ raise_for_stream_error(obj)
stream = extract_streaming_response(obj)
if stream and progress_cb is not None:
index = _parse_image_index(stream.get("imageIndex"))
@@ -1070,16 +1136,19 @@ async def edit(
token = acct.token
response_id = make_response_id()
+ edit_prompt = prompt
try:
- image_references = await _prepare_edit_references(token, image_inputs)
- if not image_references:
+ edit_references = await _prepare_edit_references(token, image_inputs)
+ if not edit_references:
raise UpstreamError("All image uploads failed; cannot proceed with image edit")
+ edit_prompt = _replace_edit_image_placeholders(prompt, edit_references)
+ image_references = [ref.content_url for ref in edit_references]
post = await create_media_post(
token,
media_type=IMAGE_POST_MEDIA_TYPE,
- prompt=prompt,
+ prompt=edit_prompt,
)
post_data = post.get("post")
if not isinstance(post_data, dict):
@@ -1087,6 +1156,9 @@ async def edit(
parent_post_id = str(post_data.get("id") or "").strip()
if not parent_post_id:
raise UpstreamError("Image edit create-post returned no post id")
+ post_prompt = post_data.get("originalPrompt") or post_data.get("prompt")
+ if isinstance(post_prompt, str) and post_prompt.strip():
+ edit_prompt = post_prompt.strip()
except Exception:
await _acct_dir.release(acct)
raise
@@ -1109,7 +1181,7 @@ async def _progress(index: int, progress: int) -> None:
task = asyncio.create_task(
_collect_edit_images(
token=token,
- prompt=prompt,
+ prompt=edit_prompt,
image_references=image_references,
parent_post_id=parent_post_id,
requested_n=n,
@@ -1128,7 +1200,12 @@ async def _progress(index: int, progress: int) -> None:
chunk = make_thinking_chunk(
response_id,
model,
- _progress_reason("图片", aggregate, completed=completed, total=n),
+ _progress_reason_delta(
+ "图片",
+ aggregate,
+ completed=completed,
+ total=n,
+ ),
)
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
images = await task
@@ -1173,7 +1250,7 @@ async def _progress(index: int, progress: int) -> None:
images = await _collect_edit_images(
token=token,
- prompt=prompt,
+ prompt=edit_prompt,
image_references=image_references,
parent_post_id=parent_post_id,
requested_n=n,
diff --git a/app/products/openai/video.py b/app/products/openai/video.py
index 99fadd527..f4e75baca 100644
--- a/app/products/openai/video.py
+++ b/app/products/openai/video.py
@@ -56,7 +56,7 @@
_IMAGE_MEDIA_TYPE = "MEDIA_POST_TYPE_IMAGE"
_VIDEO_MEDIA_TYPE = "MEDIA_POST_TYPE_VIDEO"
-_VIDEO_MODEL_NAME = "grok-3"
+_VIDEO_MODEL_NAME = "imagine-video-gen"
_VIDEO_QUALITY = "standard"
_VIDEO_OBJECT = "video"
_VIDEO_JOB_TTL_S = 3600
@@ -143,6 +143,10 @@ def _progress_reason(progress: int) -> str:
return f"视频正在生成 {max(0, min(100, int(progress)))}%"
+def _progress_reason_delta(progress: int) -> str:
+ return _progress_reason(progress) + "\n"
+
+
def _coerce_seconds(value: str | int | None) -> int:
if value is None:
return 6
@@ -230,7 +234,6 @@ def _video_create_payload(
"temporary": True,
"modelName": _VIDEO_MODEL_NAME,
"message": _build_message(prompt, preset),
- "toolOverrides": {"videoGen": True},
"enableSideBySide": True,
"responseMetadata": {
"experiments": [],
@@ -262,7 +265,6 @@ def _video_extend_payload(
"temporary": True,
"modelName": _VIDEO_MODEL_NAME,
"message": _build_message(prompt, preset),
- "toolOverrides": {"videoGen": True},
"enableSideBySide": True,
"responseMetadata": {
"experiments": [],
@@ -434,16 +436,45 @@ async def _prepare_video_references(
input_references: list[dict[str, Any]],
) -> list[_VideoReference]:
"""Upload multiple video references concurrently and preserve order."""
- results: list[_VideoReference | None] = [None] * len(input_references)
+ tasks = [
+ _prepare_video_reference(token, ref)
+ for ref in input_references
+ ]
+ results = await asyncio.gather(*tasks, return_exceptions=True)
+ failures: list[tuple[int, BaseException]] = [
+ (index, result)
+ for index, result in enumerate(results)
+ if isinstance(result, BaseException)
+ ]
+ if failures:
+ index, exc = failures[0]
+ message = f"Video input reference {index + 1} failed: {_exception_message(exc)}"
+ if len(failures) > 1:
+ message += f" ({len(failures)} references failed)"
+ if isinstance(exc, ValidationError):
+ raise ValidationError(message, param=exc.param) from exc
+ if isinstance(exc, UpstreamError):
+ raise UpstreamError(
+ message,
+ status=exc.status,
+ body=exc.details.get("body", ""),
+ ) from exc
+ raise UpstreamError(message) from exc
- async def _runner(index: int, ref: dict[str, Any]) -> None:
- results[index] = await _prepare_video_reference(token, ref)
+ return [r for r in results if isinstance(r, _VideoReference)]
- async with asyncio.TaskGroup() as tg:
- for index, ref in enumerate(input_references):
- tg.create_task(_runner(index, ref), name=f"video-ref-{index}")
- return [r for r in results if r is not None]
+def _exception_message(exc: BaseException) -> str:
+ if isinstance(exc, BaseExceptionGroup):
+ messages = [
+ _exception_message(child)
+ for child in exc.exceptions
+ if not isinstance(child, asyncio.CancelledError)
+ ]
+ return "; ".join(message for message in messages if message) or str(exc)
+ if isinstance(exc, AppError):
+ return exc.message
+ return str(exc)
async def _collect_video_segment(
@@ -458,6 +489,7 @@ async def _collect_video_segment(
final_asset_id = ""
final_thumbnail = ""
video_post_id = ""
+ stream_data_items: list[str] = []
async for line in _stream_video_request(
token,
@@ -470,6 +502,7 @@ async def _collect_video_segment(
break
if event_type != "data" or not data:
continue
+ stream_data_items.append(data)
try:
obj = orjson.loads(data)
except Exception:
@@ -512,10 +545,14 @@ async def _collect_video_segment(
if not final_url and final_asset_id:
raise UpstreamError(
- "Video segment returned only assetId without a resolvable URL"
+ "Video segment returned only assetId without a resolvable URL",
+ body="\n".join(stream_data_items),
)
if not final_url:
- raise UpstreamError("Video generation returned no final video URL")
+ raise UpstreamError(
+ "Video generation returned no final video URL",
+ body="\n".join(stream_data_items),
+ )
return _VideoArtifact(
video_url=final_url,
@@ -535,7 +572,12 @@ async def _download_video_bytes(token: str, url: str) -> tuple[bytes, str]:
raise
except Exception as exc:
raise UpstreamError(f"Video download failed: {exc}") from exc
- return b"".join(chunks), (content_type or "video/mp4")
+ raw = b"".join(chunks)
+ if not raw:
+ raise UpstreamError("Video download returned empty content", status=502)
+ if raw.lstrip()[:1] in {b"<", b"{"}:
+ raise UpstreamError("Video download returned non-video content", status=502)
+ return raw, (content_type or "video/mp4")
def _save_video_bytes(raw: bytes, file_id: str) -> Path:
@@ -579,7 +621,7 @@ async def _resolve_video_output(*, token: str, url: str, file_id: str) -> str:
raw, _mime = await _download_video_bytes(token, url)
await asyncio.to_thread(_save_video_bytes, raw, file_id)
except Exception as exc:
- logger.warning("video download failed: fallback_to=upstream_url error={}", exc)
+ logger.debug("video download fallback_to=upstream_url error={}", exc)
return url if fmt == "local_url" else _render_video_html(url)
local_url = _local_video_url(file_id)
@@ -868,7 +910,7 @@ async def _progress(progress: int) -> None:
logger.exception("video job failed: job_id={} error={}", job.id, exc)
async with _VIDEO_JOBS_LOCK:
job.status = "failed"
- job.error = _job_error_payload(str(exc))
+ job.error = _job_error_payload(_exception_message(exc))
async def create_video(
@@ -1062,7 +1104,7 @@ async def _progress(progress: int) -> None:
if progress > last_progress:
last_progress = progress
chunk = make_thinking_chunk(
- response_id, model, _progress_reason(progress)
+ response_id, model, _progress_reason_delta(progress)
)
yield f"data: {orjson.dumps(chunk).decode()}\n\n"
diff --git a/app/statics/admin/config.html b/app/statics/admin/config.html
index e04672118..fe9d5f6da 100644
--- a/app/statics/admin/config.html
+++ b/app/statics/admin/config.html
@@ -467,9 +467,17 @@
{ value: 'local_md', label: 'Markdown(本地代理)', labelKey: 'config.schema.options.imageFormat.localMarkdown', disabledWhen: 'no_app_url', disabledTip: '请先填写 APP 访问地址', disabledTipKey: 'config.disabledTip.appUrlRequired' },
{ value: 'base64', label: 'Base64(内嵌)', labelKey: 'config.schema.options.imageFormat.base64' },
],
- desc: 'grok_url / grok_md 直接返回 Grok CDN 地址,客户端需具备 CDN 访问能力;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。',
+ desc: 'grok_url / grok_md 默认直接返回 Grok CDN 地址;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。开启 Imagine Public 图片代理后,WebSocket 返回的 imagine-public 图片也会本地代理。',
descKey: 'config.schema.fields.imageFormat.desc',
},
+ {
+ key: 'imagine_public_image_proxy',
+ label: 'Imagine Public 图片代理',
+ labelKey: 'config.schema.fields.imaginePublicImageProxy.label',
+ type: 'bool',
+ desc: '开启后将 WebSocket 返回的 imagine-public 图片下载到服务端,再通过本地 URL 代理分发;关闭时保持公开图片直返。',
+ descKey: 'config.schema.fields.imaginePublicImageProxy.desc',
+ },
{
key: 'video_format',
label: '视频返回格式',
diff --git a/app/statics/css/app.css b/app/statics/css/app.css
index 777d3ed35..4aca0c5aa 100644
--- a/app/statics/css/app.css
+++ b/app/statics/css/app.css
@@ -561,6 +561,11 @@ button{cursor:pointer}
.msg-card-assistant video{
display:block;width:min(100%,400px);max-height:300px;border-radius:16px;background:#111
}
+.msg-media-error{
+ box-sizing:border-box;width:max-content;max-width:100%;margin-top:8px;padding:9px 11px;border-radius:10px;
+ background:#fff7ed;border:1px solid #fed7aa;color:#9a3412;font-size:12px;line-height:1.5;
+ white-space:nowrap
+}
.msg-card-assistant > *:first-child{margin-top:0}
.msg-card-assistant > *:last-child{margin-bottom:0}
.msg-card-assistant p,
@@ -688,6 +693,8 @@ button{cursor:pointer}
color:#201d19;
font-size:12px;
letter-spacing:-.01em;
+ text-align:right;
+ text-align-last:right;
appearance:none;
background-image:none;
}
diff --git a/app/statics/i18n/de.json b/app/statics/i18n/de.json
index 8b9c626a7..ab9646eac 100644
--- a/app/statics/i18n/de.json
+++ b/app/statics/i18n/de.json
@@ -64,6 +64,8 @@
"imageRequired": "Für die Bildbearbeitung ist mindestens ein Referenzbild erforderlich",
"imageOnly": "Die Bildbearbeitung unterstützt nur Bild-Uploads",
"videoImageOnly": "Die Videogenerierung unterstützt nur Bild-Uploads als Referenz",
+ "imageProxyRequired": "Bild konnte nicht geladen werden. Konfigurieren Sie die APP-Basis-URL und ändern Sie das Bildausgabeformat in local_url, local_md oder base64.",
+ "videoProxyRequired": "Das Laden des Videos ergab 403. Öffnen Sie die Admin-Seite, konfigurieren Sie die APP-Basis-URL und ändern Sie das Videoausgabeformat in den lokalen Proxy-Modus (local_url oder local_html). Versuchen Sie es dann erneut.",
"requestFailed": "Anfrage fehlgeschlagen",
"initFailed": "Initialisierung der Chat-Seite fehlgeschlagen"
}
@@ -393,7 +395,8 @@
"enableNsfw": { "label": "NSFW-Erzeugung zulassen" },
"showSearchSources": { "label": "Quellen an Inhalt anhängen", "desc": "Suchquellen werden immer im Feld search_sources ausgegeben. Diese Option steuert, ob zusätzlich ein ## Sources-Abschnitt an den Inhalt angehängt wird (für Abwärtskompatibilität mit textbasierten Clients)." },
"customInstruction": { "label": "Globale Zusatzanweisung" },
- "imageFormat": { "label": "Bildausgabeformat" },
+ "imageFormat": { "label": "Bildausgabeformat", "desc": "grok_url und grok_md geben die native Grok-CDN-Adresse standardmäßig direkt zurück. local_* Modi laden Assets zuerst auf den Server herunter und stellen sie über den lokalen Proxy bereit. base64 gibt eine eingebettete Data URI zurück. Wenn der Imagine-Public-Bildproxy aktiviert ist, werden vom WebSocket zurückgegebene imagine-public-Bilder ebenfalls lokal proxied." },
+ "imaginePublicImageProxy": { "label": "Imagine-Public-Bildproxy", "desc": "Wenn aktiviert, werden vom WebSocket zurückgegebene imagine-public-Bilder auf den Server heruntergeladen und über den lokalen Proxy ausgeliefert. Wenn deaktiviert, werden öffentliche Bilder direkt zurückgegeben." },
"videoFormat": { "label": "Videoausgabeformat" },
"refreshEnabled": { "label": "Quotenaktualisierung aktivieren", "desc": "Wenn aktiviert, wechselt das System automatisch in den Quotenaktualisierungsmodus. Wenn deaktiviert, wechselt es automatisch in den Auto-Retry-Modus.", "help": "Ein: Quotenaktualisierung. Der Scheduler synchronisiert regelmäßig echte Quoten, die Auswahl erfolgt nach Bewertung.\nAus: Auto-Retry-Modus. Keine aktive upstream-Abfrage, zufällige Auswahl und automatischer Kontowechsel bei Fehlern mit bis zu 5 Wiederholungen.\nEmpfehlung: Bei mehr als 10.000 Konten ausschalten, um upstream 429 durch aktive Abfragen zu vermeiden." },
"maxInflight": { "label": "Max. laufende Anfragen pro Konto", "desc": "Maximale Anzahl gleichzeitig laufender Anfragen pro Konto. Konten am Limit werden von der Auswahl übersprungen (für beide Modi gemeinsam)." },
diff --git a/app/statics/i18n/en.json b/app/statics/i18n/en.json
index 479a0d673..8fe60a16d 100644
--- a/app/statics/i18n/en.json
+++ b/app/statics/i18n/en.json
@@ -83,6 +83,8 @@
"imageRequired": "Image edit requires at least one reference image",
"imageOnly": "Image edit only supports image uploads",
"videoImageOnly": "Video generation only supports image reference uploads",
+ "imageProxyRequired": "Image failed to load. Set APP Base URL and change image output format to local_url, local_md, or base64.",
+ "videoProxyRequired": "Video loading returned 403. Go to the admin page, set the APP Base URL, then change the video output format to local proxy mode (local_url or local_html) and retry.",
"requestFailed": "Request failed",
"initFailed": "Chat page initialization failed"
}
@@ -441,7 +443,11 @@
},
"imageFormat": {
"label": "Image Output Format",
- "desc": "grok_url and grok_md return the native Grok CDN address directly. local_* modes download assets to the server first and re-expose them through the local proxy. base64 returns an inline Data URI."
+ "desc": "grok_url and grok_md return the native Grok CDN address directly by default. local_* modes download assets to the server first and re-expose them through the local proxy. base64 returns an inline Data URI. When Imagine Public Image Proxy is enabled, imagine-public images returned by the WebSocket are also proxied locally."
+ },
+ "imaginePublicImageProxy": {
+ "label": "Imagine Public Image Proxy",
+ "desc": "When enabled, imagine-public images returned by the WebSocket are downloaded to the server and delivered through the local proxy. When disabled, public images are returned directly."
},
"videoFormat": {
"label": "Video Output Format",
diff --git a/app/statics/i18n/es.json b/app/statics/i18n/es.json
index c94be4f85..167e4faea 100644
--- a/app/statics/i18n/es.json
+++ b/app/statics/i18n/es.json
@@ -64,6 +64,8 @@
"imageRequired": "La edición de imagen requiere al menos una imagen de referencia",
"imageOnly": "La edición de imagen solo admite cargas de imágenes",
"videoImageOnly": "La generación de vídeo solo admite imágenes como referencia",
+ "imageProxyRequired": "No se pudo cargar la imagen. Configure la URL base de APP y cambie el formato de salida de imagen a local_url, local_md o base64.",
+ "videoProxyRequired": "La carga del vídeo devolvió 403. Vaya a la página de administración, configure la URL base de APP y cambie el formato de salida de vídeo al modo de proxy local (local_url o local_html). Después, inténtelo de nuevo.",
"requestFailed": "La solicitud falló",
"initFailed": "La inicialización de la página de chat falló"
}
@@ -393,7 +395,8 @@
"enableNsfw": { "label": "Permitir generación NSFW" },
"showSearchSources": { "label": "Agregar fuentes al contenido", "desc": "Las fuentes de búsqueda siempre se incluyen en el campo search_sources. Esta opción controla si también se agrega una sección ## Sources al contenido (para compatibilidad con clientes que analizan texto)." },
"customInstruction": { "label": "Instrucción suplementaria global" },
- "imageFormat": { "label": "Formato de salida de imagen" },
+ "imageFormat": { "label": "Formato de salida de imagen", "desc": "grok_url y grok_md devuelven directamente la dirección nativa del CDN de Grok de forma predeterminada. Los modos local_* descargan primero los recursos en el servidor y los vuelven a exponer mediante el proxy local. base64 devuelve un Data URI integrado. Cuando el proxy de imágenes Imagine Public está activado, las imágenes imagine-public devueltas por WebSocket también se sirven mediante el proxy local." },
+ "imaginePublicImageProxy": { "label": "Proxy de imágenes Imagine Public", "desc": "Cuando está activado, las imágenes imagine-public devueltas por WebSocket se descargan en el servidor y se entregan mediante el proxy local. Cuando está desactivado, las imágenes públicas se devuelven directamente." },
"videoFormat": { "label": "Formato de salida de video" },
"refreshEnabled": { "label": "Activar actualización de cuota", "desc": "Al activarlo, el sistema cambia automáticamente al modo de actualización de cuota. Al desactivarlo, cambia automáticamente al modo de reintento automático.", "help": "Activado: modo de actualización de cuota. El scheduler sincroniza periódicamente la cuota real y la selección usa puntuación.\nDesactivado: modo de reintento automático. No comprueba upstream activamente, usa selección aleatoria y cambia de cuenta al fallar hasta 5 reintentos.\nRecomendación: desactívalo con más de 10.000 cuentas para evitar provocar 429 de upstream con comprobaciones activas." },
"maxInflight": { "label": "Máximo en curso por cuenta", "desc": "Máximo de solicitudes simultáneas en curso por cuenta. Las cuentas en ese límite se omiten en la selección (compartido por ambos modos)." },
diff --git a/app/statics/i18n/fr.json b/app/statics/i18n/fr.json
index 68d0b55b7..43a8aca92 100644
--- a/app/statics/i18n/fr.json
+++ b/app/statics/i18n/fr.json
@@ -64,6 +64,8 @@
"imageRequired": "L’édition d’image nécessite au moins une image de référence",
"imageOnly": "L’édition d’image ne prend en charge que les téléversements d’images",
"videoImageOnly": "La génération vidéo n’accepte que les images comme référence",
+ "imageProxyRequired": "Échec du chargement de l’image. Configurez l’URL de base APP et changez le format de sortie image en local_url, local_md ou base64.",
+ "videoProxyRequired": "Le chargement de la vidéo a renvoyé 403. Accédez à la page d’administration, configurez l’URL de base APP, puis passez le format de sortie vidéo en mode proxy local (local_url ou local_html) et réessayez.",
"requestFailed": "Échec de la requête",
"initFailed": "Échec de l’initialisation de la page de chat"
}
@@ -393,7 +395,8 @@
"enableNsfw": { "label": "Autoriser la génération NSFW" },
"showSearchSources": { "label": "Ajouter les sources au contenu", "desc": "Les sources de recherche sont toujours présentes dans le champ search_sources. Cette option contrôle si une section ## Sources est également ajoutée au contenu (pour la compatibilité avec les clients analysant le texte)." },
"customInstruction": { "label": "Instruction globale supplémentaire" },
- "imageFormat": { "label": "Format de sortie image" },
+ "imageFormat": { "label": "Format de sortie image", "desc": "grok_url et grok_md renvoient directement l’adresse CDN native de Grok par défaut. Les modes local_* téléchargent d’abord les ressources sur le serveur puis les redistribuent via le proxy local. base64 renvoie une Data URI intégrée. Lorsque le proxy d’images Imagine Public est activé, les images imagine-public renvoyées par le WebSocket sont également servies via le proxy local." },
+ "imaginePublicImageProxy": { "label": "Proxy d’images Imagine Public", "desc": "Lorsque cette option est activée, les images imagine-public renvoyées par le WebSocket sont téléchargées sur le serveur puis distribuées via le proxy local. Lorsqu’elle est désactivée, les images publiques sont renvoyées directement." },
"videoFormat": { "label": "Format de sortie vidéo" },
"refreshEnabled": { "label": "Activer l’actualisation du quota", "desc": "Lorsqu’il est activé, le système passe automatiquement en mode d’actualisation du quota. Lorsqu’il est désactivé, il passe automatiquement en mode de relance automatique.", "help": "Activé : mode d’actualisation du quota. Le scheduler synchronise périodiquement le quota réel et la sélection utilise un score.\nDésactivé : mode de relance automatique. Aucune sonde upstream active, sélection aléatoire et changement automatique de compte en cas d’erreur jusqu’à 5 relances.\nRecommandation : désactivez-le au-delà de 10 000 comptes pour éviter de déclencher des 429 upstream par les sondes actives." },
"maxInflight": { "label": "Maximum en cours par compte", "desc": "Nombre maximal de requêtes simultanées par compte. Les comptes à cette limite sont ignorés par la sélection (partagé par les deux modes)." },
diff --git a/app/statics/i18n/ja.json b/app/statics/i18n/ja.json
index 9751a1853..41e42699f 100644
--- a/app/statics/i18n/ja.json
+++ b/app/statics/i18n/ja.json
@@ -64,6 +64,8 @@
"imageRequired": "画像編集には少なくとも 1 枚の参照画像が必要です",
"imageOnly": "画像編集では画像ファイルのみアップロードできます",
"videoImageOnly": "動画生成では参照として画像のみアップロードできます",
+ "imageProxyRequired": "画像を読み込めませんでした。APP ベース URL を設定し、画像の返却形式を local_url、local_md、または base64 に変更してください。",
+ "videoProxyRequired": "動画の読み込みで 403 が返されました。管理ページで APP ベース URL を設定し、動画の返却形式をローカルプロキシモード(local_url または local_html)に変更してから再試行してください。",
"requestFailed": "リクエストに失敗しました",
"initFailed": "チャットページの初期化に失敗しました"
}
@@ -393,7 +395,8 @@
"enableNsfw": { "label": "NSFW 生成を許可" },
"showSearchSources": { "label": "コンテンツにソースを追加", "desc": "検索ソースは常に search_sources フィールドに出力されます。このオプションは、テキスト解析クライアントとの互換性のために ## Sources セクションをコンテンツに追加するかどうかを制御します。" },
"customInstruction": { "label": "グローバル補助指示" },
- "imageFormat": { "label": "画像出力形式" },
+ "imageFormat": { "label": "画像出力形式", "desc": "grok_url と grok_md は既定では Grok CDN のネイティブ URL を直接返します。local_* モードでは、先にサーバーへダウンロードしてからローカルプロキシ経由で再配信します。base64 は埋め込み Data URI を返します。Imagine Public 画像プロキシを有効にすると、WebSocket が返す imagine-public 画像もローカルプロキシ経由になります。" },
+ "imaginePublicImageProxy": { "label": "Imagine Public 画像プロキシ", "desc": "有効にすると、WebSocket が返す imagine-public 画像をサーバーにダウンロードし、ローカルプロキシ経由で配信します。無効の場合、公開画像はそのまま返します。" },
"videoFormat": { "label": "動画出力形式" },
"refreshEnabled": { "label": "クォータ更新を有効化", "desc": "オンにすると自動的にクォータ更新モードへ切り替わり、オフにすると自動的に自動再試行モードへ切り替わります。", "help": "オン:クォータ更新モード。scheduler が実クォータを定期同期し、スコアでアカウントを選択します。\nオフ:自動再試行モード。upstream を能動的に確認せず、ランダム選択し、エラー時は最大 5 回まで別アカウントへ自動切替します。\n推奨:1 万件以上のアカウントではオフにして、能動確認による upstream 429 を避けてください。" },
"maxInflight": { "label": "アカウントごとの同時実行上限", "desc": "1 アカウントで同時に処理中にできるリクエスト数の上限です。上限に達したアカウントは選択時にスキップされます(両モード共通)。" },
diff --git a/app/statics/i18n/zh.json b/app/statics/i18n/zh.json
index 1e53feed3..1c508ada5 100644
--- a/app/statics/i18n/zh.json
+++ b/app/statics/i18n/zh.json
@@ -83,6 +83,8 @@
"imageRequired": "图像编辑至少需要上传一张参考图",
"imageOnly": "图像编辑只支持上传图片",
"videoImageOnly": "视频生成只支持上传图片作为参考图",
+ "imageProxyRequired": "图片加载失败。请先设置 APP 访问地址,并将图片返回格式改为 local_url、local_md 或 base64。",
+ "videoProxyRequired": "视频加载 403,请前往管理页面设置 APP 访问地址后,将频返回格式改为本地代理( local_url 或 local_html)模式后重试。",
"requestFailed": "请求失败",
"initFailed": "聊天页面初始化失败"
}
@@ -441,7 +443,11 @@
},
"imageFormat": {
"label": "图片返回格式",
- "desc": "grok_url / grok_md 直接返回 Grok CDN 地址,客户端需具备 CDN 访问能力;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。"
+ "desc": "grok_url / grok_md 默认直接返回 Grok CDN 地址;local_* 模式会先下载到服务端,再通过本地 URL 代理分发;base64 会以内嵌 Data URI 返回。开启 Imagine Public 图片代理后,WebSocket 返回的 imagine-public 图片也会本地代理。"
+ },
+ "imaginePublicImageProxy": {
+ "label": "Imagine Public 图片代理",
+ "desc": "开启后将 WebSocket 返回的 imagine-public 图片下载到服务端,再通过本地 URL 代理分发;关闭时保持公开图片直返。"
},
"videoFormat": {
"label": "视频返回格式",
diff --git a/app/statics/js/webui/chat.js b/app/statics/js/webui/chat.js
index 1c26c70e6..ff3481f17 100644
--- a/app/statics/js/webui/chat.js
+++ b/app/statics/js/webui/chat.js
@@ -316,6 +316,64 @@
});
}
+ function isNativeGrokMediaUrl(value) {
+ try {
+ const url = new URL(value, window.location.origin);
+ return /(^|\.)grok\.com$/i.test(url.hostname);
+ } catch {
+ return false;
+ }
+ }
+
+ function showMediaProxyHint(media, type) {
+ if (!media || media.nextElementSibling?.classList?.contains('msg-media-error')) return;
+ const hint = document.createElement('div');
+ hint.className = 'msg-media-error';
+ if (type === 'image') {
+ hint.textContent = text(
+ 'webui.chat.errors.imageProxyRequired',
+ 'Image failed to load. Set APP Base URL and change image output format to local_url, local_md, or base64.'
+ );
+ } else {
+ hint.textContent = text(
+ 'webui.chat.errors.videoProxyRequired',
+ 'Video loading returned 403. Go to the admin page, set the APP Base URL, then change the video output format to local proxy mode (local_url or local_html) and retry.'
+ );
+ }
+ media.insertAdjacentElement('afterend', hint);
+ }
+
+ function clearMediaProxyHint(media) {
+ const hint = media && media.nextElementSibling;
+ if (hint?.classList?.contains('msg-media-error')) hint.remove();
+ }
+
+ function enhanceMediaElements(card) {
+ card.querySelectorAll('video').forEach((video) => {
+ if (video.dataset.proxyHintBound === '1') return;
+ video.dataset.proxyHintBound = '1';
+ const onVideoError = () => showMediaProxyHint(video, 'video');
+ video.addEventListener('error', onVideoError);
+ video.querySelectorAll('source').forEach((source) => {
+ source.addEventListener('error', onVideoError);
+ });
+ video.addEventListener('loadedmetadata', () => clearMediaProxyHint(video));
+ if (video.error) showMediaProxyHint(video, 'video');
+ });
+
+ card.querySelectorAll('img').forEach((img) => {
+ if (img.dataset.proxyHintBound === '1') return;
+ img.dataset.proxyHintBound = '1';
+ img.addEventListener('error', () => {
+ if (isNativeGrokMediaUrl(img.currentSrc || img.src)) showMediaProxyHint(img, 'image');
+ });
+ img.addEventListener('load', () => clearMediaProxyHint(img));
+ if (img.complete && img.naturalWidth === 0 && isNativeGrokMediaUrl(img.currentSrc || img.src)) {
+ showMediaProxyHint(img, 'image');
+ }
+ });
+ }
+
function extractTextContent(content) {
if (typeof content === 'string') return content;
if (!Array.isArray(content)) return '';
@@ -461,6 +519,7 @@
)).join(''));
}
card.innerHTML = parts.join('') || '