v1.1.0 新增大模型配置管理:页面可配置分析网页的LLM接口(名称/BaseURL/Key/模型/温度/超时/默认),支持视觉标记——视觉模型走截图视觉分析路线,非视觉模型走DOM快照文本分析路线;任务创建可选模型并记录使用情况

This commit is contained in:
2026-08-10 12:01:05 +08:00
parent fd67548052
commit 69b6f8ccfb
8 changed files with 678 additions and 43 deletions
+93 -16
View File
@@ -53,6 +53,55 @@ SYSTEM_PROMPT = """你是一个专业的网页自动化测试工程师,正在
6. 快照可能被截断,必要时用 wait 等待页面加载完成再操作。
"""
# 视觉模型专用提示词:通过截图观察页面视觉状态,而不是依赖 DOM 快照文本
VISION_SYSTEM_PROMPT = """你是一个专业的网页自动化测试工程师,通过**页面截图**观察网页的真实视觉状态,并使用浏览器工具执行端到端测试任务。
你的目标:根据用户给的测试目标,直接观察截图画面,实际操作网页完成测试,并通过断言判断测试是否通过。
## 你的感知方式(重点)
- 每次决策时,你都会收到一张当前页面的**截图**,请仔细观察截图中的:页面布局、可见文案、按钮、输入框、弹窗、错误提示、加载状态等视觉信息。
- 截图里能看到什么,就以什么为准;看不到的内容不要臆测。
- 若截图不完整或看不清楚,可以用 wait 等待加载,或用 scroll 滚动后截图再看。
## 可用的浏览器能力
- click text:按钮文字 点击页面上可见的按钮/链接(用截图里看到的文字定位,推荐)
- click @e1 点击元素(@e1 是附带的元素引用列表里的编号)
- fill text:输入框 "" 在输入框填入文字(会先清空)
- select text:选项 "" 选择下拉框选项
- press "Enter" 按键(Enter/Tab/Escape/Control+a 等)
- wait "2000" 等待毫秒;wait text:"xx" 等待文本出现;wait url:"/path" 等待 URL 变化
- scroll "down 500" 滚动页面
- screenshot 截图留证(重要步骤请调用)
- assert 断言验证(见下)
- done 测试完成(全部通过)
- fail 测试失败(无法继续或断言不通过)
## 断言类型 assert
{"type":"text","expect":"欢迎","present":true} 截图中/页面上应出现"欢迎"文本
{"type":"text","expect":"错误提示","present":false} 页面上不应出现"错误提示"
{"type":"url","expect":"/dashboard","present":true} URL 应包含 /dashboard
{"type":"title","expect":"首页","present":true} 页面标题应包含"首页"
{"type":"element","expect":"登录","present":true} 页面上应存在文本为"登录"的元素
断言执行后会返回 PASS 或 FAIL,根据结果决定下一步。
## 输出格式(严格 JSON,不要输出其他内容)
{
"reason": "简要中文说明你在截图中看到了什么、当前判断和下一步计划",
"action": "click|fill|select|press|wait|scroll|screenshot|assert|done|fail",
"target": "text:按钮文字 或 @e1",
"value": "填入的值/按键名/等待参数",
"assert": {断言对象,action=assert 时必填},
"summary": "测试结论(action=done/fail 时必填,说明验证了什么)"
}
## 工作原则
1. 先看清截图再动手:观察布局、找目标元素、确认它的文字和位置,每一步只做一件事。
2. 优先用截图里看到的文字做 text: 语义定位;附带的元素引用列表可帮助确定编号。
3. 操作后必须用 assert 验证结果,不要盲目继续。
4. 测试失败(断言 FAIL 且无补救)时用 fail 结束,并说明原因。
5. 全部验证通过后用 done 结束,summary 里总结测试覆盖的内容。
6. 页面可能未加载完(白屏/转圈),先用 wait 等待再操作。
"""
def _fmt_snapshot(snap, max_chars=DEFAULT_MAX_SNAPSHOT_CHARS):
"""把快照数据转成紧凑文本给 LLM"""
@@ -89,13 +138,15 @@ def _fmt_history(steps):
class TaskRunner(threading.Thread):
def __init__(self, task_id, url, goal, max_steps, timeout):
def __init__(self, task_id, url, goal, max_steps, timeout, llm_config=None):
super().__init__(daemon=True, name=f'task-{task_id}')
self.task_id = task_id
self.url = url
self.goal = goal
self.max_steps = max_steps
self.timeout = timeout
self.llm_cfg = llm_config or {} # 模型配置快照(base_url/api_key/model/temperature/timeout/vision
self.vision = bool(self.llm_cfg.get('vision')) # True=截图视觉分析, False=DOM快照文本分析
self.stop_flag = threading.Event()
self.task_dir = os.path.join(TASKS_DIR, task_id)
os.makedirs(self.task_dir, exist_ok=True)
@@ -174,9 +225,10 @@ class TaskRunner(threading.Thread):
cur_url = browser.url()
cur_title = browser.title()
# 2. LLM 决策
# 2. LLM 决策(视觉模型带截图,非视觉模型带 DOM 快照文本)
self._set_current(phase='AI 决策中', step=step_n,
detail='正在分析页面并决定下一步动作...')
detail=('正在视觉分析页面截图...' if self.vision
else '正在分析页面并决定下一步动作...'))
user_msg = (
f'## 测试目标\n{self.goal}\n\n'
f'## 当前页面\nURL: {cur_url}\n标题: {cur_title}\n\n'
@@ -184,11 +236,13 @@ class TaskRunner(threading.Thread):
f'## 已执行步骤\n' + (_fmt_history(steps) if steps else '(尚无)') +
f'\n\n请输出下一步动作的 JSON。'
)
user_content = self._build_user_content(user_msg, browser, step_n)
try:
decision = chat_json(
[{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': user_msg}],
temperature=0.2, max_tokens=500)
[{'role': 'system',
'content': VISION_SYSTEM_PROMPT if self.vision else SYSTEM_PROMPT},
{'role': 'user', 'content': user_content}],
cfg=self.llm_cfg, temperature=0.2, max_tokens=500)
except LLMError as e:
self._log(f'LLM 错误: {e}')
self._finish(browser, 'error', f'LLM 决策失败: {e}', steps)
@@ -237,6 +291,26 @@ class TaskRunner(threading.Thread):
self._log(f'未知异常: {traceback.format_exc()}')
self._finish(browser, 'error', f'异常: {e}', steps)
def _build_user_content(self, user_msg, browser, step_n):
"""构建用户消息:视觉模型=文本+截图;非视觉模型=纯文本。
截图失败时自动降级为纯文本分析。"""
if not self.vision:
return user_msg
import base64
shot_path = os.path.join(self.task_dir, f'decide{step_n:02d}.png')
try:
browser.screenshot(shot_path, timeout=30)
with open(shot_path, 'rb') as f:
b64 = base64.b64encode(f.read()).decode('ascii')
return [
{'type': 'text', 'text': user_msg},
{'type': 'image_url',
'image_url': {'url': f'data:image/png;base64,{b64}'}},
]
except Exception as e:
self._log(f'视觉截图失败,降级为文本分析: {e}')
return user_msg
def _record(self, n, decision, result, detail='', extra=None):
rec = {
'n': n,
@@ -316,17 +390,20 @@ class TaskRunner(threading.Thread):
snap_text = _fmt_snapshot(snap, max_chars=5000)
except Exception:
snap_text = '(快照失败)'
retry_msg = (
f'## 测试目标\n{self.goal}\n\n'
f'## 刚才执行失败\n动作: {action} 目标: {target} 值: {value}\n'
f'错误: {last_err}\n\n'
f'## 当前页面元素\n{snap_text}\n\n'
f'请换一种方式完成相同意图,输出下一步动作 JSON。'
f'如果确认无法完成,输出 {{"action":"fail","reason":"...","summary":"..."}}'
)
retry_dec = chat_json(
[{'role': 'system', 'content': SYSTEM_PROMPT},
{'role': 'user', 'content': (
f'## 测试目标\n{self.goal}\n\n'
f'## 刚才执行失败\n动作: {action} 目标: {target} 值: {value}\n'
f'错误: {last_err}\n\n'
f'## 当前页面元素\n{snap_text}\n\n'
f'请换一种方式完成相同意图,输出下一步动作 JSON。'
f'如果确认无法完成,输出 {{"action":"fail","reason":"...","summary":"..."}}'
)}],
temperature=0.2, max_tokens=400)
[{'role': 'system',
'content': VISION_SYSTEM_PROMPT if self.vision else SYSTEM_PROMPT},
{'role': 'user',
'content': self._build_user_content(retry_msg, browser, self.current.get('step', 0) + 99)}],
cfg=self.llm_cfg, temperature=0.2, max_tokens=400)
new_action = retry_dec.get('action', '')
if new_action == 'fail':
return 'error', f'自愈放弃: {retry_dec.get("summary", last_err)}', {}