292 lines
9.6 KiB
Python
292 lines
9.6 KiB
Python
#!/usr/bin/env python3
|
|
"""webtest-agent - AI 网页测试智能体 (Flask 服务)"""
|
|
import os
|
|
import threading
|
|
|
|
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, list_llm_configs, get_llm_config,
|
|
get_default_llm_config, create_llm_config, update_llm_config,
|
|
delete_llm_config, set_default_llm_config, llm_config_to_dict)
|
|
from agent import TaskRunner
|
|
|
|
app = Flask(__name__, static_folder='static', static_url_path='/static')
|
|
CORS(app)
|
|
|
|
init_db()
|
|
|
|
_runner_lock = threading.Lock()
|
|
_runners = {} # task_id -> TaskRunner
|
|
_semaphore = threading.Semaphore(config.MAX_CONCURRENT_TASKS)
|
|
|
|
|
|
@app.route('/')
|
|
def index():
|
|
return send_from_directory('static', 'index.html')
|
|
|
|
|
|
@app.route('/health')
|
|
def health():
|
|
default_cfg = get_default_llm_config()
|
|
return jsonify({'status': 'ok', 'version': '1.0.0',
|
|
'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 {}
|
|
url = (data.get('url') or '').strip()
|
|
goal = (data.get('goal') or '').strip()
|
|
if not url:
|
|
return jsonify({'error': '缺少 url 参数'}), 400
|
|
if not goal:
|
|
return jsonify({'error': '缺少 goal(测试目标)参数'}), 400
|
|
if not url.startswith(('http://', 'https://')):
|
|
url = 'https://' + url
|
|
max_steps = int(data.get('max_steps') or config.DEFAULT_MAX_STEPS)
|
|
timeout = int(data.get('timeout') or config.DEFAULT_TIMEOUT)
|
|
max_steps = max(1, min(max_steps, 100))
|
|
timeout = max(30, min(timeout, 3600))
|
|
|
|
# 选择分析模型:指定 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, llm_cfg)
|
|
with _runner_lock:
|
|
_runners[tid] = runner
|
|
runner.start()
|
|
runner.join()
|
|
with _runner_lock:
|
|
_runners.pop(tid, None)
|
|
|
|
threading.Thread(target=_launch, daemon=True).start()
|
|
return jsonify({'task_id': tid, 'status': 'queued',
|
|
'url': url, 'goal': goal,
|
|
'llm_name': cfg_row['name'],
|
|
'vision': bool(cfg_row['vision'])}), 202
|
|
|
|
|
|
@app.route('/api/tasks', methods=['GET'])
|
|
def api_list_tasks():
|
|
tasks = list_tasks(limit=50)
|
|
for t in tasks:
|
|
t['created'] = _fmt_time(t.get('created_at'))
|
|
t['finished'] = _fmt_time(t.get('finished_at'))
|
|
return jsonify({'tasks': tasks})
|
|
|
|
|
|
@app.route('/api/tasks/<tid>', methods=['GET'])
|
|
def api_get_task(tid):
|
|
t = get_task(tid)
|
|
if not t:
|
|
abort(404)
|
|
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)
|
|
|
|
|
|
@app.route('/api/tasks/<tid>/stop', methods=['POST'])
|
|
def api_stop_task(tid):
|
|
runner = _runners.get(tid)
|
|
if runner:
|
|
runner.stop()
|
|
update_task(tid, status='stopped', result='stopped')
|
|
return jsonify({'ok': True, 'message': '停止请求已发送'})
|
|
t = get_task(tid)
|
|
if not t:
|
|
abort(404)
|
|
if t['status'] == 'queued':
|
|
update_task(tid, status='stopped', result='stopped')
|
|
return jsonify({'ok': True, 'message': '已取消排队任务'})
|
|
return jsonify({'ok': False, 'message': '任务不在运行中'})
|
|
|
|
|
|
@app.route('/api/tasks/<tid>/report')
|
|
def api_report(tid):
|
|
path = os.path.join(config.TASKS_DIR, tid, 'report.html')
|
|
if os.path.exists(path):
|
|
return send_from_directory(config.TASKS_DIR, f'{tid}/report.html')
|
|
abort(404)
|
|
|
|
|
|
@app.route('/api/tasks/<tid>/report.json')
|
|
def api_report_json(tid):
|
|
t = get_task(tid)
|
|
if not t:
|
|
abort(404)
|
|
return jsonify({
|
|
'task': t,
|
|
'steps': load_step_logs(tid),
|
|
'report_url': f'/api/tasks/{tid}/report',
|
|
})
|
|
|
|
|
|
@app.route('/api/tasks/<tid>/screenshot/<name>')
|
|
def api_screenshot(tid, name):
|
|
if not name or '..' in name or '/' in name:
|
|
abort(400)
|
|
path = os.path.join(config.TASKS_DIR, tid, name)
|
|
if os.path.exists(path):
|
|
return send_from_directory(config.TASKS_DIR, f'{tid}/{name}')
|
|
abort(404)
|
|
|
|
|
|
def _fmt_time(ts):
|
|
if not ts:
|
|
return None
|
|
import time
|
|
return time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(ts))
|
|
|
|
|
|
if __name__ == '__main__':
|
|
print(f'webtest-agent 启动: http://0.0.0.0:{config.PORT}')
|
|
app.run(host=config.HOST, port=config.PORT, threaded=True)
|