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

348 lines
13 KiB
HTML
Raw 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>LLM Demo</title>
<style>
:root { --bg:#0b1220; --panel:#111a2e; --muted:#8aa0c5; --text:#e6eeff; --border:#233152; --accent:#4f8cff; --danger:#ff5c7a; }
* { 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: 1200px; margin: 0 auto; padding: 20px; }
.header { display:flex; align-items:flex-end; justify-content:space-between; gap:12px; margin-bottom: 16px; }
.title { font-size: 18px; font-weight: 650; 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; }
.panel h3 { margin: 0 0 10px 0; font-size: 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: 120px; 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:600; }
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); }
.pill input { width: auto; }
.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: 160px; 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(79,140,255,.35); }
.tabs { display:flex; gap: 8px; flex-wrap: wrap; margin-bottom: 10px; }
.tab { padding: 8px 10px; border-radius: 10px; border: 1px solid var(--border); background: rgba(255,255,255,.04); cursor:pointer; font-weight:650; font-size: 12px; }
.tab.active { border-color: rgba(79,140,255,.55); background: rgba(79,140,255,.14); }
.small { font-size: 12px; }
</style>
</head>
<body>
<div class="wrap">
<div class="header">
<div>
<div class="title">H5 前端调用模型接口(前后端分离版)</div>
<div class="sub">此页面可放在任意静态服务器;后端需开启 CORS我已在后端加了 CorsConfig</div>
</div>
<div class="small muted">接口:/api/chat 与 /api/vl/chat</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>Model可选留空则用后端默认</label>
<input id="model" type="text" placeholder="例如qwen3-vl-plus / 你的模型名" />
</div>
</div>
<div style="margin-top:10px;" class="btns">
<span class="pill"><input id="useStream" type="checkbox" /> <label for="useStream" style="margin:0; cursor:pointer;">流式SSE</label></span>
<span class="pill"><input id="prettyJson" type="checkbox" checked /> <label for="prettyJson" style="margin:0; cursor:pointer;">格式化 JSON 请求</label></span>
<button id="stopBtn" class="danger" disabled>停止</button>
<span id="status" class="muted small"></span>
</div>
</div>
<div class="grid">
<div class="panel">
<div class="tabs">
<div class="tab active" data-mode="text">文本模型(/api/chat</div>
<div class="tab" data-mode="vl">多模态模型(/api/vl/chat</div>
</div>
<div id="textMode">
<h3>文本请求</h3>
<label>System可选</label>
<textarea id="textSystem" placeholder="例如:你是一名严谨的助教。"></textarea>
<label style="margin-top:10px;">User</label>
<textarea id="textUser" placeholder="请输入问题...">你好,请用一句话介绍你自己。</textarea>
<div style="margin-top:10px;" class="btns">
<button id="sendTextBtn">发送</button>
<button id="genTextJsonBtn" class="secondary">生成 JSON</button>
</div>
</div>
<div id="vlMode" style="display:none;">
<h3>多模态请求</h3>
<div class="muted small" style="margin-bottom:10px;">
messages[].content 是 Object可以是字符串或数组包含 text/image_url 等)。
</div>
<label>Messages JSON可直接编辑</label>
<textarea id="vlMessagesJson" class="mono"></textarea>
<label style="margin-top:10px;">extraBody JSON可选例如 max_tokens、temperature 等)</label>
<textarea id="vlExtraJson" class="mono" placeholder='例如:{ "temperature": 0.2 }'></textarea>
<div style="margin-top:10px;" class="btns">
<button id="sendVlBtn">发送</button>
<button id="resetVlBtn" class="secondary">重置示例</button>
</div>
</div>
<div style="margin-top: 12px;">
<h3>最终请求体</h3>
<div id="reqBox" class="out mono muted"></div>
</div>
</div>
<div class="panel">
<h3>响应</h3>
<div id="respBox" class="out ok"></div>
<div style="margin-top:10px;">
<h3>调试信息</h3>
<div id="debugBox" class="out mono muted" style="min-height:120px;"></div>
</div>
</div>
</div>
</div>
<script>
const $ = (id) => document.getElementById(id);
const state = {
mode: 'text',
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 appendResp(text) {
$('respBox').textContent += text;
}
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 buildTextRequest() {
const model = $('model').value.trim();
const system = $('textSystem').value.trim();
const user = $('textUser').value.trim();
const messages = [];
if (system) messages.push({ role: 'system', content: system });
if (user) messages.push({ role: 'user', content: user });
const body = { messages };
if (model) body.model = model;
return body;
}
function parseJsonStrict(text, label) {
try { return JSON.parse(text); }
catch (e) { throw new Error(`${label} 不是合法 JSON${e.message}`); }
}
function buildVlRequest() {
const model = $('model').value.trim();
const messages = parseJsonStrict($('vlMessagesJson').value, 'Messages JSON');
const body = { messages };
if (model) body.model = model;
const extra = $('vlExtraJson').value.trim();
if (extra) {
body.extraBody = parseJsonStrict(extra, 'extraBody JSON');
}
return body;
}
function stopRunning() {
if (state.aborter) {
state.aborter.abort();
state.aborter = null;
}
$('stopBtn').disabled = true;
setStatus('已停止');
}
async function postJson(url, body, { stream=false } = {}) {
stopRunning();
$('stopBtn').disabled = false;
state.aborter = new AbortController();
setRespBox('');
setDebug('');
const reqInfo = { url, method: 'POST', headers: { 'Content-Type': 'application/json' }, body };
setDebug(reqInfo);
const startedAt = Date.now();
setStatus(stream ? '流式请求中...' : '请求中...');
try {
const resp = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
signal: state.aborter.signal,
});
const ct = resp.headers.get('content-type') || '';
if (!resp.ok) {
const t = await resp.text();
throw new Error(`HTTP ${resp.status}: ${t}`);
}
if (!stream) {
const t = await resp.text();
if (!t) {
setRespBox('');
} else if (ct.includes('application/json') || t.trim().startsWith('{') || t.trim().startsWith('[')) {
try {
const data = JSON.parse(t);
setRespBox(typeof data === 'string' ? data : (data.content ?? JSON.stringify(data, null, 2)));
} catch {
setRespBox(t);
}
} else {
setRespBox(t);
}
setStatus(`完成:${((Date.now() - startedAt)/1000).toFixed(2)}s`);
return;
}
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let buffer = '';
while (true) {
const { value, done } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const lines = buffer.split(/\r?\n/);
buffer = lines.pop() || '';
for (const line of lines) {
const trimmed = line.trim();
if (!trimmed) continue;
if (trimmed.startsWith('data:')) {
const data = trimmed.slice(5).trim();
if (data === '[DONE]') { setStatus('完成DONE'); continue; }
appendResp(data + '\n');
} else {
appendResp(line + '\n');
}
}
}
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 setMode(mode) {
state.mode = mode;
document.querySelectorAll('.tab').forEach(el => el.classList.toggle('active', el.dataset.mode === mode));
$('textMode').style.display = mode === 'text' ? '' : 'none';
$('vlMode').style.display = mode === 'vl' ? '' : 'none';
setRespBox('');
setDebug('');
$('reqBox').textContent = '';
}
function resetVlExample() {
const example = [
{ role: 'system', content: '你是一名严谨的助教。' },
{ role: 'user', content: '请用一句话解释什么是多模态模型。' }
];
$('vlMessagesJson').value = JSON.stringify(example, null, 2);
$('vlExtraJson').value = '';
}
// init
$('baseUrl').value = 'http://localhost:8080';
resetVlExample();
document.querySelectorAll('.tab').forEach(el => {
el.addEventListener('click', () => setMode(el.dataset.mode));
});
$('stopBtn').addEventListener('click', stopRunning);
$('genTextJsonBtn').addEventListener('click', () => {
try { setReqBox(buildTextRequest()); }
catch (e) { setReqBox(String(e.message ?? e)); }
});
$('sendTextBtn').addEventListener('click', async () => {
const stream = $('useStream').checked;
const body = buildTextRequest();
setReqBox(body);
const url = baseUrl() + (stream ? '/api/chat/stream' : '/api/chat');
await postJson(url, body, { stream });
});
$('resetVlBtn').addEventListener('click', resetVlExample);
$('sendVlBtn').addEventListener('click', async () => {
const stream = $('useStream').checked;
let body;
try {
body = buildVlRequest();
} catch (e) {
setRespBox(String(e.message ?? e), true);
return;
}
setReqBox(body);
const url = baseUrl() + (stream ? '/api/vl/chat/stream' : '/api/vl/chat');
await postJson(url, body, { stream });
});
</script>
</body>
</html>