401 lines
15 KiB
JavaScript
401 lines
15 KiB
JavaScript
/* webtest-agent 前端逻辑 */
|
||
const $ = s => document.querySelector(s);
|
||
const API = '';
|
||
|
||
async function refreshHealth() {
|
||
try {
|
||
const r = await fetch(API + '/health');
|
||
const d = await r.json();
|
||
const mode = d.llm_vision ? '🖼️视觉' : '📄文本';
|
||
$('#health').textContent = `服务正常 | 默认模型: ${d.llm_name || d.llm_model || '-'}(${mode}) | 运行中任务: ${d.concurrent}`;
|
||
$('#health').classList.add('ok');
|
||
} catch (e) {
|
||
$('#health').textContent = '服务异常';
|
||
$('#health').classList.remove('ok');
|
||
}
|
||
}
|
||
|
||
const STATUS_MAP = {
|
||
queued: '排队中', running: '测试中', finished: '已完成', stopped: '已停止'
|
||
};
|
||
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 => (
|
||
{'&':'&','<':'<','>':'>','"':'"',"'":'''}[c]));
|
||
}
|
||
|
||
async function loadTasks() {
|
||
try {
|
||
const r = await fetch(API + '/api/tasks');
|
||
const d = await r.json();
|
||
const tb = $('#task-table tbody');
|
||
if (!d.tasks.length) {
|
||
tb.innerHTML = '<tr><td colspan="8" class="empty">暂无任务,先发起一个吧</td></tr>';
|
||
return;
|
||
}
|
||
tb.innerHTML = d.tasks.map(t => {
|
||
const ops = t.status === 'running' || t.status === 'queued'
|
||
? `<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>`
|
||
: '';
|
||
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><button class="btn small" onclick="openDetail('${t.id}')">详情</button> ${ops}${report}</td>
|
||
</tr>`;
|
||
}).join('');
|
||
} catch (e) {
|
||
$('#task-table tbody').innerHTML = '<tr><td colspan="8" class="empty">加载失败</td></tr>';
|
||
}
|
||
}
|
||
|
||
async function stopTask(tid) {
|
||
if (!confirm('确定停止该任务?')) return;
|
||
await fetch(API + `/api/tasks/${tid}/stop`, { method: 'POST' });
|
||
setTimeout(loadTasks, 500);
|
||
}
|
||
|
||
function openReport(tid) {
|
||
$('#report-frame').src = API + `/api/tasks/${tid}/report`;
|
||
$('#modal-title').textContent = `测试报告 - ${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'); };
|
||
|
||
$('#submit').onclick = async () => {
|
||
const url = $('#url').value.trim();
|
||
const goal = $('#goal').value.trim();
|
||
if (!url || !goal) { alert('请填写网址和测试目标'); return; }
|
||
const btn = $('#submit');
|
||
btn.disabled = true; btn.textContent = '提交中...';
|
||
try {
|
||
const r = await fetch(API + '/api/tasks', {
|
||
method: 'POST',
|
||
headers: { 'Content-Type': 'application/json' },
|
||
body: JSON.stringify({
|
||
url, goal,
|
||
max_steps: parseInt($('#max_steps').value) || 30,
|
||
timeout: parseInt($('#timeout').value) || 600,
|
||
llm_config_id: parseInt($('#llm_config_id').value) || null
|
||
})
|
||
});
|
||
const d = await r.json();
|
||
if (d.error) { alert('提交失败: ' + d.error); }
|
||
else {
|
||
$('#goal').value = '';
|
||
loadTasks();
|
||
// 轮询直到该任务结束
|
||
pollTask(d.task_id);
|
||
}
|
||
} catch (e) { alert('提交失败: ' + e); }
|
||
finally { btn.disabled = false; btn.textContent = '🚀 开始测试'; }
|
||
};
|
||
|
||
function pollTask(tid, count = 0) {
|
||
if (count > 400) return;
|
||
setTimeout(async () => {
|
||
try {
|
||
const r = await fetch(API + `/api/tasks/${tid}`);
|
||
const t = await r.json();
|
||
loadTasks();
|
||
if (t.status === 'finished' || t.status === 'stopped') return;
|
||
pollTask(tid, count + 1);
|
||
} catch (e) { /* ignore */ }
|
||
}, 3000);
|
||
}
|
||
|
||
$('#refresh').onclick = loadTasks;
|
||
|
||
refreshHealth();
|
||
loadTasks();
|
||
loadLlmConfigs();
|
||
setInterval(refreshHealth, 30000);
|
||
setInterval(loadTasks, 5000);
|