5 Commits
10 changed files with 921 additions and 51 deletions
+131 -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,16 +138,30 @@ 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)
# 实时状态(供 API 轮询展示)
self.current = {
'phase': '排队中', 'step': 0, 'reason': '', 'action': '',
'target': '', 'value': '', 'detail': '', 'ts': '',
'elapsed': 0, 'started': False,
}
def _set_current(self, **kw):
self.current.update(kw)
self.current['ts'] = time.strftime('%H:%M:%S')
if self.current.get('started'):
self.current['elapsed'] = int(time.time() - self.current.get('start_ts', time.time()))
def stop(self):
self.stop_flag.set()
@@ -118,15 +181,25 @@ class TaskRunner(threading.Thread):
def run(self):
update_task(self.task_id, status='running', started_at=time.time(),
result='running')
self._set_current(phase='启动中', started=True, start_ts=time.time())
steps = []
browser = None
try:
browser = AgentBrowser(namespace=f'task-{self.task_id}')
deadline = time.time() + self.timeout
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='页面已打开')
step_n = 0
while step_n < self.max_steps:
@@ -139,6 +212,8 @@ class TaskRunner(threading.Thread):
step_n += 1
self._log(f'--- 步骤 {step_n}/{self.max_steps} ---')
self._set_current(phase='分析页面', step=step_n,
detail=f'正在获取页面元素快照')
# 1. 快照
try:
@@ -150,7 +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=('正在视觉分析页面截图...' if self.vision
else '正在分析页面并决定下一步动作...'))
user_msg = (
f'## 测试目标\n{self.goal}\n\n'
f'## 当前页面\nURL: {cur_url}\n标题: {cur_title}\n\n'
@@ -158,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)
@@ -172,11 +252,18 @@ class TaskRunner(threading.Thread):
if action not in ('click', 'fill', 'select', 'press', 'wait',
'scroll', 'screenshot', 'assert', 'done', 'fail'):
self._log(f'非法动作: {action}')
self._set_current(phase='动作异常', step=step_n,
action=action, detail=f'非法动作: {action}')
steps.append(self._record(step_n, decision, 'error',
f'非法动作: {action}'))
continue
# 3. 执行动作(带自愈重试)
self._set_current(phase='执行动作', step=step_n,
action=action, target=decision.get('target', ''),
value=decision.get('value', ''),
reason=decision.get('reason', ''),
detail=f'{action} {decision.get("target", "")} {decision.get("value", "")}'.strip())
result, detail, extra = self._execute(browser, decision)
self._log(f'动作 {action} -> {result} {detail}')
@@ -204,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,
@@ -243,6 +350,9 @@ class TaskRunner(threading.Thread):
for attempt in range(MAX_RETRY_SAME_ERROR + 1):
if attempt > 0:
self._log(f'重试 {attempt}: {action} {target}')
self._set_current(phase='自愈重试', step=self.current.get('step', 0),
action=action, target=target,
detail=f'{attempt} 次重试: {action} {target}')
try:
if action == 'click':
browser.click(self._resolve(target))
@@ -280,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)}', {}
@@ -382,6 +495,8 @@ class TaskRunner(threading.Thread):
return bool(r)
def _finish(self, browser, result, summary, steps):
self._set_current(phase='完成', step=len(steps),
detail=f'结果: {result} - {summary}')
if browser:
browser.close()
update_task(self.task_id, status='finished', result=result,
+152 -6
View File
@@ -7,10 +7,13 @@ 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='')
app = Flask(__name__, static_folder='static', static_url_path='/static')
CORS(app)
init_db()
@@ -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'])
@@ -84,6 +227,9 @@ def api_get_task(tid):
t['created'] = _fmt_time(t.get('created_at'))
t['finished'] = _fmt_time(t.get('finished_at'))
t['steps_log'] = load_step_logs(tid)
runner = _runners.get(tid)
if runner:
t['current'] = runner.current
return jsonify(t)
+22 -2
View File
@@ -3,6 +3,7 @@
import json
import os
import subprocess
import time
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
@@ -43,8 +44,27 @@ class AgentBrowser:
pass
return out
def open(self, url, timeout=60):
return self._run(['open', url], timeout=timeout, check=True)
# 瞬时网络故障(服务重启/端口切换等)会自动重试的错误特征
RETRYABLE_ERRS = (
'ERR_EMPTY_RESPONSE', 'ERR_CONNECTION_REFUSED',
'ERR_CONNECTION_RESET', 'ERR_CONNECTION_CLOSED',
'ERR_TIMED_OUT', 'ERR_NAME_NOT_RESOLVED', 'ERR_SOCKET_NOT_CONNECTED',
'ERR_ADDRESS_UNREACHABLE', 'ERR_NETWORK_CHANGED', 'ERR_INTERNET_DISCONNECTED',
)
def open(self, url, timeout=60, retries=2):
"""打开页面;对瞬时连接类错误自动重试(默认最多重试 2 次,间隔 2s/4s)"""
last_err = None
for attempt in range(retries + 1):
try:
return self._run(['open', url], timeout=timeout, check=True)
except BrowserError as e:
last_err = e
if attempt < retries and any(t in str(e) for t in self.RETRYABLE_ERRS):
time.sleep(2 * (attempt + 1))
continue
raise
raise last_err
def snapshot(self, interactive=True, compact=False, depth=None, timeout=60):
args = ['snapshot']
+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}')
+2 -1
View File
@@ -7,7 +7,8 @@ mkdir -p "$XDG_RUNTIME_DIR" && chmod 700 "$XDG_RUNTIME_DIR"
mkdir -p logs
if [ -n "$1" ] && [ "$1" = "stop" ]; then
pkill -f "webtest-agent/app.py" && echo "已停止" || echo "未在运行"
PID=$(ss -tlnp 2>/dev/null | grep ":16061 " | grep -oP 'pid=\K[0-9]+' | head -1)
if [ -n "$PID" ]; then kill "$PID" && echo "已停止 (PID $PID)"; else echo "未在运行"; fi
exit 0
fi
+53
View File
@@ -45,3 +45,56 @@ a.report-link:hover { text-decoration: underline; }
.modal-body { width: 90vw; height: 90vh; background: #fff; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; }
.modal-head { padding: 12px 16px; border-bottom: 1px solid #e5e7eb; display: flex; justify-content: space-between; align-items: center; font-weight: 600; }
#report-frame { flex: 1; border: none; width: 100%; }
.btn.danger { background: #fee2e2; color: #991b1b; border: 1px solid #fecaca; }
.btn.danger:hover { background: #fecaca; }
/* 任务详情 */
.detail-body { width: 96vw; max-width: 1100px; }
.detail-content { flex: 1; overflow-y: auto; padding: 16px 20px; }
.detail-meta { display: grid; grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); gap: 10px 24px; background: #f9fafb; border: 1px solid #e5e7eb; border-radius: 10px; padding: 14px 16px; font-size: 13px; margin-bottom: 14px; }
.detail-meta .m-item b { display: block; color: #6b7280; font-size: 11px; font-weight: 500; margin-bottom: 3px; }
.detail-meta .m-item span { color: #111; word-break: break-all; }
.live-bar { display: flex; align-items: flex-start; gap: 12px; background: #eff6ff; border: 1px solid #bfdbfe; border-radius: 10px; padding: 12px 16px; margin-bottom: 16px; }
.live-bar.hidden { display: none; }
.live-dot { width: 10px; height: 10px; border-radius: 50%; background: #3b82f6; margin-top: 5px; animation: pulse 1.2s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .3; } }
.live-phase { font-weight: 600; font-size: 14px; color: #1e40af; margin-bottom: 4px; }
.live-detail { font-size: 13px; color: #374151; }
.live-reason { font-size: 12px; color: #6b7280; margin-top: 4px; }
.detail-steps-head { font-size: 14px; font-weight: 600; margin: 18px 0 10px; }
.detail-steps { display: flex; flex-direction: column; gap: 8px; }
.step-card { border: 1px solid #e5e7eb; border-radius: 10px; padding: 12px 14px; background: #fff; }
.step-card .step-top { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; flex-wrap: wrap; }
.step-num { background: #f3f4f6; color: #374151; font-size: 12px; font-weight: 600; padding: 2px 8px; border-radius: 999px; }
.step-action { font-size: 12px; font-weight: 600; padding: 2px 10px; border-radius: 999px; background: #dbeafe; color: #1e40af; }
.step-action.assert { background: #fef3c7; color: #92400e; }
.step-action.click { background: #dcfce7; color: #166534; }
.step-action.fill { background: #ede9fe; color: #5b21b6; }
.step-action.fail { background: #fee2e2; color: #991b1b; }
.step-target { font-family: ui-monospace, monospace; font-size: 12px; color: #374151; background: #f9fafb; padding: 2px 8px; border-radius: 6px; }
.step-result { margin-left: auto; font-size: 12px; font-weight: 600; }
.step-result.ok { color: #16a34a; }
.step-result.bad { color: #dc2626; }
.step-reason { font-size: 12px; color: #6b7280; margin-bottom: 6px; }
.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; }
+86 -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">
@@ -46,6 +60,33 @@
</section>
</main>
<div id="detail-modal" class="modal hidden">
<div class="modal-body detail-body">
<div class="modal-head">
<span id="detail-title">任务详情</span>
<span>
<button id="detail-report-btn" class="btn small hidden">📄 完整报告</button>
<button id="detail-stop-btn" class="btn small danger hidden">⏹ 停止</button>
<button id="detail-close" class="btn small">关闭</button>
</span>
</div>
<div class="detail-content">
<div class="detail-meta" id="detail-meta"></div>
<div class="live-bar hidden" id="live-bar">
<span class="live-dot"></span>
<div class="live-info">
<div class="live-phase" id="live-phase"></div>
<div class="live-detail" id="live-detail"></div>
</div>
</div>
<div class="detail-steps-head">步骤日志</div>
<div class="detail-steps" id="detail-steps">
<div class="empty">加载中...</div>
</div>
</div>
</div>
</div>
<div id="report-modal" class="modal hidden">
<div class="modal-body">
<div class="modal-head"><span id="modal-title">测试报告</span><button id="modal-close" class="btn small">关闭</button></div>
@@ -53,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>
+283 -5
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]));
@@ -40,17 +215,18 @@ async function loadTasks() {
? `<button class="btn small" onclick="stopTask('${t.id}')">停止</button> `
: '';
const report = t.status === 'finished'
? `<a class="report-link" href="#" onclick="openReport('${t.id}');return false;">查看报告</a>`
: '';
? `<a class="report-link" href="#" onclick="openReport('${t.id}');return false;">报告</a>`
: '';
return `<tr>
<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>
<td>${esc(t.created)}</td>
<td>${ops}${report}</td>
<td><button class="btn small" onclick="openDetail('${t.id}')">详情</button> ${ops}${report}</td>
</tr>`;
}).join('');
} catch (e) {
@@ -70,6 +246,106 @@ function openReport(tid) {
$('#report-modal').classList.remove('hidden');
}
/* ========== 任务详情 ========== */
let detailTid = null;
let detailTimer = null;
function openDetail(tid) {
detailTid = tid;
$('#detail-title').textContent = `任务详情 - ${tid}`;
$('#detail-steps').innerHTML = '<div class="empty">加载中...</div>';
$('#detail-modal').classList.remove('hidden');
refreshDetail();
detailTimer = setInterval(refreshDetail, 2500);
}
function closeDetail() {
detailTid = null;
if (detailTimer) { clearInterval(detailTimer); detailTimer = null; }
$('#detail-modal').classList.add('hidden');
}
const ACTION_LABEL = { click: '点击', fill: '填表', select: '选择', press: '按键',
wait: '等待', scroll: '滚动', assert: '断言', screenshot: '截图', done: '完成', fail: '失败' };
async function refreshDetail() {
if (!detailTid) return;
let t;
try {
const r = await fetch(API + `/api/tasks/${detailTid}`);
t = await r.json();
} catch (e) { return; }
// 元信息
const done = t.status === 'finished' || t.status === 'stopped';
$('#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>
${t.finished ? `<div class="m-item"><b>结束时间</b><span>${esc(t.finished)}</span></div>` : ''}
`;
// 实时状态条
const cur = t.current;
const liveBar = $('#live-bar');
if (!done && cur) {
liveBar.classList.remove('hidden');
$('#live-phase').textContent = `步骤 ${cur.step || '-'}: ${cur.phase || ''}` +
(cur.elapsed ? `(已运行 ${cur.elapsed}s` : '');
let d = cur.detail || '';
if (cur.reason) d += `<div class="live-reason">💡 ${esc(cur.reason)}</div>`;
$('#live-detail').innerHTML = esc(d);
} else {
liveBar.classList.add('hidden');
}
// 按钮
$('#detail-report-btn').classList.toggle('hidden', !done || t.result === 'error');
$('#detail-stop-btn').classList.toggle('hidden', done);
// 步骤列表
const steps = t.steps_log || [];
if (!steps.length) {
$('#detail-steps').innerHTML = '<div class="empty">还没有步骤记录,AI 正在准备...</div>';
} else {
$('#detail-steps').innerHTML = steps.map(s => {
const act = s.action || '';
const actCls = ['assert','click','fill','fail'].includes(act) ? act : '';
const shot = s.screenshot
? `<div class="step-shot"><img src="${API}/api/tasks/${detailTid}/screenshot/${esc(s.screenshot)}" loading="lazy" onclick="window.open(this.src)"></div>`
: '';
const resCls = s.result === 'ok' ? 'ok' : (s.result === 'fail' || s.result === 'error' ? 'bad' : '');
return `<div class="step-card">
<div class="step-top">
<span class="step-num">#${s.n}</span>
<span class="step-action ${actCls}">${ACTION_LABEL[act] || act}</span>
${s.target ? `<span class="step-target">${esc(s.target)}${s.value ? ' ' + esc(s.value) : ''}</span>` : ''}
<span class="step-result ${resCls}">${esc(s.result)}</span>
<span class="step-time">${esc(s.ts || '')}</span>
</div>
${s.reason ? `<div class="step-reason">💡 ${esc(s.reason)}</div>` : ''}
${s.detail ? `<div class="step-detail">${esc(s.detail)}</div>` : ''}
${shot}
</div>`;
}).join('');
}
// 结束后停止轮询
if (done) { if (detailTimer) { clearInterval(detailTimer); detailTimer = null; } }
}
$('#detail-close').onclick = closeDetail;
$('#detail-modal').onclick = e => { if (e.target === $('#detail-modal')) closeDetail(); };
$('#detail-stop-btn').onclick = async () => {
if (!detailTid || !confirm('确定停止该任务?')) return;
await fetch(API + `/api/tasks/${detailTid}/stop`, { method: 'POST' });
refreshDetail(); loadTasks();
};
$('#detail-report-btn').onclick = () => { if (detailTid) openReport(detailTid); };
$('#modal-close').onclick = () => $('#report-modal').classList.add('hidden');
$('#report-modal').onclick = e => { if (e.target === $('#report-modal')) $('#report-modal').classList.add('hidden'); };
@@ -86,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();
@@ -118,5 +395,6 @@ $('#refresh').onclick = loadTasks;
refreshHealth();
loadTasks();
loadLlmConfigs();
setInterval(refreshHealth, 30000);
setInterval(loadTasks, 5000);