355 lines
13 KiB
HTML
355 lines
13 KiB
HTML
|
|
<!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>上传图片(建议 < 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>
|