jiaoxue/pest/frontend/pest-disease-vl.html
2026-02-28 15:05:39 +08:00

355 lines
13 KiB
HTML
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>病虫害识别 - qwen3-vl-plus</title>
<style>
:root { --bg:#0b1220; --panel:#111a2e; --muted:#8aa0c5; --text:#e6eeff; --border:#233152; --accent:#4f8cff; --danger:#ff5c7a; --ok:#37d67a; }
* { box-sizing: border-box; }
body { margin:0; font-family: ui-sans-serif, system-ui, -apple-system, Segoe UI, Roboto, Helvetica, Arial, "PingFang SC", "Microsoft YaHei"; background: radial-gradient(1200px 600px at 10% 0%, #182447, var(--bg)); color:var(--text); }
.wrap { max-width: 1100px; margin: 0 auto; padding: 18px; }
.header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom: 14px; }
.title { font-size: 18px; font-weight: 700; letter-spacing: .2px; }
.sub { font-size: 12px; color: var(--muted); margin-top: 4px; }
.panel { background: linear-gradient(180deg, rgba(255,255,255,.04), rgba(255,255,255,.02)); border:1px solid var(--border); border-radius: 14px; padding: 14px; }
label { display:block; font-size: 12px; color: var(--muted); margin-bottom: 6px; }
input[type="text"], textarea { width: 100%; background: rgba(0,0,0,.18); color: var(--text); border:1px solid var(--border); border-radius: 10px; padding: 10px 10px; outline: none; }
textarea { min-height: 110px; resize: vertical; font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; font-size: 12px; line-height: 1.45; }
.grid { display:grid; grid-template-columns: 1fr 1fr; gap: 12px; }
@media (max-width: 980px) { .grid { grid-template-columns: 1fr; } }
.btns { display:flex; gap:10px; flex-wrap: wrap; align-items:center; }
button { border: 1px solid var(--border); background: rgba(79,140,255,.18); color: var(--text); border-radius: 10px; padding: 9px 12px; cursor:pointer; font-weight:700; }
button.secondary { background: rgba(255,255,255,.06); }
button.danger { background: rgba(255,92,122,.18); border-color: rgba(255,92,122,.4); }
button:disabled { opacity: .55; cursor:not-allowed; }
.pill { display:inline-flex; align-items:center; gap: 8px; padding: 6px 10px; border: 1px solid var(--border); border-radius: 999px; background: rgba(255,255,255,.04); }
.row { display:flex; gap:12px; flex-wrap: wrap; }
.mono { font-family: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace; }
.out { white-space: pre-wrap; word-break: break-word; min-height: 180px; background: rgba(0,0,0,.22); border:1px solid var(--border); border-radius: 12px; padding: 12px; font-size: 13px; line-height: 1.5; }
.muted { color: var(--muted); }
.err { color: #ffd0d8; border-color: rgba(255,92,122,.35); background: rgba(255,92,122,.08); }
.ok { border-color: rgba(55,214,122,.35); }
.preview { width:100%; border-radius: 12px; border: 1px solid var(--border); background: rgba(0,0,0,.18); overflow:hidden; min-height: 220px; display:flex; align-items:center; justify-content:center; }
.preview img { max-width: 100%; height: auto; display:block; }
.small { font-size: 12px; }
</style>
</head>
<body>
<div class="wrap">
<div class="header">
<div>
<div class="title">病虫害识别qwen3-vl-plus</div>
<div class="sub">上传作物叶片/果实等图片,调用后端 <span class="mono">/api/vl/chat</span> 进行识别与建议输出</div>
</div>
<div class="small muted">建议后端:<span class="mono">http://localhost:8080</span></div>
</div>
<div class="panel" style="margin-bottom: 12px;">
<div class="grid">
<div>
<label>后端 Base URL</label>
<input id="baseUrl" type="text" placeholder="http://localhost:8080" />
</div>
<div>
<label>模型(固定为 qwen3-vl-plus可按需修改</label>
<input id="model" type="text" value="qwen3-vl-plus" />
</div>
</div>
<div style="margin-top:10px;" class="btns">
<span class="pill"><input id="prettyJson" type="checkbox" checked /> <label for="prettyJson" style="margin:0; cursor:pointer;">显示格式化请求</label></span>
<button id="analyzeBtn">开始识别</button>
<button id="stopBtn" class="danger" disabled>停止</button>
<span id="status" class="muted small"></span>
</div>
</div>
<div class="grid">
<div class="panel">
<h3 style="margin:0 0 10px 0; font-size:14px;">输入</h3>
<label>上传图片(建议 &lt; 2MB过大可能 413 或超时)</label>
<input id="file" type="file" accept="image/*" />
<div style="margin-top:10px;" class="preview" id="preview">
<div class="muted small" id="previewHint">未选择图片</div>
</div>
<label style="margin-top:10px;">补充说明(可选)</label>
<textarea id="note" placeholder="例如:作物品种、种植地区、拍摄时间、症状出现多久、是否施药等"></textarea>
<label style="margin-top:10px;">识别提示词(可编辑)</label>
<textarea id="prompt" class="mono"></textarea>
<div style="margin-top:10px;">
<label>请求体(发送到 /api/vl/chat</label>
<div id="reqBox" class="out mono muted"></div>
</div>
</div>
<div class="panel">
<h3 style="margin:0 0 10px 0; font-size:14px;">输出</h3>
<div id="respBox" class="out ok"></div>
<div style="margin-top:10px;">
<label>调试信息</label>
<div id="debugBox" class="out mono muted" style="min-height:140px;"></div>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const state = {
imageDataUrl: '',
aborter: null,
};
function setStatus(msg) { $('status').textContent = msg || ''; }
function setReqBox(objOrText) {
if (typeof objOrText === 'string') { $('reqBox').textContent = objOrText; return; }
const pretty = $('prettyJson').checked;
$('reqBox').textContent = pretty ? JSON.stringify(objOrText, null, 2) : JSON.stringify(objOrText);
}
function setRespBox(text, isError=false) {
const box = $('respBox');
box.textContent = text ?? '';
box.classList.toggle('err', !!isError);
box.classList.toggle('ok', !isError);
}
function setDebug(objOrText) {
if (typeof objOrText === 'string') { $('debugBox').textContent = objOrText; return; }
$('debugBox').textContent = JSON.stringify(objOrText, null, 2);
}
function baseUrl() {
const v = $('baseUrl').value.trim();
return v || 'http://localhost:8080';
}
function stopRunning() {
if (state.aborter) {
state.aborter.abort();
state.aborter = null;
}
$('stopBtn').disabled = true;
}
function defaultPrompt() {
return [
'你是农业植保专家。请基于图片进行病虫害识别,并给出可执行建议。',
'输出请包含以下结构:',
'1) 可能病害/虫害名称Top3给出置信度与依据',
'2) 关键可见症状(你在图中看到的)',
'3) 可能原因与传播/发生条件',
'4) 处置建议:农业措施/物理措施/生物防治/化学用药建议(注意安全间隔期与轮换用药)',
'5) 需要补充的信息(如果不足以判断)',
'如果图片不足以判断,请明确说明并给出补拍建议(光线、角度、叶背、近景/远景等)。'
].join('\n');
}
function buildRequest() {
const model = $('model').value.trim() || 'qwen3-vl-plus';
const note = $('note').value.trim();
const prompt = $('prompt').value.trim();
if (!state.imageDataUrl) {
throw new Error('请先上传一张图片');
}
const userContent = [
{ type: 'image_url', image_url: { url: state.imageDataUrl } },
{ type: 'text', text: prompt + (note ? ('\n\n补充说明' + note) : '') }
];
return {
model,
messages: [
{ role: 'user', content: userContent }
]
};
}
async function callVlChat(body) {
stopRunning();
$('stopBtn').disabled = false;
state.aborter = new AbortController();
setRespBox('');
setDebug('');
const url = baseUrl() + '/api/vl/chat';
setDebug({ url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body });
const startedAt = Date.now();
setStatus('识别中...');
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: state.aborter.signal,
});
const text = await resp.text();
if (!resp.ok) {
throw new Error(`HTTP ${resp.status}: ${text}`);
}
// 后端返回 ChatResponse: { content: "..." }
if (!text) {
setRespBox('');
} else {
try {
const data = JSON.parse(text);
setRespBox(typeof data === 'string' ? data : (data.content ?? JSON.stringify(data, null, 2)));
} catch {
setRespBox(text);
}
}
setStatus(`完成:${((Date.now() - startedAt)/1000).toFixed(2)}s`);
} catch (e) {
if (e.name === 'AbortError') {
setStatus('已中止');
return;
}
setRespBox(String(e?.message ?? e), true);
setStatus('失败');
} finally {
state.aborter = null;
$('stopBtn').disabled = true;
}
}
function updateReqPreview() {
try {
const body = buildRequest();
setReqBox(body);
} catch (e) {
setReqBox(String(e?.message ?? e));
}
}
function setPreview(dataUrl) {
const preview = $('preview');
preview.innerHTML = '';
if (!dataUrl) {
const div = document.createElement('div');
div.className = 'muted small';
div.textContent = '未选择图片';
preview.appendChild(div);
return;
}
const img = document.createElement('img');
img.src = dataUrl;
preview.appendChild(img);
}
async function fileToDataUrl(file) {
const originalDataUrl = await new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onerror = () => reject(new Error('读取图片失败'));
reader.onload = () => resolve(String(reader.result || ''));
reader.readAsDataURL(file);
});
// 压缩/缩放:避免 data URL 过长导致请求体过大
// 说明base64 大约会把体积放大到 ~1.33 倍
const MAX_DIM = 1280;
const JPEG_QUALITY = 0.82;
try {
const img = await new Promise((resolve, reject) => {
const i = new Image();
i.onload = () => resolve(i);
i.onerror = () => reject(new Error('图片解码失败'));
i.src = originalDataUrl;
});
let w = img.naturalWidth || img.width;
let h = img.naturalHeight || img.height;
if (!w || !h) return originalDataUrl;
const scale = Math.min(1, MAX_DIM / Math.max(w, h));
const tw = Math.max(1, Math.round(w * scale));
const th = Math.max(1, Math.round(h * scale));
const canvas = document.createElement('canvas');
canvas.width = tw;
canvas.height = th;
const ctx = canvas.getContext('2d');
if (!ctx) return originalDataUrl;
ctx.drawImage(img, 0, 0, tw, th);
// 统一转 JPEG通常更小透明背景会变黑/默认背景
const compressed = canvas.toDataURL('image/jpeg', JPEG_QUALITY);
// 如果压缩后反而变大,退回原始 dataUrl
return compressed.length < originalDataUrl.length ? compressed : originalDataUrl;
} catch {
return originalDataUrl;
}
}
// init
$('baseUrl').value = 'http://localhost:8080';
$('prompt').value = defaultPrompt();
setPreview('');
updateReqPreview();
$('file').addEventListener('change', async (ev) => {
const file = ev.target.files && ev.target.files[0];
if (!file) {
state.imageDataUrl = '';
setPreview('');
updateReqPreview();
return;
}
try {
setStatus('读取图片...');
const dataUrl = await fileToDataUrl(file);
state.imageDataUrl = dataUrl;
setPreview(dataUrl);
setStatus('');
updateReqPreview();
} catch (e) {
state.imageDataUrl = '';
setPreview('');
setRespBox(String(e?.message ?? e), true);
setStatus('失败');
}
});
$('note').addEventListener('input', updateReqPreview);
$('prompt').addEventListener('input', updateReqPreview);
$('model').addEventListener('input', updateReqPreview);
$('prettyJson').addEventListener('change', updateReqPreview);
$('stopBtn').addEventListener('click', () => {
stopRunning();
setStatus('已停止');
});
$('analyzeBtn').addEventListener('click', async () => {
let body;
try {
body = buildRequest();
} catch (e) {
setRespBox(String(e?.message ?? e), true);
return;
}
setReqBox(body);
await callVlChat(body);
});
</script>
</body>
</html>