File size: 10,096 Bytes
6db48b4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
"""Text API 客户端封装"""
import time
import random
import base64
import requests
from functools import wraps
from typing import List, Optional, Union
from .image_compressor import compress_image


def retry_on_429(max_retries=3, base_delay=2):
    """429 错误自动重试装饰器"""
    def decorator(func):
        @wraps(func)
        def wrapper(*args, **kwargs):
            for attempt in range(max_retries):
                try:
                    return func(*args, **kwargs)
                except Exception as e:
                    error_str = str(e)
                    if "429" in error_str or "rate" in error_str.lower():
                        if attempt < max_retries - 1:
                            wait_time = (base_delay ** attempt) + random.uniform(0, 1)
                            print(f"[重试] 遇到限流,{wait_time:.1f}秒后重试 (尝试 {attempt + 2}/{max_retries})")
                            time.sleep(wait_time)
                            continue
                    raise
            raise Exception(
                f"Text API 重试 {max_retries} 次后仍失败。\n"
                "可能原因:\n"
                "1. API持续限流或配额不足\n"
                "2. 网络连接持续不稳定\n"
                "3. API服务暂时不可用\n"
                "建议:稍后再试,或联系API服务提供商"
            )
        return wrapper
    return decorator


class TextChatClient:
    """Text API 客户端封装类"""

    def __init__(self, api_key: str = None, base_url: str = None, endpoint_type: str = None):
        self.api_key = api_key
        if not self.api_key:
            raise ValueError(
                "Text API Key 未配置。\n"
                "解决方案:在系统设置页面编辑文本生成服务商,填写 API Key"
            )

        self.base_url = (base_url or "https://api.openai.com").rstrip('/').rstrip('/v1')

        # 支持自定义端点路径
        endpoint = endpoint_type or '/v1/chat/completions'
        # 确保端点以 / 开头
        if not endpoint.startswith('/'):
            endpoint = '/' + endpoint
        self.chat_endpoint = f"{self.base_url}{endpoint}"

    def _encode_image_to_base64(self, image_data: bytes) -> str:
        """将图片数据编码为 base64"""
        return base64.b64encode(image_data).decode('utf-8')

    def _build_content_with_images(
        self,
        text: str,
        images: List[Union[bytes, str]] = None
    ) -> Union[str, List[dict]]:
        """
        构建包含图片的 content

        Args:
            text: 文本内容
            images: 图片列表,可以是 bytes(图片数据)或 str(URL)

        Returns:
            如果没有图片,返回纯文本;有图片则返回多模态内容列表
        """
        if not images:
            return text

        content = [{"type": "text", "text": text}]

        for img in images:
            if isinstance(img, bytes):
                # 压缩图片到 200KB 以内
                compressed_img = compress_image(img, max_size_kb=200)
                # 图片数据,转为 base64 data URL
                base64_data = self._encode_image_to_base64(compressed_img)
                image_url = f"data:image/png;base64,{base64_data}"
            else:
                # 已经是 URL
                image_url = img

            content.append({
                "type": "image_url",
                "image_url": {"url": image_url}
            })

        return content

    @retry_on_429(max_retries=3, base_delay=2)
    def generate_text(
        self,
        prompt: str,
        model: str = "gemini-3-pro-preview",
        temperature: float = 1.0,
        max_output_tokens: int = 8000,
        images: List[Union[bytes, str]] = None,
        system_prompt: str = None,
        **kwargs
    ) -> str:
        """
        生成文本(支持图片输入)

        Args:
            prompt: 提示词
            model: 模型名称
            temperature: 温度
            max_output_tokens: 最大输出 token
            images: 图片列表(可选)
            system_prompt: 系统提示词(可选)

        Returns:
            生成的文本
        """
        messages = []

        # 添加系统提示词
        if system_prompt:
            messages.append({
                "role": "system",
                "content": system_prompt
            })

        # 构建用户消息内容
        content = self._build_content_with_images(prompt, images)
        messages.append({
            "role": "user",
            "content": content
        })

        payload = {
            "model": model,
            "messages": messages,
            "temperature": temperature,
            "max_tokens": max_output_tokens,
            "stream": False
        }

        headers = {
            "Content-Type": "application/json",
            "Accept": "application/json",
            "Authorization": f"Bearer {self.api_key}"
        }

        response = requests.post(
            self.chat_endpoint,
            json=payload,
            headers=headers,
            timeout=300  # 5分钟超时
        )

        if response.status_code != 200:
            error_detail = response.text[:500]
            status_code = response.status_code

            # 根据状态码给出更详细的错误信息
            if status_code == 401:
                raise Exception(
                    "❌ API Key 认证失败\n\n"
                    "【可能原因】\n"
                    "1. API Key 无效或已过期\n"
                    "2. API Key 格式错误(复制时可能包含空格)\n"
                    "3. API Key 被禁用或删除\n\n"
                    "【解决方案】\n"
                    "1. 在系统设置页面检查 API Key 是否正确\n"
                    "2. 重新获取 API Key\n"
                    f"\n【请求地址】{self.chat_endpoint}"
                )
            elif status_code == 403:
                raise Exception(
                    "❌ 权限被拒绝\n\n"
                    "【可能原因】\n"
                    "1. API Key 没有访问该模型的权限\n"
                    "2. 账户配额已用尽\n"
                    "3. 区域限制\n\n"
                    "【解决方案】\n"
                    "1. 检查 API 权限配置\n"
                    "2. 尝试使用其他模型\n"
                    f"\n【原始错误】{error_detail[:200]}"
                )
            elif status_code == 404:
                raise Exception(
                    "❌ 模型不存在或 API 端点错误\n\n"
                    "【可能原因】\n"
                    f"1. 模型 '{model}' 不存在或已下线\n"
                    "2. Base URL 配置错误\n\n"
                    "【解决方案】\n"
                    "1. 检查模型名称是否正确\n"
                    "2. 检查 Base URL 配置\n"
                    f"\n【请求地址】{self.chat_endpoint}"
                )
            elif status_code == 429:
                raise Exception(
                    "⏳ API 配额或速率限制\n\n"
                    "【说明】\n"
                    "请求频率过高或配额已用尽。\n\n"
                    "【解决方案】\n"
                    "1. 稍后再试(等待 1-2 分钟)\n"
                    "2. 检查 API 配额使用情况\n"
                    "3. 考虑升级计划获取更多配额"
                )
            elif status_code >= 500:
                raise Exception(
                    f"⚠️ API 服务器错误 ({status_code})\n\n"
                    "【说明】\n"
                    "这是服务端的临时故障,与您的配置无关。\n\n"
                    "【解决方案】\n"
                    "1. 稍等几分钟后重试\n"
                    "2. 如果持续出现,检查服务商状态页"
                )
            else:
                raise Exception(
                    f"❌ API 请求失败 (状态码: {status_code})\n\n"
                    f"【原始错误】\n{error_detail}\n\n"
                    f"【请求地址】{self.chat_endpoint}\n"
                    f"【模型】{model}\n\n"
                    "【通用解决方案】\n"
                    "1. 检查 API Key 是否正确\n"
                    "2. 检查 Base URL 配置\n"
                    "3. 检查模型名称是否正确"
                )

        result = response.json()

        # 提取生成的文本
        if "choices" in result and len(result["choices"]) > 0:
            return result["choices"][0]["message"]["content"]
        else:
            raise Exception(
                f"Text API 响应格式异常:未找到生成的文本。\n"
                f"响应数据: {str(result)[:500]}\n"
                "可能原因:\n"
                "1. API返回格式与OpenAI标准不一致\n"
                "2. 请求被拒绝或过滤\n"
                "3. 模型输出为空\n"
                "建议:检查API文档确认响应格式"
            )


def get_text_chat_client(provider_config: dict):
    """
    获取 Text Chat 客户端实例(根据 type 返回对应客户端)

    Args:
        provider_config: 服务商配置字典
            - type: 'google_gemini' 或 'openai_compatible'
            - api_key: API密钥
            - base_url: API基础URL(可选)
            - endpoint_type: 自定义端点路径(可选)

    Returns:
        GenAIClient 或 TextChatClient
    """
    provider_type = provider_config.get('type', 'openai_compatible')
    api_key = provider_config.get('api_key')
    base_url = provider_config.get('base_url')
    endpoint_type = provider_config.get('endpoint_type')

    if provider_type == 'google_gemini':
        from .genai_client import GenAIClient
        return GenAIClient(api_key=api_key, base_url=base_url)
    else:
        return TextChatClient(api_key=api_key, base_url=base_url, endpoint_type=endpoint_type)