Skip to content

Commit 4b82bc6

Browse files
committed
feat: AI全代理模式 + 版本自动检查(v1.5.4)
1 parent ecc0fb8 commit 4b82bc6

4 files changed

Lines changed: 184 additions & 6 deletions

File tree

extension/package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
"name": "testpilot-ai",
33
"displayName": "TestPilot AI",
44
"description": "AI-powered automated testing robot — operates UI like a human, finds bugs, and auto-fixes them. Supports Web, Android, iOS, Mini Program & Desktop.",
5-
"version": "1.5.3",
5+
"version": "1.5.4",
66
"publisher": "wenzhouxinzao",
77
"engines": {
88
"vscode": "^1.85.0"

extension/src/engineManager.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,9 @@ export class EngineManager {
108108
* 流程:已运行 → 直接返回;已缓存 → 启动;未缓存 → 先下载再启动。
109109
*/
110110
async ensureRunning(): Promise<void> {
111+
// 0. 版本检查(异步,不阻塞启动,但强制更新会提示)
112+
this._checkVersion().catch(() => {});
113+
111114
// 1. 已有引擎在跑,直接复用
112115
if (await this.isEngineRunning()) {
113116
this._outputChannel.appendLine("[引擎] 检测到引擎已运行,直接连接");
@@ -259,4 +262,63 @@ export class EngineManager {
259262
}
260263
this._setStatus("offline");
261264
}
265+
266+
/** 版本检查:低于最低版本强制提示,低于最新版本弱提示 */
267+
private async _checkVersion(): Promise<void> {
268+
const VERSION_URL = "https://xinzaoai.com/api/version/check";
269+
const currentVersion = vscode.extensions.getExtension("wenzhouxinzao.testpilot-ai")
270+
?.packageJSON?.version as string | undefined;
271+
if (!currentVersion) { return; }
272+
273+
try {
274+
const data = await new Promise<{ latest: string; minimum: string; changelog?: string }>(
275+
(resolve, reject) => {
276+
const mod = VERSION_URL.startsWith("https") ? https : http;
277+
mod.get(VERSION_URL, { timeout: 8000 }, (res) => {
278+
if (res.statusCode !== 200) { reject(new Error(`HTTP ${res.statusCode}`)); return; }
279+
let body = "";
280+
res.on("data", (c: Buffer) => (body += c.toString()));
281+
res.on("end", () => { try { resolve(JSON.parse(body)); } catch (e) { reject(e); } });
282+
}).on("error", reject).on("timeout", () => reject(new Error("timeout")));
283+
}
284+
);
285+
286+
const cmp = (a: string, b: string) => {
287+
const pa = a.split(".").map(Number);
288+
const pb = b.split(".").map(Number);
289+
for (let i = 0; i < 3; i++) {
290+
if ((pa[i] || 0) < (pb[i] || 0)) { return -1; }
291+
if ((pa[i] || 0) > (pb[i] || 0)) { return 1; }
292+
}
293+
return 0;
294+
};
295+
296+
if (cmp(currentVersion, data.minimum) < 0) {
297+
// 强制更新:弹出模态提示
298+
const action = await vscode.window.showErrorMessage(
299+
`TestPilot AI 当前版本 v${currentVersion} 已不再支持,请更新至 v${data.latest}。`,
300+
{ modal: true },
301+
"前往下载"
302+
);
303+
if (action === "前往下载") {
304+
vscode.env.openExternal(
305+
vscode.Uri.parse("https://xinzaoai.com/downloads/testpilot-ai-" + data.latest + ".vsix")
306+
);
307+
}
308+
} else if (cmp(currentVersion, data.latest) < 0) {
309+
// 弱提示:状态栏提醒,不阻塞
310+
const action = await vscode.window.showInformationMessage(
311+
`TestPilot AI 有新版本 v${data.latest}(当前 v${currentVersion})`,
312+
"下载更新", "忽略"
313+
);
314+
if (action === "下载更新") {
315+
vscode.env.openExternal(
316+
vscode.Uri.parse("https://xinzaoai.com/downloads/testpilot-ai-" + data.latest + ".vsix")
317+
);
318+
}
319+
}
320+
} catch {
321+
// 版本检查失败不影响正常使用,静默忽略
322+
}
323+
}
262324
}

src/app.py

Lines changed: 13 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@
1818
from src.core.config import PROJECT_ROOT
1919
from src.api.websocket import ws_manager
2020
from src.browser.automator import BrowserAutomator
21-
from src.core.ai_client import AIClient
21+
from src.core.ai_client import AIClient, ProxyAIClient
2222
from src.core.config import get_config
2323
from src.core.logger import setup_logger
2424
from src.memory.store import MemoryStore
@@ -52,15 +52,23 @@ def create_app() -> FastAPI:
5252
# 记忆系统(SQLite 本地存储,零外部依赖)
5353
memory_store = MemoryStore()
5454

55-
# AI 客户端(API Key 未配置时优雅降级,测试任务端点将返回503
56-
ai_client: AIClient | None = None
55+
# AI 客户端:有 TP_AI_API_KEY 时直连豆包(开发模式),否则走代理(生产/用户端
56+
ai_client: AIClient | ProxyAIClient | None = None
5757
if config.ai.api_key:
5858
try:
5959
ai_client = AIClient(config.ai)
6060
except Exception as e:
61-
logger.warning("AI 客户端初始化失败(测试任务不可用): {}", e)
61+
logger.warning("AI 客户端初始化失败,降级为代理模式: {}", e)
62+
ai_client = ProxyAIClient(
63+
reasoning_effort=config.ai.reasoning_effort,
64+
max_tokens=config.ai.max_completion_tokens,
65+
)
6266
else:
63-
logger.warning("TP_AI_API_KEY 未配置,测试任务功能不可用")
67+
logger.info("TP_AI_API_KEY 未配置,使用代理模式(xinzaoai.com)")
68+
ai_client = ProxyAIClient(
69+
reasoning_effort=config.ai.reasoning_effort,
70+
max_tokens=config.ai.max_completion_tokens,
71+
)
6472

6573
@asynccontextmanager
6674
async def lifespan(app: FastAPI) -> AsyncGenerator[None, None]:

src/core/ai_client.py

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@
1111
"""
1212

1313
import base64
14+
import json
1415
from pathlib import Path
1516
from typing import Optional
1617

18+
import httpx
1719
from loguru import logger
1820
from openai import OpenAI
1921

@@ -293,3 +295,109 @@ def _call_chat(
293295
message="AI API 调用失败",
294296
detail=error_msg,
295297
)
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

Comments
 (0)