From de19d42d34bcbe3e751f0b3c58cb125c9ca930ce Mon Sep 17 00:00:00 2001 From: skyslity Date: Mon, 22 Dec 2025 15:31:33 +0800 Subject: [PATCH 1/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=E5=BC=95=E6=93=8E=E6=A8=A1=E5=9E=8B=E7=9A=84=E6=94=AF?= =?UTF-8?q?=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/generators/openai_compatible.py | 30 +++++++++++++++++++------ backend/utils/text_client.py | 22 +++++++++++++++--- 2 files changed, 42 insertions(+), 10 deletions(-) diff --git a/backend/generators/openai_compatible.py b/backend/generators/openai_compatible.py index 2fdbf76..7517e1a 100644 --- a/backend/generators/openai_compatible.py +++ b/backend/generators/openai_compatible.py @@ -69,22 +69,38 @@ def __init__(self, config: Dict[str, Any]): "解决方案:在系统设置页面编辑该服务商,填写 Base URL" ) - # 规范化 base_url:去除末尾 /v1 - self.base_url = self.base_url.rstrip('/').rstrip('/v1') + # 规范化 base_url 并识别 API 版本(OpenAI 默认 v1,火山引擎 Ark/Doubao 为 v3) + raw_base_url = (self.base_url or '').rstrip('/') + + # 识别 Doubao/Ark:如果 base_url 以 /v3 结尾,或域名包含 volces/ark,则使用 v3 + if raw_base_url.endswith('/v3') or ('volces' in raw_base_url.lower()) or ('ark' in raw_base_url.lower()): + self.api_version = 'v3' + else: + self.api_version = 'v1' + + # 去掉末尾版本号,保留基础路径(例如 https://ark.../api) + if raw_base_url.endswith('/v1') or raw_base_url.endswith('/v3'): + self.base_url = raw_base_url.rsplit('/', 1)[0] + else: + self.base_url = raw_base_url # 默认模型 self.default_model = config.get('model', 'dall-e-3') # API 端点类型: 支持完整路径 (如 '/v1/images/generations') 或简写 ('images', 'chat') - endpoint_type = config.get('endpoint_type', '/v1/images/generations') - # 兼容旧的简写格式 + default_endpoint = f"/{self.api_version}/images/generations" + endpoint_type = config.get('endpoint_type', default_endpoint) + # 兼容简写:根据识别出的版本拼接正确路径 if endpoint_type == 'images': - endpoint_type = '/v1/images/generations' + endpoint_type = f"/{self.api_version}/images/generations" elif endpoint_type == 'chat': - endpoint_type = '/v1/chat/completions' + endpoint_type = f"/{self.api_version}/chat/completions" self.endpoint_type = endpoint_type - logger.info(f"OpenAICompatibleGenerator 初始化完成: base_url={self.base_url}, model={self.default_model}, endpoint={self.endpoint_type}") + logger.info( + f"OpenAICompatibleGenerator 初始化完成: base_url={self.base_url}, api_version={self.api_version}, " + f"model={self.default_model}, endpoint={self.endpoint_type}" + ) def validate_config(self) -> bool: """验证配置""" diff --git a/backend/utils/text_client.py b/backend/utils/text_client.py index 2bd2be6..bd41aa9 100644 --- a/backend/utils/text_client.py +++ b/backend/utils/text_client.py @@ -48,10 +48,26 @@ def __init__(self, api_key: str = None, base_url: str = None, endpoint_type: str "解决方案:在系统设置页面编辑文本生成服务商,填写 API Key" ) - self.base_url = (base_url or "https://api.openai.com").rstrip('/').rstrip('/v1') + # 规范化 base_url 并识别 API 版本(OpenAI 默认 v1,火山引擎 Ark/Doubao 为 v3) + raw_base_url = (base_url or "https://api.openai.com").rstrip('/') + if raw_base_url.endswith('/v3') or ('volces' in raw_base_url.lower()) or ('ark' in raw_base_url.lower()): + self.api_version = 'v3' + else: + self.api_version = 'v1' - # 支持自定义端点路径 - endpoint = endpoint_type or '/v1/chat/completions' + # 去掉末尾版本号,保留基础路径(例如 https://ark.../api) + if raw_base_url.endswith('/v1') or raw_base_url.endswith('/v3'): + self.base_url = raw_base_url.rsplit('/', 1)[0] + else: + self.base_url = raw_base_url + + # 支持自定义端点路径;简写会自动映射至正确版本 + default_endpoint = f"/{self.api_version}/chat/completions" + endpoint = endpoint_type or default_endpoint + if endpoint == 'chat': + endpoint = f"/{self.api_version}/chat/completions" + elif endpoint == 'images': + endpoint = f"/{self.api_version}/images/generations" # 确保端点以 / 开头 if not endpoint.startswith('/'): endpoint = '/' + endpoint From 482bb2cbadbddc52f699f431cacc28e0fa36cc52 Mon Sep 17 00:00:00 2001 From: skyslity Date: Mon, 22 Dec 2025 16:18:49 +0800 Subject: [PATCH 2/7] =?UTF-8?q?=E5=A2=9E=E5=8A=A0=E5=AF=B9=E7=81=AB?= =?UTF-8?q?=E5=B1=B1=E5=BC=95=E6=93=8E=E5=8D=B3=E6=A2=A6=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84=E6=94=AF=E6=8C=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/generators/image_api.py | 114 +++++++++++++++++++++++--------- backend/routes/config_routes.py | 22 ++++-- 2 files changed, 100 insertions(+), 36 deletions(-) diff --git a/backend/generators/image_api.py b/backend/generators/image_api.py index 62e99b6..fc55924 100644 --- a/backend/generators/image_api.py +++ b/backend/generators/image_api.py @@ -36,21 +36,36 @@ class ImageApiGenerator(ImageGeneratorBase): def __init__(self, config: Dict[str, Any]): super().__init__(config) logger.debug("初始化 ImageApiGenerator...") - self.base_url = config.get('base_url', 'https://api.example.com').rstrip('/').rstrip('/v1') + raw_base_url = (config.get('base_url') or 'https://api.example.com').rstrip('/') + if raw_base_url.endswith('/v3') or ('volces' in raw_base_url.lower()) or ('ark' in raw_base_url.lower()): + self.api_version = 'v3' + else: + self.api_version = 'v1' + + if raw_base_url.endswith('/v1') or raw_base_url.endswith('/v3'): + self.base_url = raw_base_url.rsplit('/', 1)[0] + else: + self.base_url = raw_base_url + self.model = config.get('model', 'default-model') self.default_aspect_ratio = config.get('default_aspect_ratio', '3:4') self.image_size = config.get('image_size', '4K') # 支持自定义端点路径 - endpoint_type = config.get('endpoint_type', '/v1/images/generations') + endpoint_type = config.get('endpoint_type', f'/{self.api_version}/images/generations') # 兼容旧的简写格式 if endpoint_type == 'images': - endpoint_type = '/v1/images/generations' + endpoint_type = f'/{self.api_version}/images/generations' elif endpoint_type == 'chat': - endpoint_type = '/v1/chat/completions' + endpoint_type = f'/{self.api_version}/chat/completions' # 确保以 / 开头 if not endpoint_type.startswith('/'): endpoint_type = '/' + endpoint_type + + if endpoint_type.startswith('/v1/') and self.api_version == 'v3': + endpoint_type = '/v3/' + endpoint_type[len('/v1/'):] + elif endpoint_type.startswith('/v3/') and self.api_version == 'v1': + endpoint_type = '/v1/' + endpoint_type[len('/v3/'):] self.endpoint_type = endpoint_type logger.info(f"ImageApiGenerator 初始化完成: base_url={self.base_url}, model={self.model}, endpoint={self.endpoint_type}") @@ -299,50 +314,85 @@ def _generate_via_chat_api( result = response.json() logger.debug(f"Chat API 响应: {str(result)[:500]}") - # 解析响应 - if "choices" in result and len(result["choices"]) > 0: - choice = result["choices"][0] - if "message" in choice and "content" in choice["message"]: - content = choice["message"]["content"] + extracted = self._extract_image_from_chat_result(result) + if extracted is not None: + return extracted + + raise Exception( + "❌ 无法从 Chat API 响应中提取图片数据\n\n" + f"【响应内容】\n{str(result)[:500]}\n\n" + "【可能原因】\n" + "1. 该模型不支持图片生成\n" + "2. 响应格式与预期不符\n" + "3. 提示词被安全过滤\n\n" + "【解决方案】\n" + "1. 确认模型名称正确\n" + "2. 修改提示词后重试" + ) + + def _extract_image_from_chat_result(self, result: Any) -> Optional[bytes]: + import re + + if isinstance(result, dict): + data = result.get('data') + if isinstance(data, list) and data: + item = data[0] if isinstance(data[0], dict) else None + if item: + if 'b64_json' in item and isinstance(item['b64_json'], str): + b64_string = item['b64_json'] + if b64_string.startswith('data:'): + b64_string = b64_string.split(',', 1)[1] + return base64.b64decode(b64_string) + if 'url' in item and isinstance(item['url'], str): + return self._download_image(item['url'].strip()) + + choices = result.get('choices') + if isinstance(choices, list) and choices: + choice0 = choices[0] if isinstance(choices[0], dict) else None + message = (choice0 or {}).get('message') + content = (message or {}).get('content') if isinstance(message, dict) else None + + if isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + part_type = part.get('type') + if part_type in {'image_url', 'image'}: + image_url = part.get('image_url') + if isinstance(image_url, dict): + url = image_url.get('url') + if isinstance(url, str) and url.strip(): + url = url.strip() + if url.startswith('data:image'): + return base64.b64decode(url.split(',', 1)[1]) + if url.startswith('http://') or url.startswith('https://'): + return self._download_image(url) if isinstance(content, str): - # Markdown 图片链接: ![xxx](url) + text = content.strip() + pattern = r'!\[.*?\]\((https?://[^\s\)]+)\)' - urls = re.findall(pattern, content) + urls = re.findall(pattern, text) if urls: logger.info(f"从 Markdown 提取到 {len(urls)} 张图片,下载第一张...") return self._download_image(urls[0]) - # Markdown 图片 Base64: ![xxx](data:image/...) base64_pattern = r'!\[.*?\]\((data:image\/[^;]+;base64,[^\s\)]+)\)' - base64_urls = re.findall(base64_pattern, content) + base64_urls = re.findall(base64_pattern, text) if base64_urls: logger.info("从 Markdown 提取到 Base64 图片数据") - base64_data = base64_urls[0].split(",")[1] + base64_data = base64_urls[0].split(",", 1)[1] return base64.b64decode(base64_data) - # 纯 Base64 data URL - if content.startswith("data:image"): + if text.startswith('data:image') and ',' in text: logger.info("检测到 Base64 图片数据") - base64_data = content.split(",")[1] - return base64.b64decode(base64_data) + return base64.b64decode(text.split(',', 1)[1]) - # 纯 URL - if content.startswith("http://") or content.startswith("https://"): + if text.startswith('http://') or text.startswith('https://'): logger.info("检测到图片 URL") - return self._download_image(content.strip()) + return self._download_image(text) - raise Exception( - "❌ 无法从 Chat API 响应中提取图片数据\n\n" - f"【响应内容】\n{str(result)[:500]}\n\n" - "【可能原因】\n" - "1. 该模型不支持图片生成\n" - "2. 响应格式与预期不符\n" - "3. 提示词被安全过滤\n\n" - "【解决方案】\n" - "1. 确认模型名称正确\n" - "2. 修改提示词后重试" - ) + return None def _download_image(self, url: str) -> bytes: """下载图片并返回二进制数据""" diff --git a/backend/routes/config_routes.py b/backend/routes/config_routes.py index 4f58eaf..a829753 100644 --- a/backend/routes/config_routes.py +++ b/backend/routes/config_routes.py @@ -364,8 +364,8 @@ def _test_openai_compatible(config: dict, test_prompt: str) -> dict: """测试 OpenAI 兼容接口""" import requests - base_url = config['base_url'].rstrip('/').rstrip('/v1') if config.get('base_url') else 'https://api.openai.com' - url = f"{base_url}/v1/chat/completions" + base_url, api_version = _normalize_base_url_and_version(config.get('base_url')) + url = f"{base_url}/{api_version}/chat/completions" payload = { "model": config.get('model') or 'gpt-3.5-turbo', @@ -396,8 +396,8 @@ def _test_image_api(config: dict) -> dict: """测试图片 API 连接""" import requests - base_url = config['base_url'].rstrip('/').rstrip('/v1') if config.get('base_url') else 'https://api.openai.com' - url = f"{base_url}/v1/models" + base_url, api_version = _normalize_base_url_and_version(config.get('base_url')) + url = f"{base_url}/{api_version}/models" response = requests.get( url, @@ -426,3 +426,17 @@ def _check_response(result_text: str) -> dict: "success": True, "message": f"连接成功,但响应内容不符合预期: {result_text[:100]}" } + + +def _normalize_base_url_and_version(base_url: str | None) -> tuple[str, str]: + raw = (base_url or 'https://api.openai.com').rstrip('/') + lowered = raw.lower() + if raw.endswith('/v3') or ('volces' in lowered) or ('ark' in lowered): + api_version = 'v3' + else: + api_version = 'v1' + + if raw.endswith('/v1') or raw.endswith('/v3'): + raw = raw.rsplit('/', 1)[0] + + return raw, api_version From a0bbb8156c802aa321003036fc73945dce1fd9cf Mon Sep 17 00:00:00 2001 From: SkySlity <52128805+SkySlity@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:16:03 +0800 Subject: [PATCH 3/7] Update docker-publish.yml MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 自己打包先测试一下 --- .github/workflows/docker-publish.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index 7bd9fbe..a234591 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: env: - DOCKER_USERNAME: histonemax + DOCKER_USERNAME: skyslity IMAGE_NAME: redink jobs: @@ -23,11 +23,12 @@ jobs: - name: 设置 Docker Buildx uses: docker/setup-buildx-action@v3 - - name: 登录 Docker Hub + - name: 登录GHCR uses: docker/login-action@v3 with: - username: ${{ env.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + registry: ghcr.io # 固定GHCR的仓库地址 + username: ${{ github.actor }} # 自动获取当前GitHub用户名(无需手动配置) + password: ${{ secrets.GITHUB_TOKEN }} - name: 提取 Docker 元数据 id: meta From b0c06a55327662d7b539e843ed2ebbc99df679b3 Mon Sep 17 00:00:00 2001 From: SkySlity <52128805+SkySlity@users.noreply.github.com> Date: Tue, 23 Dec 2025 08:25:43 +0800 Subject: [PATCH 4/7] Update docker-publish.yml --- .github/workflows/docker-publish.yml | 23 +++++++++++++---------- 1 file changed, 13 insertions(+), 10 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a234591..a8e0355 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,15 +6,18 @@ on: - main tags: - 'v*' - workflow_dispatch: + workflow_dispatch: # 保留手动触发功能 env: - DOCKER_USERNAME: skyslity - IMAGE_NAME: redink + IMAGE_NAME: redink # 仅保留镜像名,移除多余的DOCKER_USERNAME jobs: build-and-push: runs-on: ubuntu-latest + # GHCR推送必需:授予Packages写入权限和代码读取权限 + permissions: + packages: write + contents: read steps: - name: Checkout 代码 @@ -26,18 +29,18 @@ jobs: - name: 登录GHCR uses: docker/login-action@v3 with: - registry: ghcr.io # 固定GHCR的仓库地址 - username: ${{ github.actor }} # 自动获取当前GitHub用户名(无需手动配置) - password: ${{ secrets.GITHUB_TOKEN }} + registry: ghcr.io # 固定GHCR仓库地址 + username: ${{ github.actor }} # GitHub自动获取用户名,无需手动配置 + password: ${{ secrets.GITHUB_TOKEN }} # 自动提供的令牌,无需手动添加Secrets - - name: 提取 Docker 元数据 + - name: 提取 Docker 元数据(适配GHCR格式) id: meta uses: docker/metadata-action@v5 with: - images: ${{ env.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }} + # 关键修正:GHCR镜像格式固定为 ghcr.io/[GitHub用户名]/[镜像名] + images: ghcr.io/${{ github.actor }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch - type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} @@ -47,7 +50,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - platforms: linux/amd64,linux/arm64 + platforms: linux/amd64,linux/arm64 # 保留多架构构建(可选) push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From e534992472c19aa146ca1f7884e1403903ecf88f Mon Sep 17 00:00:00 2001 From: skyslity Date: Tue, 23 Dec 2025 16:22:16 +0800 Subject: [PATCH 5/7] =?UTF-8?q?=E5=8E=BB=E6=8E=89=E6=A8=A1=E5=9E=8B?= =?UTF-8?q?=E7=9A=84AI=20=E6=B0=B4=E5=8D=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/generators/image_api.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/generators/image_api.py b/backend/generators/image_api.py index fc55924..fcf96f9 100644 --- a/backend/generators/image_api.py +++ b/backend/generators/image_api.py @@ -148,7 +148,8 @@ def _generate_via_images_api( "prompt": prompt, "response_format": "b64_json", "aspect_ratio": aspect_ratio, - "image_size": self.image_size + "image_size": self.image_size, + "watermark": False } # 收集所有参考图片 From 4310a0034d13c24846d2952bdbc99ee6be3b6a4f Mon Sep 17 00:00:00 2001 From: skyslity Date: Tue, 23 Dec 2025 16:35:51 +0800 Subject: [PATCH 6/7] Revert "Update docker-publish.yml" This reverts commit b0c06a55327662d7b539e843ed2ebbc99df679b3. --- .github/workflows/docker-publish.yml | 23 ++++++++++------------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a8e0355..a234591 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -6,18 +6,15 @@ on: - main tags: - 'v*' - workflow_dispatch: # 保留手动触发功能 + workflow_dispatch: env: - IMAGE_NAME: redink # 仅保留镜像名,移除多余的DOCKER_USERNAME + DOCKER_USERNAME: skyslity + IMAGE_NAME: redink jobs: build-and-push: runs-on: ubuntu-latest - # GHCR推送必需:授予Packages写入权限和代码读取权限 - permissions: - packages: write - contents: read steps: - name: Checkout 代码 @@ -29,18 +26,18 @@ jobs: - name: 登录GHCR uses: docker/login-action@v3 with: - registry: ghcr.io # 固定GHCR仓库地址 - username: ${{ github.actor }} # GitHub自动获取用户名,无需手动配置 - password: ${{ secrets.GITHUB_TOKEN }} # 自动提供的令牌,无需手动添加Secrets + registry: ghcr.io # 固定GHCR的仓库地址 + username: ${{ github.actor }} # 自动获取当前GitHub用户名(无需手动配置) + password: ${{ secrets.GITHUB_TOKEN }} - - name: 提取 Docker 元数据(适配GHCR格式) + - name: 提取 Docker 元数据 id: meta uses: docker/metadata-action@v5 with: - # 关键修正:GHCR镜像格式固定为 ghcr.io/[GitHub用户名]/[镜像名] - images: ghcr.io/${{ github.actor }}/${{ env.IMAGE_NAME }} + images: ${{ env.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }} tags: | type=ref,event=branch + type=ref,event=pr type=semver,pattern={{version}} type=semver,pattern={{major}}.{{minor}} type=semver,pattern={{major}} @@ -50,7 +47,7 @@ jobs: uses: docker/build-push-action@v5 with: context: . - platforms: linux/amd64,linux/arm64 # 保留多架构构建(可选) + platforms: linux/amd64,linux/arm64 push: true tags: ${{ steps.meta.outputs.tags }} labels: ${{ steps.meta.outputs.labels }} From 1fd607b372fee959e10c5984789cc1da9ae2e3d9 Mon Sep 17 00:00:00 2001 From: skyslity Date: Tue, 23 Dec 2025 16:36:57 +0800 Subject: [PATCH 7/7] Revert "Update docker-publish.yml" This reverts commit a0bbb8156c802aa321003036fc73945dce1fd9cf. --- .github/workflows/docker-publish.yml | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index a234591..7bd9fbe 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -9,7 +9,7 @@ on: workflow_dispatch: env: - DOCKER_USERNAME: skyslity + DOCKER_USERNAME: histonemax IMAGE_NAME: redink jobs: @@ -23,12 +23,11 @@ jobs: - name: 设置 Docker Buildx uses: docker/setup-buildx-action@v3 - - name: 登录GHCR + - name: 登录 Docker Hub uses: docker/login-action@v3 with: - registry: ghcr.io # 固定GHCR的仓库地址 - username: ${{ github.actor }} # 自动获取当前GitHub用户名(无需手动配置) - password: ${{ secrets.GITHUB_TOKEN }} + username: ${{ env.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - name: 提取 Docker 元数据 id: meta