Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
ebdc9c4265 | ||
|
|
69b6f8ccfb | ||
|
|
fd67548052 | ||
|
|
f942705fed |
@@ -53,6 +53,55 @@ SYSTEM_PROMPT = """你是一个专业的网页自动化测试工程师,正在
|
|||||||
6. 快照可能被截断,必要时用 wait 等待页面加载完成再操作。
|
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):
|
def _fmt_snapshot(snap, max_chars=DEFAULT_MAX_SNAPSHOT_CHARS):
|
||||||
"""把快照数据转成紧凑文本给 LLM"""
|
"""把快照数据转成紧凑文本给 LLM"""
|
||||||
@@ -89,13 +138,15 @@ def _fmt_history(steps):
|
|||||||
|
|
||||||
|
|
||||||
class TaskRunner(threading.Thread):
|
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}')
|
super().__init__(daemon=True, name=f'task-{task_id}')
|
||||||
self.task_id = task_id
|
self.task_id = task_id
|
||||||
self.url = url
|
self.url = url
|
||||||
self.goal = goal
|
self.goal = goal
|
||||||
self.max_steps = max_steps
|
self.max_steps = max_steps
|
||||||
self.timeout = timeout
|
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.stop_flag = threading.Event()
|
||||||
self.task_dir = os.path.join(TASKS_DIR, task_id)
|
self.task_dir = os.path.join(TASKS_DIR, task_id)
|
||||||
os.makedirs(self.task_dir, exist_ok=True)
|
os.makedirs(self.task_dir, exist_ok=True)
|
||||||
@@ -139,7 +190,14 @@ class TaskRunner(threading.Thread):
|
|||||||
self._set_current(phase='打开页面', detail=self.url)
|
self._set_current(phase='打开页面', detail=self.url)
|
||||||
self._log(f'打开页面: {self.url}')
|
self._log(f'打开页面: {self.url}')
|
||||||
browser.open(self.url, timeout=60)
|
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._log('页面已打开')
|
||||||
self._set_current(phase='页面已打开')
|
self._set_current(phase='页面已打开')
|
||||||
|
|
||||||
@@ -167,9 +225,10 @@ class TaskRunner(threading.Thread):
|
|||||||
cur_url = browser.url()
|
cur_url = browser.url()
|
||||||
cur_title = browser.title()
|
cur_title = browser.title()
|
||||||
|
|
||||||
# 2. LLM 决策
|
# 2. LLM 决策(视觉模型带截图,非视觉模型带 DOM 快照文本)
|
||||||
self._set_current(phase='AI 决策中', step=step_n,
|
self._set_current(phase='AI 决策中', step=step_n,
|
||||||
detail='正在分析页面并决定下一步动作...')
|
detail=('正在视觉分析页面截图...' if self.vision
|
||||||
|
else '正在分析页面并决定下一步动作...'))
|
||||||
user_msg = (
|
user_msg = (
|
||||||
f'## 测试目标\n{self.goal}\n\n'
|
f'## 测试目标\n{self.goal}\n\n'
|
||||||
f'## 当前页面\nURL: {cur_url}\n标题: {cur_title}\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' + (_fmt_history(steps) if steps else '(尚无)') +
|
||||||
f'\n\n请输出下一步动作的 JSON。'
|
f'\n\n请输出下一步动作的 JSON。'
|
||||||
)
|
)
|
||||||
|
user_content = self._build_user_content(user_msg, browser, step_n)
|
||||||
try:
|
try:
|
||||||
decision = chat_json(
|
decision = chat_json(
|
||||||
[{'role': 'system', 'content': SYSTEM_PROMPT},
|
[{'role': 'system',
|
||||||
{'role': 'user', 'content': user_msg}],
|
'content': VISION_SYSTEM_PROMPT if self.vision else SYSTEM_PROMPT},
|
||||||
temperature=0.2, max_tokens=500)
|
{'role': 'user', 'content': user_content}],
|
||||||
|
cfg=self.llm_cfg, max_tokens=1024)
|
||||||
except LLMError as e:
|
except LLMError as e:
|
||||||
self._log(f'LLM 错误: {e}')
|
self._log(f'LLM 错误: {e}')
|
||||||
self._finish(browser, 'error', f'LLM 决策失败: {e}', steps)
|
self._finish(browser, 'error', f'LLM 决策失败: {e}', steps)
|
||||||
@@ -230,6 +291,26 @@ class TaskRunner(threading.Thread):
|
|||||||
self._log(f'未知异常: {traceback.format_exc()}')
|
self._log(f'未知异常: {traceback.format_exc()}')
|
||||||
self._finish(browser, 'error', f'异常: {e}', steps)
|
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):
|
def _record(self, n, decision, result, detail='', extra=None):
|
||||||
rec = {
|
rec = {
|
||||||
'n': n,
|
'n': n,
|
||||||
@@ -309,17 +390,20 @@ class TaskRunner(threading.Thread):
|
|||||||
snap_text = _fmt_snapshot(snap, max_chars=5000)
|
snap_text = _fmt_snapshot(snap, max_chars=5000)
|
||||||
except Exception:
|
except Exception:
|
||||||
snap_text = '(快照失败)'
|
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(
|
retry_dec = chat_json(
|
||||||
[{'role': 'system', 'content': SYSTEM_PROMPT},
|
[{'role': 'system',
|
||||||
{'role': 'user', 'content': (
|
'content': VISION_SYSTEM_PROMPT if self.vision else SYSTEM_PROMPT},
|
||||||
f'## 测试目标\n{self.goal}\n\n'
|
{'role': 'user',
|
||||||
f'## 刚才执行失败\n动作: {action} 目标: {target} 值: {value}\n'
|
'content': self._build_user_content(retry_msg, browser, self.current.get('step', 0) + 99)}],
|
||||||
f'错误: {last_err}\n\n'
|
cfg=self.llm_cfg, max_tokens=800)
|
||||||
f'## 当前页面元素\n{snap_text}\n\n'
|
|
||||||
f'请换一种方式完成相同意图,输出下一步动作 JSON。'
|
|
||||||
f'如果确认无法完成,输出 {{"action":"fail","reason":"...","summary":"..."}}'
|
|
||||||
)}],
|
|
||||||
temperature=0.2, max_tokens=400)
|
|
||||||
new_action = retry_dec.get('action', '')
|
new_action = retry_dec.get('action', '')
|
||||||
if new_action == 'fail':
|
if new_action == 'fail':
|
||||||
return 'error', f'自愈放弃: {retry_dec.get("summary", last_err)}', {}
|
return 'error', f'自愈放弃: {retry_dec.get("summary", last_err)}', {}
|
||||||
|
|||||||
@@ -7,7 +7,10 @@ from flask import Flask, request, jsonify, send_from_directory, abort
|
|||||||
from flask_cors import CORS
|
from flask_cors import CORS
|
||||||
|
|
||||||
import config
|
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
|
from agent import TaskRunner
|
||||||
|
|
||||||
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
||||||
@@ -27,11 +30,134 @@ def index():
|
|||||||
|
|
||||||
@app.route('/health')
|
@app.route('/health')
|
||||||
def health():
|
def health():
|
||||||
|
default_cfg = get_default_llm_config()
|
||||||
return jsonify({'status': 'ok', 'version': '1.0.0',
|
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)})
|
'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'])
|
@app.route('/api/tasks', methods=['POST'])
|
||||||
def api_create_task():
|
def api_create_task():
|
||||||
data = request.get_json(silent=True) or {}
|
data = request.get_json(silent=True) or {}
|
||||||
@@ -48,13 +174,28 @@ def api_create_task():
|
|||||||
max_steps = max(1, min(max_steps, 100))
|
max_steps = max(1, min(max_steps, 100))
|
||||||
timeout = max(30, min(timeout, 3600))
|
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():
|
def _launch():
|
||||||
with _semaphore:
|
with _semaphore:
|
||||||
if get_task(tid) and get_task(tid).get('status') == 'stopped':
|
if get_task(tid) and get_task(tid).get('status') == 'stopped':
|
||||||
return
|
return
|
||||||
runner = TaskRunner(tid, url, goal, max_steps, timeout)
|
runner = TaskRunner(tid, url, goal, max_steps, timeout, llm_cfg)
|
||||||
with _runner_lock:
|
with _runner_lock:
|
||||||
_runners[tid] = runner
|
_runners[tid] = runner
|
||||||
runner.start()
|
runner.start()
|
||||||
@@ -64,7 +205,9 @@ def api_create_task():
|
|||||||
|
|
||||||
threading.Thread(target=_launch, daemon=True).start()
|
threading.Thread(target=_launch, daemon=True).start()
|
||||||
return jsonify({'task_id': tid, 'status': 'queued',
|
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'])
|
@app.route('/api/tasks', methods=['GET'])
|
||||||
|
|||||||
+22
-2
@@ -3,6 +3,7 @@
|
|||||||
import json
|
import json
|
||||||
import os
|
import os
|
||||||
import subprocess
|
import subprocess
|
||||||
|
import time
|
||||||
|
|
||||||
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
|
from config import AGENT_BROWSER, NODE_BIN_DIR, XDG_RUNTIME_DIR
|
||||||
|
|
||||||
@@ -43,8 +44,27 @@ class AgentBrowser:
|
|||||||
pass
|
pass
|
||||||
return out
|
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):
|
def snapshot(self, interactive=True, compact=False, depth=None, timeout=60):
|
||||||
args = ['snapshot']
|
args = ['snapshot']
|
||||||
|
|||||||
@@ -17,12 +17,14 @@ NODE_BIN_DIR = os.path.dirname(AGENT_BROWSER)
|
|||||||
# agent-browser 需要可写的 socket 目录,固定用 /tmp 下的(系统 XDG_RUNTIME_DIR 可能属于其他用户)
|
# agent-browser 需要可写的 socket 目录,固定用 /tmp 下的(系统 XDG_RUNTIME_DIR 可能属于其他用户)
|
||||||
XDG_RUNTIME_DIR = '/tmp/xdg-rt'
|
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_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_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_MODEL = os.environ.get('LLM_MODEL', 'doubao-seed-evolving')
|
||||||
LLM_TEMPERATURE = 0.2
|
LLM_TEMPERATURE = 0.2
|
||||||
LLM_TIMEOUT = 120
|
LLM_TIMEOUT = 120
|
||||||
|
DEFAULT_LLM_NAME = '火山引擎豆包(默认)' # 首次启动时写入 llm_configs 的显示名
|
||||||
|
|
||||||
# Agent 默认参数
|
# Agent 默认参数
|
||||||
DEFAULT_MAX_STEPS = 30 # 最大动作步数
|
DEFAULT_MAX_STEPS = 30 # 最大动作步数
|
||||||
|
|||||||
@@ -34,17 +34,51 @@ def init_db():
|
|||||||
report_path TEXT
|
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.commit()
|
||||||
c.close()
|
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]
|
tid = uuid.uuid4().hex[:12]
|
||||||
c = _conn()
|
c = _conn()
|
||||||
c.execute(
|
c.execute(
|
||||||
'INSERT INTO tasks (id, url, goal, status, max_steps, timeout, created_at) '
|
'INSERT INTO tasks (id, url, goal, status, max_steps, timeout, created_at, '
|
||||||
'VALUES (?,?,?,?,?,?,?)',
|
'llm_config_id, llm_name, vision) VALUES (?,?,?,?,?,?,?,?,?,?)',
|
||||||
(tid, url, goal, 'queued', max_steps, timeout, time.time()))
|
(tid, url, goal, 'queued', max_steps, timeout, time.time(),
|
||||||
|
llm_config_id, llm_name, 1 if vision else 0))
|
||||||
c.commit()
|
c.commit()
|
||||||
c.close()
|
c.close()
|
||||||
return tid
|
return tid
|
||||||
@@ -104,3 +138,97 @@ def load_step_logs(tid):
|
|||||||
except json.JSONDecodeError:
|
except json.JSONDecodeError:
|
||||||
pass
|
pass
|
||||||
return out
|
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']),
|
||||||
|
}
|
||||||
@@ -1,5 +1,9 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""LLM 客户端(OpenAI 兼容接口)"""
|
"""LLM 客户端(OpenAI 兼容接口)
|
||||||
|
|
||||||
|
支持按模型配置调用(base_url / api_key / model / temperature / timeout),
|
||||||
|
支持多模态消息(content 为 [{type:text},{type:image_url}] 列表,用于视觉模型分析截图)。
|
||||||
|
"""
|
||||||
import json
|
import json
|
||||||
import re
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
@@ -12,6 +16,21 @@ class LLMError(Exception):
|
|||||||
pass
|
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):
|
def _extract_json(text):
|
||||||
"""从 LLM 输出中提取 JSON(容忍 markdown 代码块等包裹)"""
|
"""从 LLM 输出中提取 JSON(容忍 markdown 代码块等包裹)"""
|
||||||
if not text:
|
if not text:
|
||||||
@@ -35,13 +54,19 @@ def _extract_json(text):
|
|||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
def chat(messages, temperature=None, max_tokens=None, timeout=None):
|
def chat(messages, cfg=None, temperature=None, max_tokens=None, timeout=None):
|
||||||
"""调用 OpenAI 兼容 chat/completions,返回 content 字符串"""
|
"""调用 OpenAI 兼容 chat/completions,返回 content 字符串
|
||||||
url = f'{LLM_BASE_URL}/chat/completions'
|
|
||||||
|
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 = {
|
body = {
|
||||||
'model': LLM_MODEL,
|
'model': c['model'],
|
||||||
'messages': messages,
|
'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:
|
if max_tokens:
|
||||||
body['max_tokens'] = 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'),
|
data=json.dumps(body).encode('utf-8'),
|
||||||
headers={
|
headers={
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
'Authorization': f'Bearer {LLM_API_KEY}',
|
'Authorization': f'Bearer {c["api_key"]}',
|
||||||
},
|
},
|
||||||
method='POST',
|
method='POST',
|
||||||
)
|
)
|
||||||
try:
|
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'))
|
data = json.loads(resp.read().decode('utf-8'))
|
||||||
except urllib.error.HTTPError as e:
|
except urllib.error.HTTPError as e:
|
||||||
detail = e.read().decode('utf-8', 'ignore')[:300]
|
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}')
|
raise LLMError(f'LLM 调用失败: {e}')
|
||||||
|
|
||||||
try:
|
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):
|
except (KeyError, IndexError, TypeError):
|
||||||
raise LLMError(f'LLM 响应异常: {str(data)[:300]}')
|
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,失败重试"""
|
"""调用 LLM 并强制解析 JSON,失败重试"""
|
||||||
last_err = None
|
last_err = None
|
||||||
|
last_content = ''
|
||||||
for i in range(retries + 1):
|
for i in range(retries + 1):
|
||||||
try:
|
try:
|
||||||
content = chat(messages, temperature=temperature, max_tokens=max_tokens)
|
last_content = chat(messages, cfg=cfg, temperature=temperature,
|
||||||
obj = _extract_json(content)
|
max_tokens=max_tokens)
|
||||||
|
obj = _extract_json(last_content)
|
||||||
if obj is not None:
|
if obj is not None:
|
||||||
return obj
|
return obj
|
||||||
last_err = f'无法从输出解析 JSON: {content[:200]}'
|
last_err = f'无法从输出解析 JSON: {last_content[:200]}'
|
||||||
except LLMError as e:
|
except LLMError as e:
|
||||||
last_err = str(e)
|
last_err = str(e)
|
||||||
if i < retries:
|
if i < retries:
|
||||||
messages = messages + [
|
messages = messages + [
|
||||||
{'role': 'assistant', 'content': content if 'content' in dir() else ''},
|
{'role': 'assistant', 'content': _trim_content(last_content)},
|
||||||
{'role': 'user', 'content': f'刚才的输出不是合法 JSON,请只输出严格的 JSON 对象。错误: {last_err}'},
|
{'role': 'user',
|
||||||
|
'content': f'刚才的输出不是合法 JSON,请只输出严格的 JSON 对象,不要输出思考过程和代码块。错误: {last_err}'},
|
||||||
]
|
]
|
||||||
raise LLMError(f'LLM JSON 解析失败: {last_err}')
|
raise LLMError(f'LLM JSON 解析失败: {last_err}')
|
||||||
@@ -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-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-detail { font-size: 12px; color: #374151; }
|
||||||
.step-time { font-size: 11px; color: #9ca3af; margin-left: 8px; }
|
.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
@@ -28,13 +28,27 @@
|
|||||||
<label>最大步数</label>
|
<label>最大步数</label>
|
||||||
<input id="max_steps" type="number" value="30" min="1" max="100">
|
<input id="max_steps" type="number" value="30" min="1" max="100">
|
||||||
</div>
|
</div>
|
||||||
|
<div class="field">
|
||||||
|
<label>分析模型 <span class="tag" id="model-tag"></span></label>
|
||||||
|
<select id="llm_config_id"></select>
|
||||||
|
</div>
|
||||||
<div class="field">
|
<div class="field">
|
||||||
<label>超时(秒)</label>
|
<label>超时(秒)</label>
|
||||||
<input id="timeout" type="number" value="600" min="30" max="3600">
|
<input id="timeout" type="number" value="600" min="30" max="3600">
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<button id="submit" class="btn primary">🚀 开始测试</button>
|
<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>
|
||||||
|
|
||||||
<section class="card">
|
<section class="card">
|
||||||
@@ -80,6 +94,50 @@
|
|||||||
</div>
|
</div>
|
||||||
</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 URL(OpenAI 兼容接口地址)</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>
|
<script src="/static/js/app.js"></script>
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
+181
-2
@@ -6,7 +6,8 @@ async function refreshHealth() {
|
|||||||
try {
|
try {
|
||||||
const r = await fetch(API + '/health');
|
const r = await fetch(API + '/health');
|
||||||
const d = await r.json();
|
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');
|
$('#health').classList.add('ok');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
$('#health').textContent = '服务异常';
|
$('#health').textContent = '服务异常';
|
||||||
@@ -21,6 +22,180 @@ const RESULT_MAP = {
|
|||||||
pending: '待定', pass: '✅ 通过', fail: '❌ 失败', error: '⚠️ 错误', stopped: '⏹️ 停止'
|
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) {
|
function esc(s) {
|
||||||
return String(s ?? '').replace(/[&<>"']/g, c => (
|
return String(s ?? '').replace(/[&<>"']/g, c => (
|
||||||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||||||
@@ -46,6 +221,7 @@ async function loadTasks() {
|
|||||||
<td>${esc(t.id)}</td>
|
<td>${esc(t.id)}</td>
|
||||||
<td>${esc(t.url)}</td>
|
<td>${esc(t.url)}</td>
|
||||||
<td><div class="goal-cell" title="${esc(t.goal)}">${esc(t.goal)}</div></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="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><span class="result ${esc(t.result)}">${RESULT_MAP[t.result] || esc(t.result)}</span></td>
|
||||||
<td>${t.steps || 0}</td>
|
<td>${t.steps || 0}</td>
|
||||||
@@ -105,6 +281,7 @@ async function refreshDetail() {
|
|||||||
$('#detail-meta').innerHTML = `
|
$('#detail-meta').innerHTML = `
|
||||||
<div class="m-item"><b>目标网址</b><span>${esc(t.url)}</span></div>
|
<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>${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><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>${t.steps || 0} / ${t.max_steps}</span></div>
|
||||||
<div class="m-item"><b>创建时间</b><span>${esc(t.created)}</span></div>
|
<div class="m-item"><b>创建时间</b><span>${esc(t.created)}</span></div>
|
||||||
@@ -185,7 +362,8 @@ $('#submit').onclick = async () => {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
url, goal,
|
url, goal,
|
||||||
max_steps: parseInt($('#max_steps').value) || 30,
|
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();
|
const d = await r.json();
|
||||||
@@ -217,5 +395,6 @@ $('#refresh').onclick = loadTasks;
|
|||||||
|
|
||||||
refreshHealth();
|
refreshHealth();
|
||||||
loadTasks();
|
loadTasks();
|
||||||
|
loadLlmConfigs();
|
||||||
setInterval(refreshHealth, 30000);
|
setInterval(refreshHealth, 30000);
|
||||||
setInterval(loadTasks, 5000);
|
setInterval(loadTasks, 5000);
|
||||||
Reference in New Issue
Block a user