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>
|