|
11 | 11 | """ |
12 | 12 |
|
13 | 13 | import base64 |
| 14 | +import json |
14 | 15 | from pathlib import Path |
15 | 16 | from typing import Optional |
16 | 17 |
|
| 18 | +import httpx |
17 | 19 | from loguru import logger |
18 | 20 | from openai import OpenAI |
19 | 21 |
|
@@ -293,3 +295,109 @@ def _call_chat( |
293 | 295 | message="AI API 调用失败", |
294 | 296 | detail=error_msg, |
295 | 297 | ) |
| 298 | + |
| 299 | + |
| 300 | +# 代理服务器地址和鉴权 secret(后端随时可切换模型,引擎无需更新) |
| 301 | +_PROXY_URL = "https://xinzaoai.com/api/ai/proxy" |
| 302 | +_ENGINE_SECRET = "testpilot-engine-secret-2026" |
| 303 | + |
| 304 | + |
| 305 | +class ProxyAIClient: |
| 306 | + """通过 xinzaoai.com 服务器代理调用豆包 AI。 |
| 307 | +
|
| 308 | + 引擎 exe 默认使用此客户端(不需要 TP_AI_API_KEY)。 |
| 309 | + 后端切换模型/Key 只需改服务器环境变量,引擎无需重新打包。 |
| 310 | + 接口与 AIClient 完全相同,可互相替换。 |
| 311 | + """ |
| 312 | + |
| 313 | + def __init__(self, reasoning_effort: str = "medium", max_tokens: int = 65535) -> None: |
| 314 | + self._reasoning_effort = reasoning_effort |
| 315 | + self._max_tokens = max_tokens |
| 316 | + self._http = httpx.Client(timeout=120.0) |
| 317 | + logger.info("AI 代理客户端初始化完成 | 代理={}", _PROXY_URL) |
| 318 | + |
| 319 | + def chat( |
| 320 | + self, |
| 321 | + prompt: str, |
| 322 | + system_prompt: str = "", |
| 323 | + reasoning_effort: Optional[str] = None, |
| 324 | + timeout: Optional[float] = None, |
| 325 | + ) -> str: |
| 326 | + messages = [] |
| 327 | + if system_prompt: |
| 328 | + messages.append({"role": "system", "content": system_prompt}) |
| 329 | + messages.append({"role": "user", "content": prompt}) |
| 330 | + return self._call(messages, reasoning_effort, timeout) |
| 331 | + |
| 332 | + def analyze_screenshot( |
| 333 | + self, |
| 334 | + image_path: str, |
| 335 | + prompt: str = "请描述这个页面的内容,并指出可能存在的UI问题或Bug。", |
| 336 | + system_prompt: str = "", |
| 337 | + reasoning_effort: Optional[str] = None, |
| 338 | + timeout: Optional[float] = None, |
| 339 | + max_tokens: Optional[int] = None, |
| 340 | + ) -> str: |
| 341 | + path = Path(image_path) |
| 342 | + if not path.exists(): |
| 343 | + raise FileNotFoundError(f"截图文件不存在: {image_path}") |
| 344 | + image_data = path.read_bytes() |
| 345 | + b64 = base64.b64encode(image_data).decode("utf-8") |
| 346 | + suffix = path.suffix.lower() |
| 347 | + mime = {"png": "image/png", "jpg": "image/jpeg", "jpeg": "image/jpeg", |
| 348 | + "webp": "image/webp", "gif": "image/gif"}.get(suffix.lstrip("."), "image/png") |
| 349 | + messages = [] |
| 350 | + if system_prompt: |
| 351 | + messages.append({"role": "system", "content": system_prompt}) |
| 352 | + messages.append({"role": "user", "content": [ |
| 353 | + {"type": "image_url", "image_url": {"url": f"data:{mime};base64,{b64}"}}, |
| 354 | + {"type": "text", "text": prompt}, |
| 355 | + ]}) |
| 356 | + logger.info("发送截图分析请求(代理)| 文件={} | 大小={}KB", path.name, len(image_data) // 1024) |
| 357 | + return self._call(messages, reasoning_effort, timeout, max_tokens) |
| 358 | + |
| 359 | + def analyze_screenshot_url( |
| 360 | + self, |
| 361 | + image_url: str, |
| 362 | + prompt: str = "请描述这个页面的内容,并指出可能存在的UI问题或Bug。", |
| 363 | + system_prompt: str = "", |
| 364 | + reasoning_effort: Optional[str] = None, |
| 365 | + ) -> str: |
| 366 | + messages = [] |
| 367 | + if system_prompt: |
| 368 | + messages.append({"role": "system", "content": system_prompt}) |
| 369 | + messages.append({"role": "user", "content": [ |
| 370 | + {"type": "image_url", "image_url": {"url": image_url}}, |
| 371 | + {"type": "text", "text": prompt}, |
| 372 | + ]}) |
| 373 | + return self._call(messages, reasoning_effort) |
| 374 | + |
| 375 | + def _call( |
| 376 | + self, |
| 377 | + messages: list[dict], |
| 378 | + reasoning_effort: Optional[str] = None, |
| 379 | + timeout: Optional[float] = None, |
| 380 | + max_tokens: Optional[int] = None, |
| 381 | + ) -> str: |
| 382 | + payload = { |
| 383 | + "messages": messages, |
| 384 | + "reasoning_effort": reasoning_effort or self._reasoning_effort, |
| 385 | + "max_tokens": max_tokens or self._max_tokens, |
| 386 | + } |
| 387 | + try: |
| 388 | + resp = self._http.post( |
| 389 | + _PROXY_URL, |
| 390 | + json=payload, |
| 391 | + headers={"X-Engine-Secret": _ENGINE_SECRET}, |
| 392 | + timeout=timeout or 120.0, |
| 393 | + ) |
| 394 | + resp.raise_for_status() |
| 395 | + data = resp.json() |
| 396 | + content = data["choices"][0]["message"]["content"] |
| 397 | + if not content: |
| 398 | + raise AIResponseError(message="代理 AI 返回了空响应", detail=str(data)) |
| 399 | + return content |
| 400 | + except AIError: |
| 401 | + raise |
| 402 | + except Exception as e: |
| 403 | + raise AIError(message="代理 AI 请求失败", detail=str(e)) |
0 commit comments