jiaoxue/pest/frontend/llm-demo.html

348 lines
13 KiB
HTML
Raw Permalink 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>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>