jiaoxue/pest/frontend/pest-disease-vl.html

355 lines
13 KiB
HTML
Raw Normal View History

2026-02-28 15:05:39 +08:00
<!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>