diff --git a/.gitignore b/.gitignore index 5c79062..34b8d7a 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,10 @@ history/* .gemini/ /image_providers.yaml /text_providers.yaml +*_providers.yaml +*_providers.yml +!*_providers.yaml.example +!*_providers.yml.example # Logs *.log diff --git a/README.md b/README.md index 5f3ad8f..742285d 100644 --- a/README.md +++ b/README.md @@ -263,6 +263,13 @@ providers: ## 更新日志 +### v1.4.2 (2025-12-18) +- 增加摩搭社区Z-image模型并使用每日免费500次请求 + +### v1.4.1 (2025-12-17) +- 🔧 修复即梦AI端点无法从 `/v1/images/generations` 修改为 `/v3/images/generations` 的问题,支持即梦AI的 `doubao-seedream-4-5-251128` 模型 +- ✨ 新增通义万相 `wan2.6-t2i`(类型选择:通义万相 Wan2.6(文生图V2)) + ### v1.4.0 (2025-11-30) - 🏗️ 后端架构重构:拆分单体路由为模块化蓝图(history、images、generation、outline、config) - 🏗️ 前端组件重构:提取可复用组件(ImageGalleryModal、OutlineModal、ShowcaseBackground等) diff --git a/backend/app.py b/backend/app.py index 79fa19a..fe97a18 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,7 +1,7 @@ import logging import sys from pathlib import Path -from flask import Flask, send_from_directory +from flask import Flask, send_from_directory, request from flask_cors import CORS from backend.config import Config from backend.routes import register_routes @@ -78,6 +78,8 @@ def serve_index(): # 处理 Vue Router 的 HTML5 History 模式 @app.errorhandler(404) def fallback(e): + if request.path.startswith('/api/'): + return {"success": False, "error": "Not Found"}, 404 return send_from_directory(app.static_folder, 'index.html') else: @app.route('/') diff --git a/backend/generators/factory.py b/backend/generators/factory.py index 05b1d11..6897106 100644 --- a/backend/generators/factory.py +++ b/backend/generators/factory.py @@ -4,6 +4,8 @@ from .google_genai import GoogleGenAIGenerator from .openai_compatible import OpenAICompatibleGenerator from .image_api import ImageApiGenerator +from .wan26_t2i import Wan26T2IGenerator +from .modelscope_z_image import ModelScopeZImageGenerator class ImageGeneratorFactory: @@ -15,6 +17,8 @@ class ImageGeneratorFactory: 'openai': OpenAICompatibleGenerator, 'openai_compatible': OpenAICompatibleGenerator, 'image_api': ImageApiGenerator, + 'wan2.6-t2i': Wan26T2IGenerator, + 'modelscope_z_image': ModelScopeZImageGenerator, } @classmethod diff --git a/backend/generators/image_api.py b/backend/generators/image_api.py index 62e99b6..7973a74 100644 --- a/backend/generators/image_api.py +++ b/backend/generators/image_api.py @@ -36,13 +36,12 @@ 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') - 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') - + + # 原始 Base URL + base_url = (config.get('base_url', 'https://api.example.com') or 'https://api.example.com').strip().rstrip('/') + # 支持自定义端点路径 - endpoint_type = config.get('endpoint_type', '/v1/images/generations') + endpoint_type = (config.get('endpoint_type', '/v1/images/generations') or '/v1/images/generations').strip() # 兼容旧的简写格式 if endpoint_type == 'images': endpoint_type = '/v1/images/generations' @@ -52,6 +51,28 @@ def __init__(self, config: Dict[str, Any]): if not endpoint_type.startswith('/'): endpoint_type = '/' + endpoint_type self.endpoint_type = endpoint_type + + # 智能处理 Base URL: + # 如果 endpoint_type 是 /v3/xxx,且 base_url 结尾也是 /v3,则去掉 base_url 的 /v3 + # 避免拼接成 /v3/v3/xxx + + # 提取版本前缀 (e.g. /v1, /v3) + import re + version_match = re.search(r'^/(v\d+)', self.endpoint_type) + if version_match: + version_prefix = version_match.group(1) # e.g. /v3 + if base_url.endswith(version_prefix): + base_url = base_url[:-len(version_prefix)].rstrip('/') + + # 兼容旧逻辑:如果 base_url 结尾是 /v1 但 endpoint 也是 /v1 开头,去掉 base_url 的 + elif base_url.endswith('/v1') and self.endpoint_type.startswith('/v1'): + base_url = base_url[:-3].rstrip('/') + + self.base_url = 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') logger.info(f"ImageApiGenerator 初始化完成: base_url={self.base_url}, model={self.model}, endpoint={self.endpoint_type}") diff --git a/backend/generators/modelscope_z_image.py b/backend/generators/modelscope_z_image.py new file mode 100644 index 0000000..49432b6 --- /dev/null +++ b/backend/generators/modelscope_z_image.py @@ -0,0 +1,175 @@ +import logging +import re +import time +from typing import Dict, Any, Optional, List + +import requests + +from .base import ImageGeneratorBase + +logger = logging.getLogger(__name__) + + +class ModelScopeZImageGenerator(ImageGeneratorBase): + def __init__(self, config: Dict[str, Any]): + super().__init__(config) + + base_url = (config.get('base_url') or 'https://api-inference.modelscope.cn').strip().rstrip('/') + endpoint_type = (config.get('endpoint_type') or '/v1/images/generations').strip() + if not endpoint_type.startswith('/'): + endpoint_type = '/' + endpoint_type + + version_match = re.search(r'^/(v\d+)', endpoint_type) + if version_match: + version_prefix = '/' + version_match.group(1) + if base_url.endswith(version_prefix): + base_url = base_url[:-len(version_prefix)].rstrip('/') + elif base_url.endswith('/v1') and endpoint_type.startswith('/v1'): + base_url = base_url[:-3].rstrip('/') + + self.base_url = base_url + self.endpoint_type = endpoint_type + + self.model = config.get('model') or 'Tongyi-MAI/Z-Image-Turbo' + self.task_endpoint = (config.get('task_endpoint') or '/v1/tasks').strip() + if not self.task_endpoint.startswith('/'): + self.task_endpoint = '/' + self.task_endpoint + + self.poll_interval_seconds = float(config.get('poll_interval_seconds') or 3) + self.max_wait_seconds = float(config.get('max_wait_seconds') or 300) + self.max_prompt_chars = int(config.get('max_prompt_chars') or 1900) + + logger.info( + f"ModelScopeZImageGenerator 初始化完成: base_url={self.base_url}, model={self.model}, endpoint={self.endpoint_type}" + ) + + def _normalize_prompt(self, prompt: str) -> str: + text = (prompt or "").strip() + max_chars = self.max_prompt_chars + if max_chars < 100: + max_chars = 100 + if len(text) <= max_chars: + return text + return text[:max_chars].rstrip() + + def validate_config(self) -> bool: + if not self.api_key: + raise ValueError( + "ModelScope API Key 未配置。\n" + "解决方案:在系统设置页面编辑该服务商,填写 API Key" + ) + if not self.base_url: + raise ValueError( + "ModelScope Base URL 未配置。\n" + "解决方案:在系统设置页面编辑该服务商,填写 Base URL(例如 https://api-inference.modelscope.cn)" + ) + return True + + def generate_image( + self, + prompt: str, + model: Optional[str] = None, + **kwargs + ) -> bytes: + self.validate_config() + + model_id = (model or self.model).strip() + if not model_id: + raise ValueError( + "ModelScope 模型未配置。\n" + "解决方案:在系统设置页面编辑该服务商,填写模型(例如 Tongyi-MAI/Z-Image-Turbo)" + ) + + normalized_prompt = self._normalize_prompt(prompt) + create_url = f"{self.base_url}{self.endpoint_type}" + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "X-ModelScope-Async-Mode": "true", + } + payload: Dict[str, Any] = { + "model": model_id, + "prompt": normalized_prompt, + "n": 1, + "size": kwargs.get("size") or self.config.get("size") or "1024x1024", + } + + logger.info(f"ModelScope Z-Image 提交任务: model={model_id}, url={create_url}") + response = requests.post(create_url, headers=headers, json=payload, timeout=60) + + if response.status_code != 200: + detail = response.text[:800] + raise Exception( + f"ModelScope 图片生成请求失败 (状态码: {response.status_code})\n" + f"请求地址: {create_url}\n" + f"错误详情: {detail}" + ) + + data = response.json() or {} + task_id = data.get("task_id") or data.get("id") + if not task_id: + raise Exception( + "ModelScope 响应中未找到 task_id。\n" + f"响应片段: {str(data)[:800]}" + ) + + task_url = f"{self.base_url}{self.task_endpoint}/{task_id}" + status_headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + "X-ModelScope-Task-Type": "image_generation", + } + + deadline = time.time() + self.max_wait_seconds + last_status: Optional[str] = None + while True: + if time.time() > deadline: + raise Exception( + f"ModelScope 任务超时({self.max_wait_seconds}s)。task_id={task_id}, last_status={last_status}" + ) + + status_resp = requests.get(task_url, headers=status_headers, timeout=60) + if status_resp.status_code != 200: + detail = status_resp.text[:800] + raise Exception( + f"ModelScope 任务查询失败 (状态码: {status_resp.status_code})\n" + f"请求地址: {task_url}\n" + f"错误详情: {detail}" + ) + + task_data = status_resp.json() or {} + task_status = (task_data.get("task_status") or task_data.get("status") or "").upper() + last_status = task_status or last_status + + if task_status == "SUCCEED": + output_images = task_data.get("output_images") or [] + if not isinstance(output_images, list) or not output_images: + raise Exception( + "ModelScope 任务成功但未返回图片地址。\n" + f"响应片段: {str(task_data)[:800]}" + ) + image_url = output_images[0] + if not isinstance(image_url, str) or not image_url.strip(): + raise Exception( + "ModelScope 返回的图片地址无效。\n" + f"响应片段: {str(task_data)[:800]}" + ) + img_resp = requests.get(image_url, timeout=120) + if img_resp.status_code != 200: + raise Exception( + f"下载图片失败 (状态码: {img_resp.status_code})\n" + f"图片地址: {image_url}\n" + f"错误详情: {img_resp.text[:200]}" + ) + return img_resp.content + + if task_status == "FAILED": + error_msg = ( + task_data.get("message") + or task_data.get("error") + or task_data.get("output") + or "未知错误" + ) + raise Exception(f"ModelScope 图片生成失败: {error_msg}") + + time.sleep(self.poll_interval_seconds) diff --git a/backend/generators/wan26_t2i.py b/backend/generators/wan26_t2i.py new file mode 100644 index 0000000..c380076 --- /dev/null +++ b/backend/generators/wan26_t2i.py @@ -0,0 +1,191 @@ +"""通义万相 Wan2.6 文生图生成器""" +import logging +import base64 +import requests +from typing import Dict, Any, Optional + +from .base import ImageGeneratorBase + +logger = logging.getLogger(__name__) + + +def _aspect_ratio_to_size(aspect_ratio: str) -> str: + mapping = { + "1:1": "1280*1280", + "2:3": "800*1200", + "3:2": "1200*800", + "3:4": "960*1280", + "4:3": "1280*960", + "9:16": "720*1280", + "16:9": "1280*720", + "21:9": "1344*576", + } + return mapping.get(aspect_ratio, "1280*1280") + + +def _normalize_size(size: Optional[str], aspect_ratio: str) -> str: + if not size: + return _aspect_ratio_to_size(aspect_ratio) + + normalized = str(size).strip().lower().replace("x", "*") + normalized = normalized.replace("×", "*") + parts = [p for p in normalized.split("*") if p] + if len(parts) == 2 and parts[0].isdigit() and parts[1].isdigit(): + return f"{int(parts[0])}*{int(parts[1])}" + + return _aspect_ratio_to_size(aspect_ratio) + + +def _extract_image_url_or_b64(data: Dict[str, Any]) -> Dict[str, Optional[str]]: + output = (data.get("output") or {}) if isinstance(data, dict) else {} + + results = output.get("results") + if isinstance(results, list) and results: + first = results[0] or {} + if isinstance(first, dict): + return { + "url": first.get("url"), + "b64": first.get("b64_json") or first.get("b64") or first.get("base64"), + } + + choices = output.get("choices") + if isinstance(choices, list) and choices: + for choice in choices: + if not isinstance(choice, dict): + continue + message = choice.get("message") or {} + if not isinstance(message, dict): + continue + content = message.get("content") + if isinstance(content, list): + for part in content: + if not isinstance(part, dict): + continue + url = part.get("image") or part.get("image_url") or part.get("url") + b64 = part.get("b64_json") or part.get("b64") or part.get("base64") + if url or b64: + return {"url": url, "b64": b64} + elif isinstance(content, dict): + url = content.get("image") or content.get("image_url") or content.get("url") + b64 = content.get("b64_json") or content.get("b64") or content.get("base64") + if url or b64: + return {"url": url, "b64": b64} + + return {"url": None, "b64": None} + + +class Wan26T2IGenerator(ImageGeneratorBase): + """通义万相 Wan2.6 文生图生成器(同步接口)""" + + def __init__(self, config: Dict[str, Any]): + super().__init__(config) + + if not self.api_key: + raise ValueError( + "通义万相 API Key 未配置。\n" + "解决方案:在系统设置页面编辑该服务商,填写 API Key" + ) + + base_url = (config.get("base_url") or "https://dashscope.aliyuncs.com/api/v1").strip() + base_url = base_url.rstrip("/") + if "/services/aigc/multimodal-generation/generation" in base_url: + base_url = base_url.split("/services/aigc/multimodal-generation/generation", 1)[0].rstrip("/") + if base_url.endswith("/api"): + base_url = f"{base_url}/v1" + elif "/api/" not in base_url and "dashscope" in base_url: + base_url = f"{base_url}/api/v1" + self.base_url = base_url + self.endpoint_path = "/services/aigc/multimodal-generation/generation" + + self.model = config.get("model") or "wan2.6-t2i" + self.default_aspect_ratio = config.get("default_aspect_ratio", "3:4") + self.prompt_extend = bool(config.get("prompt_extend", True)) + self.watermark = bool(config.get("watermark", False)) + self.default_timeout_seconds = int(config.get("timeout_seconds", 120)) + + logger.info(f"Wan26T2IGenerator 初始化完成: base_url={self.base_url}, model={self.model}") + + def validate_config(self) -> bool: + if not self.api_key: + raise ValueError( + "通义万相 API Key 未配置。\n" + "解决方案:在系统设置页面编辑该服务商,填写 API Key" + ) + return True + + def generate_image( + self, + prompt: str, + aspect_ratio: Optional[str] = None, + model: Optional[str] = None, + size: Optional[str] = None, + negative_prompt: str = "", + prompt_extend: Optional[bool] = None, + watermark: Optional[bool] = None, + **kwargs + ) -> bytes: + self.validate_config() + + if not aspect_ratio: + aspect_ratio = self.default_aspect_ratio + + if not model: + model = self.model + + final_size = _normalize_size(size, aspect_ratio) + final_prompt_extend = self.prompt_extend if prompt_extend is None else bool(prompt_extend) + final_watermark = self.watermark if watermark is None else bool(watermark) + + url = f"{self.base_url}{self.endpoint_path}" + payload = { + "model": model, + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": prompt}], + } + ] + }, + "parameters": { + "negative_prompt": negative_prompt or "", + "prompt_extend": final_prompt_extend, + "watermark": final_watermark, + "n": 1, + "size": final_size, + }, + } + + headers = { + "Authorization": f"Bearer {self.api_key}", + "Content-Type": "application/json", + } + + logger.info(f"通义万相生成图片: model={model}, size={final_size}") + resp = requests.post(url, headers=headers, json=payload, timeout=self.default_timeout_seconds) + + if resp.status_code != 200: + detail = (resp.text or "")[:500] + raise Exception( + f"通义万相请求失败 (HTTP {resp.status_code})\n" + f"错误详情: {detail}\n" + f"请求地址: {url}" + ) + + data = resp.json() if resp.content else {} + extracted = _extract_image_url_or_b64(data) + image_url = extracted.get("url") + b64_data = extracted.get("b64") + + if image_url: + img_resp = requests.get(image_url, timeout=60) + if img_resp.status_code != 200: + raise Exception(f"通义万相图片下载失败 (HTTP {img_resp.status_code})") + return img_resp.content + + if b64_data: + if isinstance(b64_data, str) and b64_data.startswith("data:"): + b64_data = b64_data.split(",", 1)[1] + return base64.b64decode(b64_data) + + raise Exception(f"通义万相响应中未找到图片结果: {str(data)[:500]}") diff --git a/backend/prompts/image_prompt.txt b/backend/prompts/image_prompt.txt index 843d628..21b7882 100644 --- a/backend/prompts/image_prompt.txt +++ b/backend/prompts/image_prompt.txt @@ -1,4 +1,4 @@ -请生成一张小红书风格的图文内容图片。 +请生成一张小红书卡通风格的图文内容图片。 【合规特别注意的】注意不要带有任何小红书的logo,不要有右下角的用户id以及logo 【合规特别注意的】用户给到的参考图片里如果有水印和logo(尤其是注意右下角,左上角),请一定要去掉 @@ -15,7 +15,7 @@ 1. 整体风格 - 小红书爆款图文风格 -- 清新、精致、有设计感 +- 卡通、清新、精致、有设计感 - 适合年轻人审美 - 配色和谐,视觉吸引力强 @@ -54,7 +54,7 @@ 5. 技术规格 - 竖版 3:4 比例(小红书标准) -- 高清画质 +- 高清4k画质 - 适合手机屏幕查看 - 所有文字内容必须完整呈现 - 【特别注意】无论是给到的图片还是参考文字,请仔细思考,让其符合正确的竖屏观看的排版,不能左右旋转或者是倒置。 @@ -74,4 +74,4 @@ {full_outline} --- -请根据以上要求,生成一张精美的小红书风格图片。请直接给出图片,不要有任何手机边框,或者是白色留边。 +请根据以上要求,生成一张精美的小红书卡通风格图片。请直接给出图片,不要有任何手机边框,或者是白色留边。 diff --git a/backend/routes/config_routes.py b/backend/routes/config_routes.py index 4f58eaf..1380eb8 100644 --- a/backend/routes/config_routes.py +++ b/backend/routes/config_routes.py @@ -127,7 +127,7 @@ def test_connection(): 测试服务商连接 请求体: - - type: 服务商类型(google_genai/google_gemini/openai_compatible/image_api) + - type: 服务商类型(google_genai/google_gemini/openai_compatible/image_api/wan2.6-t2i/modelscope_z_image) - provider_name: 服务商名称(用于从配置读取 API Key) - api_key: API Key(可选,若不提供则从配置读取) - base_url: Base URL(可选) @@ -149,7 +149,8 @@ def test_connection(): config = { 'api_key': data.get('api_key'), 'base_url': data.get('base_url'), - 'model': data.get('model') + 'model': data.get('model'), + 'endpoint_type': data.get('endpoint_type') } # 如果没有提供 api_key,从配置文件读取 @@ -269,6 +270,8 @@ def _load_provider_config(provider_type: str, provider_name: str, config: dict) config['base_url'] = saved.get('base_url') if not config['model']: config['model'] = saved.get('model') + if not config.get('endpoint_type'): + config['endpoint_type'] = saved.get('endpoint_type') return config @@ -297,6 +300,10 @@ def _test_provider_connection(provider_type: str, config: dict) -> dict: elif provider_type == 'image_api': return _test_image_api(config) + elif provider_type == 'wan2.6-t2i': + return _test_wan26_t2i(config) + elif provider_type == 'modelscope_z_image': + return _test_modelscope_z_image(config) else: raise ValueError(f"不支持的类型: {provider_type}") @@ -395,23 +402,199 @@ def _test_openai_compatible(config: dict, test_prompt: str) -> dict: def _test_image_api(config: dict) -> dict: """测试图片 API 连接""" import requests + # 改为直接使用用户提供的 endpoint_type 进行 POST 测试 + base_url = ((config.get('base_url') or 'https://api.openai.com')).strip().rstrip('/') + endpoint_type = ((config.get('endpoint_type') or '/v1/images/generations')).strip() + if not endpoint_type.startswith('/'): + endpoint_type = '/' + endpoint_type - 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/v3 + /v3/...) + try: + import re + m = re.match(r'^/(v\d+)(/.*)?$', endpoint_type) + version_prefix = m.group(1) if m else None + if version_prefix and base_url.endswith('/' + version_prefix): + base_url = base_url[:-(len(version_prefix) + 1)] + except Exception: + pass - response = requests.get( - url, - headers={'Authorization': f"Bearer {config['api_key']}"}, - timeout=30 - ) + url = f"{base_url}{endpoint_type}" + logger.info(f"正在测试图片 API 连接: {url}") + + try: + # 使用最小合法的 POST 负载进行连通性测试 + payload = { + "model": config.get("model") or "seedream-4.5", + "prompt": "测试连接:你好,红墨", + # 通用 OpenAI 兼容字段,避免过于特殊参数导致报错 + "n": 1, + "size": "1024x1024", + "response_format": "b64_json" + } + headers = { + 'Authorization': f"Bearer {config['api_key']}", + 'Content-Type': 'application/json' + } + + response = requests.post(url, json=payload, headers=headers, timeout=15) + logger.info(f"图片 API 测试响应: status={response.status_code}, url={url}, body={response.text[:200]}") + + if response.status_code == 200: + return { + "success": True, + "message": "连接成功!图片生成端点可用。" + } + elif response.status_code == 401: + return { + "success": False, + "error": "连接失败:API Key 无效 (HTTP 401)" + } + elif response.status_code in [400, 403]: + # 400/403 通常表示连通性正常,但参数/权限有问题 + return { + "success": True, + "message": f"连接连通(HTTP {response.status_code})。若生成失败,请检查模型名、参数或账户权限。" + } + else: + # 保留对 404 的严格失败提示 + raise Exception(f"HTTP {response.status_code}: 无法连接到服务商。请检查 Base URL、endpoint_type 和模型是否正确。\n测试路径: {url}") + + except Exception as e: + logger.error(f"图片 API 测试异常: {e}") + return { + "success": False, + "error": f"连接失败: {str(e)}" + } + + +def _test_wan26_t2i(config: dict) -> dict: + import requests + + base_url = (config.get("base_url") or "https://dashscope.aliyuncs.com/api/v1").strip().rstrip("/") + if "/services/aigc/multimodal-generation/generation" in base_url: + base_url = base_url.split("/services/aigc/multimodal-generation/generation", 1)[0].rstrip("/") + if base_url.endswith("/api"): + base_url = f"{base_url}/v1" + elif "/api/" not in base_url and "dashscope" in base_url: + base_url = f"{base_url}/api/v1" + url = f"{base_url}/services/aigc/multimodal-generation/generation" + + payload = { + "model": config.get("model") or "wan2.6-t2i", + "input": { + "messages": [ + { + "role": "user", + "content": [{"text": "测试连接:你好,红墨"}], + } + ] + }, + "parameters": { + "negative_prompt": "", + "prompt_extend": False, + "watermark": False, + "n": 1, + "size": "960*1280", + }, + } + + headers = { + "Authorization": f"Bearer {config['api_key']}", + "Content-Type": "application/json", + } + + response = requests.post(url, json=payload, headers=headers, timeout=30) + logger.info(f"通义万相测试响应: status={response.status_code}, url={url}, body={response.text[:200]}") if response.status_code == 200: + try: + data = response.json() if response.content else {} + output = data.get("output") or {} + results = output.get("results") or [] + choices = output.get("choices") or [] + + has_result = bool(results) + if not has_result and isinstance(choices, list) and choices: + for choice in choices: + if not isinstance(choice, dict): + continue + message = choice.get("message") or {} + content = message.get("content") if isinstance(message, dict) else None + if isinstance(content, list) and any(isinstance(p, dict) and p.get("image") for p in content): + has_result = True + break + + if has_result: + return { + "success": True, + "message": "连接成功!通义万相文生图端点可用。" + } + return { + "success": True, + "message": "连接成功(HTTP 200),但未返回图片结果字段。请在实际生成时验证。" + } + except Exception: + return { + "success": True, + "message": "连接成功(HTTP 200)。" + } + + +def _test_modelscope_z_image(config: dict) -> dict: + import requests + + base_url = ((config.get("base_url") or "https://api-inference.modelscope.cn")).strip().rstrip("/") + endpoint_type = ((config.get("endpoint_type") or "/v1/images/generations")).strip() + if not endpoint_type.startswith("/"): + endpoint_type = "/" + endpoint_type + + url = f"{base_url}{endpoint_type}" + headers = { + "Authorization": f"Bearer {config['api_key']}", + "Content-Type": "application/json", + "X-ModelScope-Async-Mode": "true", + } + payload = { + "model": config.get("model") or "Tongyi-MAI/Z-Image-Turbo", + "prompt": "测试连接:你好,红墨", + } + + response = requests.post(url, json=payload, headers=headers, timeout=20) + logger.info(f"ModelScope Z-Image 测试响应: status={response.status_code}, url={url}, body={response.text[:200]}") + + if response.status_code == 200: + try: + data = response.json() if response.content else {} + except Exception: + data = {} + + task_id = data.get("task_id") or data.get("id") + if task_id: + return { + "success": True, + "message": "连接成功!已创建异步任务。" + } return { "success": True, - "message": "连接成功!仅代表连接稳定,不确定是否可以稳定支持图片生成" + "message": "连接成功(HTTP 200)。" } - else: - raise Exception(f"HTTP {response.status_code}: {response.text[:200]}") + + if response.status_code == 401: + return { + "success": False, + "error": "连接失败:API Key 无效 (HTTP 401)" + } + + if response.status_code in [400, 403, 404]: + return { + "success": True, + "message": f"连接连通(HTTP {response.status_code})。若生成失败,请检查模型名、参数或账户权限。" + } + + return { + "success": False, + "error": f"连接失败:HTTP {response.status_code}: {response.text[:200]}" + } def _check_response(result_text: str) -> dict: diff --git a/backend/routes/history_routes.py b/backend/routes/history_routes.py index 9a0ec76..9eea45c 100644 --- a/backend/routes/history_routes.py +++ b/backend/routes/history_routes.py @@ -13,6 +13,7 @@ import io import zipfile import logging +import inspect from flask import Blueprint, request, jsonify, send_file from backend.services.history import get_history_service @@ -387,12 +388,17 @@ def download_history_zip(record_id): safe_title = _sanitize_filename(title) filename = f"{safe_title}.zip" - return send_file( - zip_buffer, - mimetype='application/zip', - as_attachment=True, - download_name=filename - ) + send_file_kwargs = { + "mimetype": "application/zip", + "as_attachment": True, + } + send_file_sig = inspect.signature(send_file) + if "download_name" in send_file_sig.parameters: + send_file_kwargs["download_name"] = filename + else: + send_file_kwargs["attachment_filename"] = filename + + return send_file(zip_buffer, **send_file_kwargs) except Exception as e: error_msg = str(e) @@ -450,10 +456,24 @@ def _sanitize_filename(title: str) -> str: Returns: str: 安全的文件名 """ - # 只保留字母、数字、空格、连字符和下划线 safe_title = "".join( c for c in title - if c.isalnum() or c in (' ', '-', '_', '\u4e00-\u9fff') + if c.isalnum() or c in (" ", "-", "_") ).strip() - return safe_title if safe_title else 'images' + safe_title = "_".join(safe_title.split()) + + if not safe_title: + return "images" + + max_bytes = 120 + encoded = safe_title.encode("utf-8", errors="ignore") + if len(encoded) <= max_bytes: + return safe_title + + truncated = encoded[:max_bytes] + while truncated and (truncated[-1] & 0b1100_0000) == 0b1000_0000: + truncated = truncated[:-1] + + result = truncated.decode("utf-8", errors="ignore").rstrip("_") + return result if result else "images" diff --git a/backend/services/image.py b/backend/services/image.py index 57b2651..6ec3c5d 100644 --- a/backend/services/image.py +++ b/backend/services/image.py @@ -46,7 +46,7 @@ def __init__(self, provider_name: str = None): self.provider_config = provider_config # 检查是否启用短 prompt 模式 - self.use_short_prompt = provider_config.get('short_prompt', False) + self.use_short_prompt = provider_config.get('short_prompt', False) or provider_type == 'modelscope_z_image' # 加载提示词模板 self.prompt_template = self._load_prompt_template() @@ -192,6 +192,19 @@ def _generate_single_image( model=self.provider_config.get('model', 'nano-banana-2'), reference_images=reference_images if reference_images else None, ) + elif self.provider_config.get('type') == 'wan2.6-t2i': + logger.debug(f" 使用 通义万相 Wan2.6 生成器") + image_data = self.generator.generate_image( + prompt=prompt, + aspect_ratio=self.provider_config.get('default_aspect_ratio', '3:4'), + model=self.provider_config.get('model', 'wan2.6-t2i'), + ) + elif self.provider_config.get('type') == 'modelscope_z_image': + logger.debug(" 使用 ModelScope Z-Image 生成器") + image_data = self.generator.generate_image( + prompt=prompt, + model=self.provider_config.get('model', 'Tongyi-MAI/Z-Image-Turbo'), + ) else: logger.debug(f" 使用 OpenAI 兼容生成器") image_data = self.generator.generate_image( diff --git a/backend/services/outline.py b/backend/services/outline.py index 923dc7f..5d85100 100644 --- a/backend/services/outline.py +++ b/backend/services/outline.py @@ -53,7 +53,7 @@ def _load_text_config(self) -> dict: def _get_client(self): """根据配置获取客户端""" - active_provider = self.text_config.get('active_provider', 'google_gemini') + active_provider = self.text_config.get('active_provider') providers = self.text_config.get('providers', {}) if not providers: @@ -65,14 +65,10 @@ def _get_client(self): "2. 或手动编辑 text_providers.yaml 文件" ) - if active_provider not in providers: - available = ', '.join(providers.keys()) - logger.error(f"文本服务商 [{active_provider}] 不存在,可用: {available}") - raise ValueError( - f"未找到文本生成服务商配置: {active_provider}\n" - f"可用的服务商: {available}\n" - "解决方案:在系统设置中选择一个可用的服务商" - ) + if not active_provider or active_provider not in providers: + fallback = next(iter(providers.keys())) + self.text_config['active_provider'] = fallback + active_provider = fallback provider_config = providers.get(active_provider, {}) @@ -83,6 +79,7 @@ def _get_client(self): "解决方案:在系统设置页面编辑该服务商,填写 API Key" ) + self.active_provider = active_provider logger.info(f"使用文本服务商: {active_provider} (type={provider_config.get('type')})") return get_text_chat_client(provider_config) @@ -143,8 +140,10 @@ def generate_outline( logger.debug(f"添加了 {len(images)} 张参考图片到提示词") # 从配置中获取模型参数 - active_provider = self.text_config.get('active_provider', 'google_gemini') + active_provider = getattr(self, 'active_provider', self.text_config.get('active_provider')) providers = self.text_config.get('providers', {}) + if not active_provider or active_provider not in providers: + active_provider = next(iter(providers.keys())) provider_config = providers.get(active_provider, {}) model = provider_config.get('model', 'gemini-2.0-flash-exp') diff --git a/docker/image_providers.yaml b/docker/image_providers.yaml index 8ddceb0..176208a 100644 --- a/docker/image_providers.yaml +++ b/docker/image_providers.yaml @@ -4,7 +4,7 @@ active_provider: default providers: default: api_key: "" - base_url: https://api.openai.com/v1 + base_url: "" high_concurrency: false - model: dall-e-3 + model: "" type: image_api diff --git a/docker/text_providers.yaml b/docker/text_providers.yaml index 8954392..43e3335 100644 --- a/docker/text_providers.yaml +++ b/docker/text_providers.yaml @@ -4,8 +4,8 @@ active_provider: default providers: default: api_key: "" - base_url: https://api.openai.com/v1 + base_url: "" max_output_tokens: 8000 - model: gpt-4o + model: "" temperature: 1 type: openai_compatible diff --git a/frontend/src/api/index.ts b/frontend/src/api/index.ts index 1e52183..dfd0b63 100644 --- a/frontend/src/api/index.ts +++ b/frontend/src/api/index.ts @@ -224,6 +224,7 @@ export async function getHistoryList( page: number page_size: number total_pages: number + error?: string }> { const params: any = { page, page_size: pageSize } if (status) params.status = status @@ -269,6 +270,7 @@ export async function deleteHistory(recordId: string): Promise<{ export async function searchHistory(keyword: string): Promise<{ success: boolean records: HistoryRecord[] + error?: string }> { const response = await axios.get(`${API_BASE_URL}/history/search`, { params: { keyword } @@ -299,6 +301,8 @@ export async function generateImagesPost( userImages?: File[], userTopic?: string ) { + let finished = false + let receivedAnyEvent = false try { // 将用户图片转换为 base64 let userImagesBase64: string[] = [] @@ -340,49 +344,67 @@ export async function generateImagesPost( const decoder = new TextDecoder() let buffer = '' - - while (true) { - const { done, value } = await reader.read() - - if (done) break - - buffer += decoder.decode(value, { stream: true }) - const lines = buffer.split('\n\n') - buffer = lines.pop() || '' - - for (const line of lines) { - if (!line.trim()) continue - - const [eventLine, dataLine] = line.split('\n') - if (!eventLine || !dataLine) continue - - const eventType = eventLine.replace('event: ', '').trim() - const eventData = dataLine.replace('data: ', '').trim() - - try { - const data = JSON.parse(eventData) - - switch (eventType) { - case 'progress': - onProgress(data) - break - case 'complete': - onComplete(data) - break - case 'error': - onError(data) - break - case 'finish': - onFinish(data) - break + try { + while (true) { + const { done, value } = await reader.read() + + if (done) break + + buffer += decoder.decode(value, { stream: true }) + const lines = buffer.split('\n\n') + buffer = lines.pop() || '' + + for (const line of lines) { + if (!line.trim()) continue + + const [eventLine, dataLine] = line.split('\n') + if (!eventLine || !dataLine) continue + + const eventType = eventLine.replace('event: ', '').trim() + const eventData = dataLine.replace('data: ', '').trim() + + try { + const data = JSON.parse(eventData) + receivedAnyEvent = true + + switch (eventType) { + case 'progress': + onProgress(data) + break + case 'complete': + onComplete(data) + break + case 'error': + onError(data) + break + case 'finish': + onFinish(data) + finished = true + break + } + } catch (e) { + console.error('解析 SSE 数据失败:', e) } - } catch (e) { - console.error('解析 SSE 数据失败:', e) } } + } finally { + try { + reader.releaseLock() + } catch (e) { + void e + } } } catch (error) { - onStreamError(error as Error) + const err = error as Error + const msg = (err?.message || '').toLowerCase() + const isAbortLike = + msg.includes('abort') || + msg.includes('network error') || + msg.includes('failed to fetch') + if (isAbortLike && (finished || receivedAnyEvent)) { + return + } + onStreamError(err) } } @@ -440,6 +462,7 @@ export async function testConnection(config: { api_key?: string base_url?: string model: string + endpoint_type?: string }): Promise<{ success: boolean message?: string diff --git a/frontend/src/components/history/GalleryCard.vue b/frontend/src/components/history/GalleryCard.vue index 43c8aac..a0d696e 100644 --- a/frontend/src/components/history/GalleryCard.vue +++ b/frontend/src/components/history/GalleryCard.vue @@ -62,26 +62,26 @@ import { computed } from 'vue' */ // 定义记录类型 -interface Record { +interface HistoryRecord { id: string title: string - status: 'draft' | 'completed' | 'generating' + status: string page_count: number updated_at: string - thumbnail?: string - task_id?: string + thumbnail?: string | null + task_id?: string | null } // 定义 Props const props = defineProps<{ - record: Record + record: HistoryRecord }>() // 定义 Emits defineEmits<{ (e: 'preview', id: string): void (e: 'edit', id: string): void - (e: 'delete', record: Record): void + (e: 'delete', record: HistoryRecord): void }>() /** @@ -91,7 +91,8 @@ const statusText = computed(() => { const map: Record = { draft: '草稿', completed: '已完成', - generating: '生成中' + generating: '生成中', + partial: '部分完成' } return map[props.record.status] || props.record.status }) diff --git a/frontend/src/components/settings/ImageProviderModal.vue b/frontend/src/components/settings/ImageProviderModal.vue index b6b90f3..8fcb05e 100644 --- a/frontend/src/components/settings/ImageProviderModal.vue +++ b/frontend/src/components/settings/ImageProviderModal.vue @@ -58,7 +58,7 @@ class="form-input" :value="formData.base_url" @input="updateField('base_url', ($event.target as HTMLInputElement).value)" - placeholder="例如: https://generativelanguage.googleapis.com" + :placeholder="baseUrlPlaceholder" /> 预览: {{ previewUrl }} @@ -205,12 +205,12 @@ function updateField(field: keyof FormData, value: string | boolean) { // 是否显示 Base URL const showBaseUrl = computed(() => { - return ['image_api', 'google_genai'].includes(props.formData.type) + return ['image_api', 'google_genai', 'wan2.6-t2i', 'modelscope_z_image'].includes(props.formData.type) }) // 是否显示端点类型 const showEndpointType = computed(() => { - return props.formData.type === 'image_api' + return ['image_api', 'modelscope_z_image'].includes(props.formData.type) }) // 模型占位符 @@ -220,20 +220,66 @@ const modelPlaceholder = computed(() => { return '例如: imagen-3.0-generate-002' case 'image_api': return '例如: flux-pro' + case 'wan2.6-t2i': + return '例如: wan2.6-t2i' + case 'modelscope_z_image': + return '例如: Tongyi-MAI/Z-Image-Turbo' default: return '例如: gpt-4o' } }) +const baseUrlPlaceholder = computed(() => { + switch (props.formData.type) { + case 'google_genai': + return '例如: https://generativelanguage.googleapis.com' + case 'image_api': + return '例如: https://api.openai.com' + case 'wan2.6-t2i': + return '例如: https://dashscope.aliyuncs.com/api/v1' + case 'modelscope_z_image': + return '例如: https://api-inference.modelscope.cn' + default: + return '例如: https://api.example.com' + } +}) + // 预览 URL const previewUrl = computed(() => { if (!props.formData.base_url) return '' + if (props.formData.type === 'wan2.6-t2i') { + const rawBaseUrl = props.formData.base_url.replace(/\/$/, '') + return `${rawBaseUrl}/services/aigc/multimodal-generation/generation` + } + if (props.formData.type === 'modelscope_z_image') { + const rawBaseUrl = props.formData.base_url.replace(/\/+$/, '') + return `${rawBaseUrl}/` + } + const baseUrl = props.formData.base_url.replace(/\/$/, '').replace(/\/v1$/, '') const endpointType = props.formData.endpoint_type || '/v1/images/generations' switch (props.formData.type) { case 'image_api': + // 如果用户自定义了 endpoint_type,直接显示完整路径 + if (props.formData.endpoint_type) { + // 检查 base_url 是否已经包含了 endpoint 的开头部分(简单的去重逻辑) + // 这里只做简单展示,更复杂的逻辑在后端处理 + const endpoint = props.formData.endpoint_type.startsWith('/') ? props.formData.endpoint_type : '/' + props.formData.endpoint_type + + // 简单的去重显示:如果 base_url 结尾是 /v1 且 endpoint 是 /v1/... + if (baseUrl.endsWith('/v1') && endpoint.startsWith('/v1')) { + return `${baseUrl.slice(0, -3)}${endpoint}` + } + // 如果 base_url 结尾是 /v3 且 endpoint 是 /v3/... + if (baseUrl.endsWith('/v3') && endpoint.startsWith('/v3')) { + return `${baseUrl.slice(0, -3)}${endpoint}` + } + + return `${baseUrl}${endpoint}` + } + if (endpointType.includes('chat')) { return `${baseUrl}/v1/chat/completions` } diff --git a/frontend/src/components/settings/ProviderTable.vue b/frontend/src/components/settings/ProviderTable.vue index 66b9d90..b703255 100644 --- a/frontend/src/components/settings/ProviderTable.vue +++ b/frontend/src/components/settings/ProviderTable.vue @@ -81,6 +81,7 @@ interface Provider { base_url?: string api_key?: string api_key_masked?: string + endpoint_type?: string } // 定义 Props diff --git a/frontend/src/composables/useProviderForm.ts b/frontend/src/composables/useProviderForm.ts index 75e363b..0555d19 100644 --- a/frontend/src/composables/useProviderForm.ts +++ b/frontend/src/composables/useProviderForm.ts @@ -1,530 +1,442 @@ import { ref } from 'vue' -import { getConfig, updateConfig, testConnection, type Config } from '../api' - -/** - * 服务商表单管理 Composable - * - * 提供服务商配置的完整管理功能: - * - 加载/保存配置 - * - 添加/编辑/删除服务商 - * - 测试连接 - * - 激活服务商 - */ - -// 服务商数据类型 -export interface Provider { +import { getConfig, testConnection, updateConfig, type Config } from '../api' + +type ProviderCategory = 'text' | 'image' + +interface TypeOption { + value: string + label: string +} + +export const textTypeOptions: TypeOption[] = [ + { value: 'openai_compatible', label: 'OpenAI 兼容' }, + { value: 'google_gemini', label: 'Google Gemini' } +] + +export const imageTypeOptions: TypeOption[] = [ + { value: 'image_api', label: '通用图片 API' }, + { value: 'google_genai', label: 'Google GenAI (Imagen)' }, + { value: 'wan2.6-t2i', label: '通义万相 Wan2.6 (文生图V2)' }, + { value: 'modelscope_z_image', label: '魔塔 ModelScope Z-Image-Turbo' } +] + +interface Provider { type: string model: string base_url?: string api_key?: string api_key_masked?: string + _has_api_key?: boolean endpoint_type?: string high_concurrency?: boolean short_prompt?: boolean } -// 服务商配置类型 -export interface ProviderConfig { +interface ProviderGroup { active_provider: string providers: Record } -// 文本服务商表单类型 -export interface TextProviderForm { +interface TextFormData { name: string type: string api_key: string - api_key_masked: string + api_key_masked?: string + _has_api_key?: boolean base_url: string model: string - endpoint_type: string - _has_api_key: boolean + endpoint_type?: string } -// 图片服务商表单类型 -export interface ImageProviderForm { +interface ImageFormData { name: string type: string api_key: string - api_key_masked: string + api_key_masked?: string + _has_api_key?: boolean base_url: string model: string - high_concurrency: boolean - short_prompt: boolean - endpoint_type: string - _has_api_key: boolean + endpoint_type?: string + high_concurrency?: boolean + short_prompt?: boolean } -// 文本服务商类型选项 -export const textTypeOptions = [ - { value: 'google_gemini', label: 'Google Gemini' }, - { value: 'openai_compatible', label: 'OpenAI 兼容接口' } -] +function buildEmptyTextForm(): TextFormData { + return { + name: '', + type: 'openai_compatible', + api_key: '', + base_url: '', + model: '', + endpoint_type: '' + } +} -// 图片服务商类型选项 -export const imageTypeOptions = [ - { value: 'google_genai', label: 'Google GenAI' }, - { value: 'image_api', label: 'OpenAI 兼容接口' } -] +function buildEmptyImageForm(): ImageFormData { + return { + name: '', + type: 'image_api', + api_key: '', + base_url: '', + model: '', + endpoint_type: '', + high_concurrency: false, + short_prompt: false + } +} + +function pickFirstKey>(obj: T): string { + return Object.keys(obj)[0] || '' +} + +function normalizeGroup(group: any): ProviderGroup { + const providers = (group?.providers || {}) as Record + let active_provider = String(group?.active_provider || '') + if (!active_provider || !providers[active_provider]) { + active_provider = pickFirstKey(providers) + } + return { active_provider, providers } +} + +async function saveGroup(category: ProviderCategory, group: ProviderGroup): Promise { + const payload: Partial = + category === 'text' + ? { text_generation: group } + : { image_generation: group } + + const result = await updateConfig(payload) + if (!result.success) { + alert(result.error || '保存失败') + return false + } + return true +} -/** - * 服务商表单管理 Hook - */ export function useProviderForm() { - // 加载状态 const loading = ref(true) - const saving = ref(false) const testingText = ref(false) const testingImage = ref(false) - // 配置数据 - const textConfig = ref({ - active_provider: '', - providers: {} - }) - - const imageConfig = ref({ - active_provider: '', - providers: {} - }) + const textConfig = ref({ active_provider: '', providers: {} }) + const imageConfig = ref({ active_provider: '', providers: {} }) - // 文本服务商弹窗状态 const showTextModal = ref(false) const editingTextProvider = ref(null) - const textForm = ref(createEmptyTextForm()) + const textForm = ref(buildEmptyTextForm()) - // 图片服务商弹窗状态 const showImageModal = ref(false) const editingImageProvider = ref(null) - const imageForm = ref(createEmptyImageForm()) - - /** - * 创建空的文本服务商表单 - */ - function createEmptyTextForm(): TextProviderForm { - return { - name: '', - type: 'openai_compatible', - api_key: '', - api_key_masked: '', - base_url: '', - model: '', - endpoint_type: '/v1/chat/completions', - _has_api_key: false - } - } - - /** - * 创建空的图片服务商表单 - */ - function createEmptyImageForm(): ImageProviderForm { - return { - name: '', - type: 'image_api', - api_key: '', - api_key_masked: '', - base_url: '', - model: '', - high_concurrency: false, - short_prompt: false, - endpoint_type: '/v1/images/generations', - _has_api_key: false - } - } + const imageForm = ref(buildEmptyImageForm()) - /** - * 加载配置 - */ async function loadConfig() { loading.value = true try { const result = await getConfig() - if (result.success && result.config) { - textConfig.value = { - active_provider: result.config.text_generation.active_provider, - providers: result.config.text_generation.providers - } - imageConfig.value = result.config.image_generation - } else { - alert('加载配置失败: ' + (result.error || '未知错误')) + if (!result.success || !result.config) { + alert(result.error || '加载配置失败') + return } + + textConfig.value = normalizeGroup(result.config.text_generation) + imageConfig.value = normalizeGroup(result.config.image_generation) } catch (e) { - alert('加载配置失败: ' + String(e)) + alert(String(e)) } finally { loading.value = false } } - /** - * 自动保存配置 - */ - async function autoSaveConfig() { - try { - const config: Partial = { - text_generation: { - active_provider: textConfig.value.active_provider, - providers: textConfig.value.providers - }, - image_generation: imageConfig.value - } - - const result = await updateConfig(config) - if (result.success) { - // 重新加载配置以获取最新的脱敏 API Key - await loadConfig() - } - } catch (e) { - console.error('自动保存失败:', e) - } - } - - // ==================== 文本服务商操作 ==================== - - /** - * 激活文本服务商 - */ async function activateTextProvider(name: string) { - textConfig.value.active_provider = name - await autoSaveConfig() + if (!textConfig.value.providers[name]) return + const next = { ...textConfig.value, active_provider: name } + const ok = await saveGroup('text', next) + if (ok) await loadConfig() } - /** - * 打开添加文本服务商弹窗 - */ function openAddTextModal() { editingTextProvider.value = null - textForm.value = createEmptyTextForm() + textForm.value = buildEmptyTextForm() showTextModal.value = true } - /** - * 打开编辑文本服务商弹窗 - */ function openEditTextModal(name: string, provider: Provider) { editingTextProvider.value = name textForm.value = { - name: name, + name, type: provider.type || 'openai_compatible', api_key: '', - api_key_masked: provider.api_key_masked || '', + api_key_masked: provider.api_key_masked, + _has_api_key: provider._has_api_key, base_url: provider.base_url || '', model: provider.model || '', - endpoint_type: provider.endpoint_type || '/v1/chat/completions', - _has_api_key: !!provider.api_key_masked + endpoint_type: provider.endpoint_type || '' } showTextModal.value = true } - /** - * 关闭文本服务商弹窗 - */ function closeTextModal() { showTextModal.value = false editingTextProvider.value = null + textForm.value = buildEmptyTextForm() } - /** - * 保存文本服务商 - */ async function saveTextProvider() { - const name = editingTextProvider.value || textForm.value.name - + const name = textForm.value.name.trim() if (!name) { - alert('请填写服务商名称') + alert('请输入服务商名称') return } - if (!textForm.value.type) { - alert('请选择服务商类型') + const providers = { ...textConfig.value.providers } + const isEditing = !!editingTextProvider.value + const targetName = isEditing ? editingTextProvider.value! : name + + if (!isEditing && providers[targetName]) { + alert('服务商名称已存在') return } - // 新增时必须填写 API Key - if (!editingTextProvider.value && !textForm.value.api_key) { - alert('请填写 API Key') + if (!isEditing && !textForm.value.api_key.trim()) { + alert('请输入 API Key') return } - const existingProvider = textConfig.value.providers[name] || {} - - const providerData: any = { + providers[targetName] = { type: textForm.value.type, - model: textForm.value.model - } - - // 如果填写了新的 API Key,使用新的;否则保留原有的 - if (textForm.value.api_key) { - providerData.api_key = textForm.value.api_key - } else if (existingProvider.api_key) { - providerData.api_key = existingProvider.api_key + model: textForm.value.model, + base_url: textForm.value.base_url || undefined, + endpoint_type: textForm.value.endpoint_type || undefined, + api_key: textForm.value.api_key } - if (textForm.value.base_url) { - providerData.base_url = textForm.value.base_url + const nextGroup: ProviderGroup = { + active_provider: textConfig.value.active_provider || targetName, + providers } - // 如果是 OpenAI 兼容接口,保存 endpoint_type - if (textForm.value.type === 'openai_compatible') { - providerData.endpoint_type = textForm.value.endpoint_type - } - - textConfig.value.providers[name] = providerData - + const ok = await saveGroup('text', nextGroup) + if (!ok) return + await loadConfig() closeTextModal() - await autoSaveConfig() } - /** - * 删除文本服务商 - */ async function deleteTextProvider(name: string) { - if (confirm(`确定要删除服务商 "${name}" 吗?`)) { - delete textConfig.value.providers[name] - if (textConfig.value.active_provider === name) { - textConfig.value.active_provider = '' - } - await autoSaveConfig() + if (!textConfig.value.providers[name]) return + const providerNames = Object.keys(textConfig.value.providers) + if (providerNames.length <= 1) { + alert('至少保留一个服务商') + return } + if (!confirm(`确定删除服务商 “${name}” 吗?`)) return + + const providers = { ...textConfig.value.providers } + delete providers[name] + + const active_provider = + textConfig.value.active_provider === name + ? pickFirstKey(providers) + : textConfig.value.active_provider + + const ok = await saveGroup('text', { active_provider, providers }) + if (ok) await loadConfig() } - /** - * 测试文本服务商连接(弹窗中) - */ async function testTextConnection() { testingText.value = true try { - const result = await testConnection({ + const payload = { type: textForm.value.type, provider_name: editingTextProvider.value || undefined, api_key: textForm.value.api_key || undefined, - base_url: textForm.value.base_url, - model: textForm.value.model - }) - if (result.success) { - alert('✅ ' + result.message) + base_url: textForm.value.base_url || undefined, + model: textForm.value.model, + endpoint_type: textForm.value.endpoint_type || undefined } - } catch (e: any) { - alert('❌ 连接失败:' + (e.response?.data?.error || e.message)) + const result = await testConnection(payload) + alert(result.success ? (result.message || '连接成功') : (result.error || '连接失败')) + } catch (e) { + alert(String(e)) } finally { testingText.value = false } } - /** - * 测试列表中的文本服务商 - */ async function testTextProviderInList(name: string, provider: Provider) { + testingText.value = true try { const result = await testConnection({ type: provider.type, provider_name: name, - api_key: undefined, base_url: provider.base_url, - model: provider.model + model: provider.model, + endpoint_type: provider.endpoint_type }) - if (result.success) { - alert('✅ ' + result.message) - } - } catch (e: any) { - alert('❌ 连接失败:' + (e.response?.data?.error || e.message)) + alert(result.success ? (result.message || '连接成功') : (result.error || '连接失败')) + } catch (e) { + alert(String(e)) + } finally { + testingText.value = false } } - // ==================== 图片服务商操作 ==================== + function updateTextForm(data: TextFormData) { + textForm.value = data + } - /** - * 激活图片服务商 - */ async function activateImageProvider(name: string) { - imageConfig.value.active_provider = name - await autoSaveConfig() + if (!imageConfig.value.providers[name]) return + const next = { ...imageConfig.value, active_provider: name } + const ok = await saveGroup('image', next) + if (ok) await loadConfig() } - /** - * 打开添加图片服务商弹窗 - */ function openAddImageModal() { editingImageProvider.value = null - imageForm.value = createEmptyImageForm() + imageForm.value = buildEmptyImageForm() showImageModal.value = true } - /** - * 打开编辑图片服务商弹窗 - */ function openEditImageModal(name: string, provider: Provider) { editingImageProvider.value = name imageForm.value = { - name: name, - type: provider.type || '', + name, + type: provider.type || 'image_api', api_key: '', - api_key_masked: provider.api_key_masked || '', + api_key_masked: provider.api_key_masked, + _has_api_key: provider._has_api_key, base_url: provider.base_url || '', model: provider.model || '', - high_concurrency: provider.high_concurrency || false, - short_prompt: provider.short_prompt || false, - endpoint_type: provider.endpoint_type || '/v1/images/generations', - _has_api_key: !!provider.api_key_masked + endpoint_type: provider.endpoint_type || '', + high_concurrency: !!provider.high_concurrency, + short_prompt: !!provider.short_prompt } showImageModal.value = true } - /** - * 关闭图片服务商弹窗 - */ function closeImageModal() { showImageModal.value = false editingImageProvider.value = null + imageForm.value = buildEmptyImageForm() } - /** - * 保存图片服务商 - */ async function saveImageProvider() { - const name = editingImageProvider.value || imageForm.value.name - + const name = imageForm.value.name.trim() if (!name) { - alert('请填写服务商名称') + alert('请输入服务商名称') return } - if (!imageForm.value.type) { - alert('请填写服务商类型') + const providers = { ...imageConfig.value.providers } + const isEditing = !!editingImageProvider.value + const targetName = isEditing ? editingImageProvider.value! : name + + if (!isEditing && providers[targetName]) { + alert('服务商名称已存在') return } - // 新增时必须填写 API Key - if (!editingImageProvider.value && !imageForm.value.api_key) { - alert('请填写 API Key') + if (!isEditing && !imageForm.value.api_key.trim()) { + alert('请输入 API Key') return } - const existingProvider = imageConfig.value.providers[name] || {} - - const providerData: any = { + providers[targetName] = { type: imageForm.value.type, model: imageForm.value.model, - high_concurrency: imageForm.value.high_concurrency, - short_prompt: imageForm.value.short_prompt - } - - // 如果是 OpenAI 兼容接口,保存 endpoint_type - if (imageForm.value.type === 'image_api') { - providerData.endpoint_type = imageForm.value.endpoint_type + base_url: imageForm.value.base_url || undefined, + endpoint_type: imageForm.value.endpoint_type || undefined, + high_concurrency: !!imageForm.value.high_concurrency, + short_prompt: !!imageForm.value.short_prompt, + api_key: imageForm.value.api_key } - // 如果填写了新的 API Key,使用新的;否则保留原有的 - if (imageForm.value.api_key) { - providerData.api_key = imageForm.value.api_key - } else if (existingProvider.api_key) { - providerData.api_key = existingProvider.api_key + const nextGroup: ProviderGroup = { + active_provider: imageConfig.value.active_provider || targetName, + providers } - if (imageForm.value.base_url) { - providerData.base_url = imageForm.value.base_url - } - - imageConfig.value.providers[name] = providerData - + const ok = await saveGroup('image', nextGroup) + if (!ok) return + await loadConfig() closeImageModal() - await autoSaveConfig() } - /** - * 删除图片服务商 - */ async function deleteImageProvider(name: string) { - if (confirm(`确定要删除服务商 "${name}" 吗?`)) { - delete imageConfig.value.providers[name] - if (imageConfig.value.active_provider === name) { - imageConfig.value.active_provider = '' - } - await autoSaveConfig() + if (!imageConfig.value.providers[name]) return + const providerNames = Object.keys(imageConfig.value.providers) + if (providerNames.length <= 1) { + alert('至少保留一个服务商') + return } + if (!confirm(`确定删除服务商 “${name}” 吗?`)) return + + const providers = { ...imageConfig.value.providers } + delete providers[name] + + const active_provider = + imageConfig.value.active_provider === name + ? pickFirstKey(providers) + : imageConfig.value.active_provider + + const ok = await saveGroup('image', { active_provider, providers }) + if (ok) await loadConfig() } - /** - * 测试图片服务商连接(弹窗中) - */ async function testImageConnection() { testingImage.value = true try { - const result = await testConnection({ + const payload = { type: imageForm.value.type, provider_name: editingImageProvider.value || undefined, api_key: imageForm.value.api_key || undefined, - base_url: imageForm.value.base_url, - model: imageForm.value.model - }) - if (result.success) { - alert('✅ ' + result.message) + base_url: imageForm.value.base_url || undefined, + model: imageForm.value.model, + endpoint_type: imageForm.value.endpoint_type || undefined } - } catch (e: any) { - alert('❌ 连接失败:' + (e.response?.data?.error || e.message)) + const result = await testConnection(payload) + alert(result.success ? (result.message || '连接成功') : (result.error || '连接失败')) + } catch (e) { + alert(String(e)) } finally { testingImage.value = false } } - /** - * 测试列表中的图片服务商 - */ async function testImageProviderInList(name: string, provider: Provider) { + testingImage.value = true try { const result = await testConnection({ type: provider.type, provider_name: name, - api_key: undefined, base_url: provider.base_url, - model: provider.model + model: provider.model, + endpoint_type: provider.endpoint_type }) - if (result.success) { - alert('✅ ' + result.message) - } - } catch (e: any) { - alert('❌ 连接失败:' + (e.response?.data?.error || e.message)) + alert(result.success ? (result.message || '连接成功') : (result.error || '连接失败')) + } catch (e) { + alert(String(e)) + } finally { + testingImage.value = false } } - /** - * 更新文本表单数据 - */ - function updateTextForm(data: TextProviderForm) { - textForm.value = data - } - - /** - * 更新图片表单数据 - */ - function updateImageForm(data: ImageProviderForm) { + function updateImageForm(data: ImageFormData) { imageForm.value = data } return { - // 状态 loading, - saving, testingText, testingImage, - - // 配置数据 textConfig, imageConfig, - - // 文本服务商弹窗 showTextModal, editingTextProvider, textForm, - - // 图片服务商弹窗 showImageModal, editingImageProvider, imageForm, - - // 方法 loadConfig, - - // 文本服务商方法 activateTextProvider, openAddTextModal, openEditTextModal, @@ -534,8 +446,6 @@ export function useProviderForm() { testTextConnection, testTextProviderInList, updateTextForm, - - // 图片服务商方法 activateImageProvider, openAddImageModal, openEditImageModal, diff --git a/frontend/src/router/index.ts b/frontend/src/router/index.ts index b7270eb..5062cd6 100644 --- a/frontend/src/router/index.ts +++ b/frontend/src/router/index.ts @@ -7,7 +7,7 @@ import HistoryView from '../views/HistoryView.vue' import SettingsView from '../views/SettingsView.vue' const router = createRouter({ - history: createWebHistory(import.meta.env.BASE_URL), + history: createWebHistory(((import.meta as any).env?.BASE_URL as string) || '/'), routes: [ { path: '/', diff --git a/frontend/src/views/GenerateView.vue b/frontend/src/views/GenerateView.vue index 935bbb0..04bbfc1 100644 --- a/frontend/src/views/GenerateView.vue +++ b/frontend/src/views/GenerateView.vue @@ -47,7 +47,6 @@ + + +
@@ -149,6 +160,7 @@ const store = useGeneratorStore() const records = ref([]) const loading = ref(false) const stats = ref(null) +const loadError = ref(null) const currentTab = ref('all') const searchKeyword = ref('') const currentPage = ref(1) @@ -165,15 +177,18 @@ const isScanning = ref(false) */ async function loadData() { loading.value = true + loadError.value = null try { let statusFilter = currentTab.value === 'all' ? undefined : currentTab.value const res = await getHistoryList(currentPage.value, 12, statusFilter) if (res.success) { records.value = res.records totalPages.value = res.total_pages + } else { + loadError.value = res.error || '加载失败' } } catch(e) { - console.error(e) + loadError.value = '无法连接后端服务,请确认后端已启动(http://localhost:12398)' } finally { loading.value = false } @@ -207,13 +222,18 @@ async function handleSearch() { return } loading.value = true + loadError.value = null try { const res = await searchHistory(searchKeyword.value) if (res.success) { records.value = res.records totalPages.value = 1 + } else { + loadError.value = res.error || '搜索失败' } - } catch(e) {} finally { + } catch(e) { + loadError.value = '无法连接后端服务,请确认后端已启动(http://localhost:12398)' + } finally { loading.value = false } } @@ -229,7 +249,7 @@ async function loadRecord(id: string) { store.recordId = res.record.id if (res.record.images.generated.length > 0) { store.taskId = res.record.images.task_id - store.images = res.record.outline.pages.map((page, idx) => { + store.images = res.record.outline.pages.map((_page, idx) => { const filename = res.record!.images.generated[idx] return { index: idx, @@ -404,7 +424,6 @@ onMounted(async () => { await loadStats() } } catch (e) { - console.error('自动扫描失败:', e) } }) diff --git a/frontend/src/views/OutlineView.vue b/frontend/src/views/OutlineView.vue index 7cc5d11..f8b74af 100644 --- a/frontend/src/views/OutlineView.vue +++ b/frontend/src/views/OutlineView.vue @@ -23,8 +23,8 @@ class="card outline-card" :draggable="true" @dragstart="onDragStart($event, idx)" - @dragover.prevent="onDragOver($event, idx)" - @drop="onDrop($event, idx)" + @dragover.prevent="onDragOver(idx)" + @drop="onDrop(idx)" :class="{ 'dragging-over': dragOverIndex === idx }" > @@ -96,12 +96,12 @@ const onDragStart = (e: DragEvent, index: number) => { } } -const onDragOver = (e: DragEvent, index: number) => { +const onDragOver = (index: number) => { if (draggedIndex.value === index) return dragOverIndex.value = index } -const onDrop = (e: DragEvent, index: number) => { +const onDrop = (index: number) => { dragOverIndex.value = null if (draggedIndex.value !== null && draggedIndex.value !== index) { store.movePage(draggedIndex.value, index)