3 Commits
8 changed files with 701 additions and 45 deletions
+101 -17
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)
@@ -139,7 +190,14 @@ class TaskRunner(threading.Thread):
self._set_current(phase='打开页面', detail=self.url)
self._log(f'打开页面: {self.url}')
browser.open(self.url, timeout=60)
browser.wait('--load', 'networkidle', timeout=45)
# 等待页面加载:优先 networkidle;若页面依赖的外部 CDN 挂起导致
# networkidle 永不满足,降级为等待 load 事件 + 短暂缓冲,不阻断测试
try:
browser.wait('--load', 'networkidle', timeout=20)
except BrowserError:
self._log('networkidle 超时(可能外部 CDN 慢),降级等待 load 事件')
browser.wait('--load', 'load', timeout=30)
time.sleep(2)
self._log('页面已打开')
self._set_current(phase='页面已打开')
@@ -167,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'
@@ -177,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, max_tokens=1024)
except LLMError as e:
self._log(f'LLM 错误: {e}')
self._finish(browser, 'error', f'LLM 决策失败: {e}', steps)
@@ -230,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,
@@ -309,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, max_tokens=800)
new_action = retry_dec.get('action', '')
if new_action == 'fail':
return 'error', f'自愈放弃: {retry_dec.get("summary", last_err)}', {}
+148 -5
View File
@@ -7,7 +7,10 @@ from flask import Flask, request, jsonify, send_from_directory, abort
from flask_cors import CORS
import config
from db import init_db, create_task, get_task, list_tasks, load_step_logs, update_task
from db import (init_db, create_task, get_task, list_tasks, load_step_logs,
update_task, list_llm_configs, get_llm_config,
get_default_llm_config, create_llm_config, update_llm_config,
delete_llm_config, set_default_llm_config, llm_config_to_dict)
from agent import TaskRunner
app = Flask(__name__, static_folder='static', static_url_path='/static')
@@ -27,11 +30,134 @@ def index():
@app.route('/health')
def health():
default_cfg = get_default_llm_config()
return jsonify({'status': 'ok', 'version': '1.0.0',
'llm_model': config.LLM_MODEL,
'llm_model': default_cfg.get('model') if default_cfg else None,
'llm_name': default_cfg.get('name') if default_cfg else None,
'llm_vision': bool(default_cfg.get('vision')) if default_cfg else None,
'concurrent': len(_runners)})
# ========== 大模型配置管理 ==========
@app.route('/api/llm-configs', methods=['GET'])
def api_list_llm_configs():
return jsonify({'configs': list_llm_configs()})
def _parse_llm_fields(data, partial=False):
"""校验并提取模型配置字段,返回 (fields, error)"""
fields = {}
for key, label in (('name', '名称'), ('base_url', 'Base URL'),
('api_key', 'API Key'), ('model', '模型名')):
val = (data.get(key) or '').strip()
if partial and key not in data:
continue
if not val:
return None, f'缺少{label}'
fields[key] = val
if 'vision' in data:
fields['vision'] = 1 if data.get('vision') else 0
if 'temperature' in data:
try:
fields['temperature'] = float(data.get('temperature'))
except (TypeError, ValueError):
return None, '温度必须是数字'
if 'timeout' in data:
try:
fields['timeout'] = max(10, min(int(data.get('timeout')), 600))
except (TypeError, ValueError):
return None, '超时必须是数字'
if 'is_default' in data:
fields['is_default'] = 1 if data.get('is_default') else 0
return fields, None
@app.route('/api/llm-configs', methods=['POST'])
def api_create_llm_config():
data = request.get_json(silent=True) or {}
fields, err = _parse_llm_fields(data)
if err:
return jsonify({'error': err}), 400
cid = create_llm_config(
name=fields['name'], base_url=fields['base_url'],
api_key=fields['api_key'], model=fields['model'],
vision=fields.get('vision', 0),
temperature=fields.get('temperature', 0.2),
timeout=fields.get('timeout', 120),
is_default=fields.get('is_default', False))
return jsonify({'ok': True, 'id': cid}), 201
@app.route('/api/llm-configs/<int:cid>', methods=['PUT'])
def api_update_llm_config(cid):
if not get_llm_config(cid):
abort(404)
data = request.get_json(silent=True) or {}
fields, err = _parse_llm_fields(data, partial=True)
if err:
return jsonify({'error': err}), 400
update_llm_config(cid, **fields)
return jsonify({'ok': True})
@app.route('/api/llm-configs/<int:cid>', methods=['DELETE'])
def api_delete_llm_config(cid):
row = get_llm_config(cid)
if not row:
abort(404)
if row.get('is_default'):
return jsonify({'error': '默认配置不能删除,请先设置其他配置为默认'}), 400
delete_llm_config(cid)
return jsonify({'ok': True})
@app.route('/api/llm-configs/<int:cid>/default', methods=['POST'])
def api_set_default_llm_config(cid):
if not get_llm_config(cid):
abort(404)
set_default_llm_config(cid)
return jsonify({'ok': True})
@app.route('/api/llm-configs/<int:cid>/test', methods=['POST'])
def api_test_llm_config(cid):
row = get_llm_config(cid)
if not row:
abort(404)
return _test_llm(llm_config_to_dict(row))
@app.route('/api/llm-configs/test-form', methods=['POST'])
def api_test_llm_config_form():
"""测试未保存的表单配置(前端弹窗里点「测试连接」用)"""
data = request.get_json(silent=True) or {}
base_url = (data.get('base_url') or '').strip()
api_key = (data.get('api_key') or '').strip()
model = (data.get('model') or '').strip()
if not base_url or not api_key or not model:
return jsonify({'error': '缺少 base_url / api_key / model'}), 400
return _test_llm({
'base_url': base_url,
'api_key': api_key,
'model': model,
'temperature': data.get('temperature', 0.2),
'timeout': data.get('timeout', 30),
})
def _test_llm(cfg):
from llm import chat, LLMError
try:
reply = chat(
[{'role': 'user', 'content': '请只回复两个字:正常'}],
cfg=cfg, max_tokens=20, timeout=30)
return jsonify({'ok': True, 'reply': (reply or '')[:200]})
except LLMError as e:
return jsonify({'ok': False, 'error': str(e)}), 400
@app.route('/api/tasks', methods=['POST'])
def api_create_task():
data = request.get_json(silent=True) or {}
@@ -48,13 +174,28 @@ def api_create_task():
max_steps = max(1, min(max_steps, 100))
timeout = max(30, min(timeout, 3600))
tid = create_task(url, goal, max_steps, timeout)
# 选择分析模型:指定 id → 默认配置
cfg_row = None
if data.get('llm_config_id'):
cfg_row = get_llm_config(int(data.get('llm_config_id')))
if not cfg_row:
return jsonify({'error': '指定的模型配置不存在'}), 400
else:
cfg_row = get_default_llm_config()
if not cfg_row:
return jsonify({'error': '尚未配置任何大模型,请先在「大模型配置」中添加'}), 400
llm_cfg = llm_config_to_dict(cfg_row)
tid = create_task(url, goal, max_steps, timeout,
llm_config_id=cfg_row['id'],
llm_name=cfg_row['name'],
vision=cfg_row['vision'])
def _launch():
with _semaphore:
if get_task(tid) and get_task(tid).get('status') == 'stopped':
return
runner = TaskRunner(tid, url, goal, max_steps, timeout)
runner = TaskRunner(tid, url, goal, max_steps, timeout, llm_cfg)
with _runner_lock:
_runners[tid] = runner
runner.start()
@@ -64,7 +205,9 @@ def api_create_task():
threading.Thread(target=_launch, daemon=True).start()
return jsonify({'task_id': tid, 'status': 'queued',
'url': url, 'goal': goal}), 202
'url': url, 'goal': goal,
'llm_name': cfg_row['name'],
'vision': bool(cfg_row['vision'])}), 202
@app.route('/api/tasks', methods=['GET'])
+3 -1
View File
@@ -17,12 +17,14 @@ NODE_BIN_DIR = os.path.dirname(AGENT_BROWSER)
# agent-browser 需要可写的 socket 目录,固定用 /tmp 下的(系统 XDG_RUNTIME_DIR 可能属于其他用户)
XDG_RUNTIME_DIR = '/tmp/xdg-rt'
# LLM 配置(OpenAI 兼容接口)
# LLM 配置(OpenAI 兼容接口)——仅作为首次初始化默认模型配置的种子值,
# 运行后以页面「大模型配置」中保存的配置为准(存于 SQLite llm_configs 表)
LLM_BASE_URL = os.environ.get('LLM_BASE_URL', 'https://ark.cn-beijing.volces.com/api/plan/v3')
LLM_API_KEY = os.environ.get('LLM_API_KEY', 'ark-2b06dc9d-8878-4c6e-b201-f422376e79cb-246d9')
LLM_MODEL = os.environ.get('LLM_MODEL', 'doubao-seed-evolving')
LLM_TEMPERATURE = 0.2
LLM_TIMEOUT = 120
DEFAULT_LLM_NAME = '火山引擎豆包(默认)' # 首次启动时写入 llm_configs 的显示名
# Agent 默认参数
DEFAULT_MAX_STEPS = 30 # 最大动作步数
+132 -4
View File
@@ -34,17 +34,51 @@ def init_db():
report_path TEXT
)
''')
# 大模型配置表
c.execute('''
CREATE TABLE IF NOT EXISTS llm_configs (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL,
base_url TEXT NOT NULL,
api_key TEXT NOT NULL,
model TEXT NOT NULL,
vision INTEGER DEFAULT 0,
temperature REAL DEFAULT 0.2,
timeout INTEGER DEFAULT 120,
is_default INTEGER DEFAULT 0,
created_at REAL
)
''')
# tasks 表迁移:新增模型相关列(老库升级)
cols = [r[1] for r in c.execute('PRAGMA table_info(tasks)')]
if 'llm_config_id' not in cols:
c.execute('ALTER TABLE tasks ADD COLUMN llm_config_id INTEGER')
if 'llm_name' not in cols:
c.execute('ALTER TABLE tasks ADD COLUMN llm_name TEXT')
if 'vision' not in cols:
c.execute('ALTER TABLE tasks ADD COLUMN vision INTEGER DEFAULT 0')
# 首次启动:写入默认模型配置
n = c.execute('SELECT COUNT(*) FROM llm_configs').fetchone()[0]
if n == 0:
from config import (LLM_BASE_URL, LLM_API_KEY, LLM_MODEL,
LLM_TEMPERATURE, LLM_TIMEOUT, DEFAULT_LLM_NAME)
c.execute(
'INSERT INTO llm_configs (name, base_url, api_key, model, vision, '
'temperature, timeout, is_default, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
(DEFAULT_LLM_NAME, LLM_BASE_URL, LLM_API_KEY, LLM_MODEL, 0,
LLM_TEMPERATURE, LLM_TIMEOUT, 1, time.time()))
c.commit()
c.close()
def create_task(url, goal, max_steps, timeout):
def create_task(url, goal, max_steps, timeout, llm_config_id=None, llm_name=None, vision=0):
tid = uuid.uuid4().hex[:12]
c = _conn()
c.execute(
'INSERT INTO tasks (id, url, goal, status, max_steps, timeout, created_at) '
'VALUES (?,?,?,?,?,?,?)',
(tid, url, goal, 'queued', max_steps, timeout, time.time()))
'INSERT INTO tasks (id, url, goal, status, max_steps, timeout, created_at, '
'llm_config_id, llm_name, vision) VALUES (?,?,?,?,?,?,?,?,?,?)',
(tid, url, goal, 'queued', max_steps, timeout, time.time(),
llm_config_id, llm_name, 1 if vision else 0))
c.commit()
c.close()
return tid
@@ -104,3 +138,97 @@ def load_step_logs(tid):
except json.JSONDecodeError:
pass
return out
# ========== 大模型配置 ==========
def list_llm_configs():
c = _conn()
rows = c.execute(
'SELECT * FROM llm_configs ORDER BY is_default DESC, id ASC'
).fetchall()
c.close()
return [dict(r) for r in rows]
def get_llm_config(cid):
c = _conn()
row = c.execute('SELECT * FROM llm_configs WHERE id=?', (cid,)).fetchone()
c.close()
return dict(row) if row else None
def get_default_llm_config():
c = _conn()
row = c.execute('SELECT * FROM llm_configs WHERE is_default=1').fetchone()
if not row:
row = c.execute('SELECT * FROM llm_configs ORDER BY id ASC').fetchone()
c.close()
return dict(row) if row else None
def create_llm_config(name, base_url, api_key, model, vision=0,
temperature=0.2, timeout=120, is_default=False):
c = _conn()
if is_default:
c.execute('UPDATE llm_configs SET is_default=0')
c.execute(
'INSERT INTO llm_configs (name, base_url, api_key, model, vision, '
'temperature, timeout, is_default, created_at) VALUES (?,?,?,?,?,?,?,?,?)',
(name, base_url, api_key, model, 1 if vision else 0,
temperature, timeout, 1 if is_default else 0, time.time()))
c.commit()
cid = c.execute('SELECT last_insert_rowid()').fetchone()[0]
c.close()
return cid
def update_llm_config(cid, **fields):
allowed = {'name', 'base_url', 'api_key', 'model', 'vision',
'temperature', 'timeout', 'is_default'}
c = _conn()
if fields.get('is_default'):
c.execute('UPDATE llm_configs SET is_default=0')
sets = []
vals = []
for k, v in fields.items():
if k not in allowed:
continue
if k == 'vision':
v = 1 if v else 0
if k == 'is_default':
v = 1 if v else 0
sets.append(f'{k}=?')
vals.append(v)
if sets:
c.execute(f'UPDATE llm_configs SET {", ".join(sets)} WHERE id=?', (*vals, cid))
c.commit()
c.close()
def delete_llm_config(cid):
c = _conn()
c.execute('DELETE FROM llm_configs WHERE id=?', (cid,))
c.commit()
c.close()
def set_default_llm_config(cid):
c = _conn()
c.execute('UPDATE llm_configs SET is_default=0')
c.execute('UPDATE llm_configs SET is_default=1 WHERE id=?', (cid,))
c.commit()
c.close()
def llm_config_to_dict(row):
"""把配置行转成 llm.py 可用的 dict(不含数据库元信息)"""
return {
'base_url': row['base_url'],
'api_key': row['api_key'],
'model': row['model'],
'temperature': row['temperature'],
'timeout': row['timeout'],
'vision': bool(row['vision']),
}
+57 -15
View File
@@ -1,5 +1,9 @@
#!/usr/bin/env python3
"""LLM 客户端(OpenAI 兼容接口)"""
"""LLM 客户端(OpenAI 兼容接口)
支持按模型配置调用(base_url / api_key / model / temperature / timeout),
支持多模态消息(content 为 [{type:text},{type:image_url}] 列表,用于视觉模型分析截图)。
"""
import json
import re
import urllib.request
@@ -12,6 +16,21 @@ class LLMError(Exception):
pass
def _cfg(cfg):
"""补齐默认值,返回完整配置 dict"""
base = {
'base_url': LLM_BASE_URL,
'api_key': LLM_API_KEY,
'model': LLM_MODEL,
'temperature': LLM_TEMPERATURE,
'timeout': LLM_TIMEOUT,
'vision': False,
}
if cfg:
base.update({k: v for k, v in cfg.items() if v is not None})
return base
def _extract_json(text):
"""从 LLM 输出中提取 JSON(容忍 markdown 代码块等包裹)"""
if not text:
@@ -35,13 +54,19 @@ def _extract_json(text):
return None
def chat(messages, temperature=None, max_tokens=None, timeout=None):
"""调用 OpenAI 兼容 chat/completions,返回 content 字符串"""
url = f'{LLM_BASE_URL}/chat/completions'
def chat(messages, cfg=None, temperature=None, max_tokens=None, timeout=None):
"""调用 OpenAI 兼容 chat/completions,返回 content 字符串
messages 中的 content 可以是字符串,也可以是多模态列表:
[{"type":"text","text":"..."},
{"type":"image_url","image_url":{"url":"data:image/png;base64,..."}}]
"""
c = _cfg(cfg)
url = f"{c['base_url'].rstrip('/')}/chat/completions"
body = {
'model': LLM_MODEL,
'model': c['model'],
'messages': messages,
'temperature': temperature if temperature is not None else LLM_TEMPERATURE,
'temperature': temperature if temperature is not None else c['temperature'],
}
if max_tokens:
body['max_tokens'] = max_tokens
@@ -50,12 +75,12 @@ def chat(messages, temperature=None, max_tokens=None, timeout=None):
data=json.dumps(body).encode('utf-8'),
headers={
'Content-Type': 'application/json',
'Authorization': f'Bearer {LLM_API_KEY}',
'Authorization': f'Bearer {c["api_key"]}',
},
method='POST',
)
try:
with urllib.request.urlopen(req, timeout=timeout or LLM_TIMEOUT) as resp:
with urllib.request.urlopen(req, timeout=timeout or c['timeout']) as resp:
data = json.loads(resp.read().decode('utf-8'))
except urllib.error.HTTPError as e:
detail = e.read().decode('utf-8', 'ignore')[:300]
@@ -64,26 +89,43 @@ def chat(messages, temperature=None, max_tokens=None, timeout=None):
raise LLMError(f'LLM 调用失败: {e}')
try:
return data['choices'][0]['message']['content']
msg = data['choices'][0]['message']
content = msg.get('content') or ''
if not content.strip():
# thinking 模型(如 qwen3/deepseek-r1 系):最终答案可能为空,
# 思考过程放在 reasoning_content 里(常包含最终 JSON),用它兜底
reasoning = msg.get('reasoning_content') or ''
if reasoning.strip():
return reasoning
return content
except (KeyError, IndexError, TypeError):
raise LLMError(f'LLM 响应异常: {str(data)[:300]}')
def chat_json(messages, temperature=None, max_tokens=None, retries=2):
def _trim_content(text, limit=300):
"""截断过长的模型输出(避免超长思考内容反复进入上下文)"""
text = (text or '').strip()
return text if len(text) <= limit else text[:limit] + '...(已截断)'
def chat_json(messages, cfg=None, temperature=None, max_tokens=None, retries=2):
"""调用 LLM 并强制解析 JSON,失败重试"""
last_err = None
last_content = ''
for i in range(retries + 1):
try:
content = chat(messages, temperature=temperature, max_tokens=max_tokens)
obj = _extract_json(content)
last_content = chat(messages, cfg=cfg, temperature=temperature,
max_tokens=max_tokens)
obj = _extract_json(last_content)
if obj is not None:
return obj
last_err = f'无法从输出解析 JSON: {content[:200]}'
last_err = f'无法从输出解析 JSON: {last_content[:200]}'
except LLMError as e:
last_err = str(e)
if i < retries:
messages = messages + [
{'role': 'assistant', 'content': content if 'content' in dir() else ''},
{'role': 'user', 'content': f'刚才的输出不是合法 JSON,请只输出严格的 JSON 对象。错误: {last_err}'},
{'role': 'assistant', 'content': _trim_content(last_content)},
{'role': 'user',
'content': f'刚才的输出不是合法 JSON,请只输出严格的 JSON 对象,不要输出思考过程和代码块。错误: {last_err}'},
]
raise LLMError(f'LLM JSON 解析失败: {last_err}')
+20
View File
@@ -78,3 +78,23 @@ a.report-link:hover { text-decoration: underline; }
.step-shot img { max-width: 320px; border-radius: 8px; border: 1px solid #e5e7eb; display: block; margin-top: 6px; cursor: zoom-in; }
.step-detail { font-size: 12px; color: #374151; }
.step-time { font-size: 11px; color: #9ca3af; margin-left: 8px; }
/* 大模型配置 */
select { padding: 10px 12px; border: 1px solid #d1d5db; border-radius: 8px; font-size: 14px; background: #fff; }
select:focus { outline: none; border-color: #2563eb; box-shadow: 0 0 0 3px rgba(37,99,235,.1); }
.mode-badge { display: inline-block; padding: 2px 8px; border-radius: 999px; font-size: 11px; font-weight: 600; white-space: nowrap; }
.mode-badge.vision { background: #ede9fe; color: #6d28d9; }
.mode-badge.text { background: #f3f4f6; color: #4b5563; }
.tag { font-size: 11px; font-weight: 400; margin-left: 6px; padding: 1px 8px; border-radius: 999px; }
.tag-vision { background: #ede9fe; color: #6d28d9; }
.tag-text { background: #f3f4f6; color: #4b5563; }
.url-cell { max-width: 240px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.model-cell { font-weight: 600; }
.ops { white-space: nowrap; }
.llm-modal-body { width: 560px; height: auto; max-height: 92vh; }
.llm-form { padding: 18px 20px; display: grid; grid-template-columns: 1fr 1fr; gap: 14px; overflow-y: auto; }
.llm-opts { display: flex; flex-direction: row; gap: 20px; align-items: center; }
.check { display: flex; align-items: center; gap: 6px; font-size: 13px; color: #374151; cursor: pointer; }
.check input { width: auto; }
.llm-actions { grid-column: span 2; display: flex; align-items: center; gap: 12px; }
.llm-test-result { font-size: 12px; color: #6b7280; word-break: break-all; }
+59 -1
View File
@@ -28,13 +28,27 @@
<label>最大步数</label>
<input id="max_steps" type="number" value="30" min="1" max="100">
</div>
<div class="field">
<label>分析模型 <span class="tag" id="model-tag"></span></label>
<select id="llm_config_id"></select>
</div>
<div class="field">
<label>超时(秒)</label>
<input id="timeout" type="number" value="600" min="30" max="3600">
</div>
</div>
<button id="submit" class="btn primary">🚀 开始测试</button>
<div class="hint">测试由 AI 自动驱动浏览器执行:打开页面 → 逐步操作 → 断言验证 → 生成报告(含截图)</div>
<div class="hint">测试由 AI 自动驱动浏览器执行:打开页面 → 逐步操作 → 断言验证 → 生成报告(含截图)
<b>🖼️ 视觉模型</b>:通过截图直接观察页面视觉状态分析;<b>📄 文本模型</b>:通过 DOM 元素快照分析。</div>
</section>
<section class="card">
<h2>🔌 大模型配置 <button id="add-llm" class="btn small primary"> 新增配置</button></h2>
<table id="llm-table">
<thead><tr><th>名称</th><th>模型</th><th>Base URL</th><th>分析方式</th><th>温度</th><th>超时</th><th>默认</th><th>操作</th></tr></thead>
<tbody><tr><td colspan="8" class="empty">加载中...</td></tr></tbody>
</table>
<div class="hint">配置 OpenAI 兼容接口的大模型。支持视觉的模型(如 GPT-4o、豆包视觉版)用截图分析网页,不支持视觉的模型用 DOM 快照分析。</div>
</section>
<section class="card">
@@ -80,6 +94,50 @@
</div>
</div>
<div id="llm-modal" class="modal hidden">
<div class="modal-body llm-modal-body">
<div class="modal-head"><span id="llm-modal-title">新增模型配置</span><button id="llm-modal-close" class="btn small">关闭</button></div>
<div class="llm-form">
<div class="field">
<label>配置名称</label>
<input id="llm-name" type="text" placeholder="例如:火山引擎豆包视觉版">
</div>
<div class="field">
<label>Base URLOpenAI 兼容接口地址)</label>
<input id="llm-base-url" type="text" placeholder="https://ark.cn-beijing.volces.com/api/plan/v3">
</div>
<div class="field">
<label>API Key</label>
<input id="llm-api-key" type="password" placeholder="sk-...">
</div>
<div class="field">
<label>模型名</label>
<input id="llm-model" type="text" placeholder="例如:doubao-seed-evolving">
</div>
<div class="field span2 llm-opts">
<label class="check"><input id="llm-vision" type="checkbox"> 🖼️ 支持视觉分析(通过页面截图观察分析)</label>
<label class="check"><input id="llm-default" type="checkbox"> ⭐ 设为默认配置</label>
</div>
<div class="field">
<label>温度(0~1</label>
<input id="llm-temperature" type="number" value="0.2" min="0" max="1" step="0.1">
</div>
<div class="field">
<label>超时(秒)</label>
<input id="llm-timeout" type="number" value="120" min="10" max="600">
</div>
<div class="llm-actions">
<button id="llm-test" class="btn small">🔌 测试连接</button>
<span id="llm-test-result" class="llm-test-result"></span>
</div>
<div class="llm-actions">
<button id="llm-save" class="btn primary">💾 保存</button>
<button id="llm-cancel" class="btn small">取消</button>
</div>
</div>
</div>
</div>
<script src="/static/js/app.js"></script>
</body>
</html>
+181 -2
View File
@@ -6,7 +6,8 @@ async function refreshHealth() {
try {
const r = await fetch(API + '/health');
const d = await r.json();
$('#health').textContent = `服务正常 | 模型: ${d.llm_model} | 运行中任务: ${d.concurrent}`;
const mode = d.llm_vision ? '🖼️视觉' : '📄文本';
$('#health').textContent = `服务正常 | 默认模型: ${d.llm_name || d.llm_model || '-'}${mode} | 运行中任务: ${d.concurrent}`;
$('#health').classList.add('ok');
} catch (e) {
$('#health').textContent = '服务异常';
@@ -21,6 +22,180 @@ const RESULT_MAP = {
pending: '待定', pass: '✅ 通过', fail: '❌ 失败', error: '⚠️ 错误', stopped: '⏹️ 停止'
};
/* ========== 大模型配置 ========== */
let llmConfigs = [];
let editingLlmId = null;
function llmBadge(cfg) {
return cfg.vision ? '🖼️ 视觉' : '📄 文本';
}
async function loadLlmConfigs() {
try {
const r = await fetch(API + '/api/llm-configs');
const d = await r.json();
llmConfigs = d.configs || [];
renderLlmTable();
renderLlmSelect();
} catch (e) { /* ignore */ }
}
function renderLlmTable() {
const tb = $('#llm-table tbody');
if (!llmConfigs.length) {
tb.innerHTML = '<tr><td colspan="8" class="empty">暂无配置,点右上角「新增配置」添加</td></tr>';
return;
}
tb.innerHTML = llmConfigs.map(c => `
<tr>
<td>${esc(c.name)}</td>
<td>${esc(c.model)}</td>
<td class="url-cell" title="${esc(c.base_url)}">${esc(c.base_url)}</td>
<td><span class="mode-badge ${c.vision ? 'vision' : 'text'}">${llmBadge(c)}</span></td>
<td>${c.temperature}</td>
<td>${c.timeout}s</td>
<td>${c.is_default ? '⭐' : ''}</td>
<td class="ops">
<button class="btn small" onclick="openLlmModal(${c.id})">编辑</button>
<button class="btn small" onclick="testLlm(${c.id})">测试</button>
${c.is_default ? '' : `<button class="btn small" onclick="setDefaultLlm(${c.id})">设默认</button>`}
${c.is_default ? '' : `<button class="btn small danger" onclick="deleteLlm(${c.id})">删除</button>`}
</td>
</tr>`).join('');
}
function renderLlmSelect() {
const sel = $('#llm_config_id');
if (!sel) return;
if (!llmConfigs.length) {
sel.innerHTML = '<option value="">(请先添加模型配置)</option>';
$('#model-tag').textContent = '';
return;
}
sel.innerHTML = llmConfigs.map(c =>
`<option value="${c.id}" ${c.is_default ? 'selected' : ''}>${esc(c.name)}${llmBadge(c)}</option>`
).join('');
updateModelTag();
}
function updateModelTag() {
const id = parseInt($('#llm_config_id').value);
const cfg = llmConfigs.find(c => c.id === id);
$('#model-tag').textContent = cfg ? llmBadge(cfg) : '';
$('#model-tag').className = 'tag ' + (cfg && cfg.vision ? 'tag-vision' : 'tag-text');
}
function openLlmModal(id) {
editingLlmId = id || null;
const c = id ? llmConfigs.find(x => x.id === id) : null;
$('#llm-modal-title').textContent = c ? `编辑配置 - ${c.name}` : '新增模型配置';
$('#llm-name').value = c ? c.name : '';
$('#llm-base-url').value = c ? c.base_url : '';
$('#llm-api-key').value = c ? c.api_key : '';
$('#llm-model').value = c ? c.model : '';
$('#llm-vision').checked = c ? !!c.vision : false;
$('#llm-default').checked = c ? !!c.is_default : false;
$('#llm-temperature').value = c ? c.temperature : 0.2;
$('#llm-timeout').value = c ? c.timeout : 120;
$('#llm-test-result').textContent = '';
$('#llm-modal').classList.remove('hidden');
}
function closeLlmModal() {
$('#llm-modal').classList.add('hidden');
editingLlmId = null;
}
async function saveLlm() {
const body = {
name: $('#llm-name').value.trim(),
base_url: $('#llm-base-url').value.trim(),
api_key: $('#llm-api-key').value.trim(),
model: $('#llm-model').value.trim(),
vision: $('#llm-vision').checked,
is_default: $('#llm-default').checked,
temperature: parseFloat($('#llm-temperature').value) || 0.2,
timeout: parseInt($('#llm-timeout').value) || 120
};
if (!body.name || !body.base_url || !body.api_key || !body.model) {
alert('请填写名称、Base URL、API Key、模型名');
return;
}
const url = editingLlmId ? `${API}/api/llm-configs/${editingLlmId}` : `${API}/api/llm-configs`;
const method = editingLlmId ? 'PUT' : 'POST';
try {
const r = await fetch(url, {
method, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const d = await r.json();
if (d.error) { alert('保存失败: ' + d.error); return; }
closeLlmModal();
loadLlmConfigs();
} catch (e) { alert('保存失败: ' + e); }
}
async function deleteLlm(id) {
const c = llmConfigs.find(x => x.id === id);
if (!confirm(`确定删除配置「${c.name}」?`)) return;
const r = await fetch(API + `/api/llm-configs/${id}`, { method: 'DELETE' });
const d = await r.json();
if (d.error) { alert('删除失败: ' + d.error); return; }
loadLlmConfigs();
}
async function setDefaultLlm(id) {
await fetch(API + `/api/llm-configs/${id}/default`, { method: 'POST' });
loadLlmConfigs();
}
async function testLlm(id) {
const c = llmConfigs.find(x => x.id === id);
const el = $('#llm-test-result');
if (el) el.textContent = '';
try {
const r = await fetch(API + `/api/llm-configs/${id}/test`, { method: 'POST' });
const d = await r.json();
if (d.ok) {
alert(`✅ 连接成功!模型回复: ${d.reply}`);
} else {
alert(`❌ 连接失败: ${d.error}`);
}
} catch (e) { alert('测试请求失败: ' + e); }
}
$('#add-llm').onclick = () => openLlmModal(null);
$('#llm-modal-close').onclick = closeLlmModal;
$('#llm-cancel').onclick = closeLlmModal;
$('#llm-save').onclick = saveLlm;
$('#llm-modal').onclick = e => { if (e.target === $('#llm-modal')) closeLlmModal(); };
$('#llm-test').onclick = async () => {
// 用表单当前内容测试(未保存也能测)
const body = {
name: $('#llm-name').value.trim() || '测试',
base_url: $('#llm-base-url').value.trim(),
api_key: $('#llm-api-key').value.trim(),
model: $('#llm-model').value.trim(),
vision: $('#llm-vision').checked,
temperature: parseFloat($('#llm-temperature').value) || 0.2,
timeout: parseInt($('#llm-timeout').value) || 120
};
if (!body.base_url || !body.api_key || !body.model) {
alert('请先填写 Base URL、API Key、模型名再测试');
return;
}
const el = $('#llm-test-result');
el.textContent = '测试中...';
try {
const r = await fetch(API + '/api/llm-configs/test-form', {
method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body)
});
const d = await r.json();
el.textContent = d.ok ? `${d.reply}` : `${d.error}`;
} catch (e) { el.textContent = '❌ ' + e; }
};
$('#llm_config_id').onchange = updateModelTag;
function esc(s) {
return String(s ?? '').replace(/[&<>"']/g, c => (
{'&':'&amp;','<':'&lt;','>':'&gt;','"':'&quot;',"'":'&#39;'}[c]));
@@ -46,6 +221,7 @@ async function loadTasks() {
<td>${esc(t.id)}</td>
<td>${esc(t.url)}</td>
<td><div class="goal-cell" title="${esc(t.goal)}">${esc(t.goal)}</div></td>
<td>${t.llm_name ? `<span class="model-cell" title="${esc(t.llm_name)}">${esc(t.llm_name)}</span> <span class="mode-badge ${t.vision ? 'vision' : 'text'}">${t.vision ? '🖼️' : '📄'}</span>` : '-'}</td>
<td><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span></td>
<td><span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></td>
<td>${t.steps || 0}</td>
@@ -105,6 +281,7 @@ async function refreshDetail() {
$('#detail-meta').innerHTML = `
<div class="m-item"><b>目标网址</b><span>${esc(t.url)}</span></div>
<div class="m-item"><b>测试目标</b><span>${esc(t.goal)}</span></div>
<div class="m-item"><b>分析模型</b><span>${t.llm_name ? esc(t.llm_name) + ' ' + (t.vision ? '🖼️' : '📄') : '-'}</span></div>
<div class="m-item"><b>状态</b><span><span class="status ${esc(t.status)}">${STATUS_MAP[t.status] || esc(t.status)}</span> / <span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></span></div>
<div class="m-item"><b>步骤</b><span>${t.steps || 0} / ${t.max_steps}</span></div>
<div class="m-item"><b>创建时间</b><span>${esc(t.created)}</span></div>
@@ -185,7 +362,8 @@ $('#submit').onclick = async () => {
body: JSON.stringify({
url, goal,
max_steps: parseInt($('#max_steps').value) || 30,
timeout: parseInt($('#timeout').value) || 600
timeout: parseInt($('#timeout').value) || 600,
llm_config_id: parseInt($('#llm_config_id').value) || null
})
});
const d = await r.json();
@@ -217,5 +395,6 @@ $('#refresh').onclick = loadTasks;
refreshHealth();
loadTasks();
loadLlmConfigs();
setInterval(refreshHealth, 30000);
setInterval(loadTasks, 5000);