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