155 lines
6.1 KiB
HTML
155 lines
6.1 KiB
HTML
<!doctype html>
|
|
<html lang="zh">
|
|
<head>
|
|
<meta charset="utf-8">
|
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
<title>文本对话 · 云 LLM/STT/TTS(免麦克风)</title>
|
|
<style>
|
|
body { font-family: system-ui, -apple-system, "PingFang SC", sans-serif; max-width: 720px;
|
|
margin: 0 auto; padding: 16px; background: #14161a; color: #e6e8eb; }
|
|
h1 { font-size: 18px; }
|
|
.status { color: #8b949e; font-size: 13px; margin-bottom: 8px; }
|
|
.chat { display: flex; flex-direction: column; gap: 8px; min-height: 320px;
|
|
max-height: 55vh; overflow-y: auto; padding: 8px; border: 1px solid #2a2f36;
|
|
border-radius: 8px; margin-bottom: 8px; }
|
|
.msg { padding: 8px 12px; border-radius: 10px; max-width: 85%; white-space: pre-wrap; font-size: 14px; }
|
|
.user { align-self: flex-end; background: #1f6feb; }
|
|
.ai { align-self: flex-start; background: #2a2f36; }
|
|
.row { display: flex; gap: 8px; }
|
|
input { flex: 1; padding: 10px; border-radius: 8px; border: 1px solid #3a4149;
|
|
background: #1c1f24; color: #e6e8eb; font-size: 14px; }
|
|
button { padding: 10px 18px; border-radius: 8px; border: none; background: #1f6feb;
|
|
color: #fff; font-size: 14px; cursor: pointer; }
|
|
button:disabled { opacity: .5; cursor: not-allowed; }
|
|
.hint { color: #6b7280; font-size: 12px; margin-top: 8px; }
|
|
</style>
|
|
</head>
|
|
<body>
|
|
<h1>💬 文本对话 · 云 LLM(qwen-plus) + 云 TTS(qwen3-tts)(免麦克风)</h1>
|
|
<div class="status" id="status">连接中…</div>
|
|
<div class="chat" id="chat"></div>
|
|
<div class="row">
|
|
<input id="text" placeholder="输入文字,回车发送" autocomplete="off">
|
|
<button id="send" disabled>发送</button>
|
|
<button id="replay" disabled>重播</button>
|
|
</div>
|
|
<div class="hint">回复语音在「发送」点击后自动播放(浏览器要求手势内激活音频);没听到就点「重播」。</div>
|
|
|
|
<script>
|
|
const $ = id => document.getElementById(id);
|
|
const chat = $("chat"), statusEl = $("status"), input = $("text"), sendBtn = $("send"), replayBtn = $("replay");
|
|
let ws = null, audioCtx = null, lastAudio = null;
|
|
let acc = []; // accumulate PCM16 bytes of the current response
|
|
let collecting = false;
|
|
|
|
function addMsg(role, text) {
|
|
const div = document.createElement("div");
|
|
div.className = "msg " + role;
|
|
div.textContent = text;
|
|
chat.appendChild(div);
|
|
chat.scrollTop = chat.scrollHeight;
|
|
}
|
|
function setStatus(s) { statusEl.textContent = s; }
|
|
|
|
async function getWsUrl() {
|
|
try {
|
|
const r = await fetch("/api/config");
|
|
const cfg = await r.json();
|
|
if (cfg.s2sUrl) return cfg.s2sUrl;
|
|
} catch (e) { /* fall through */ }
|
|
return "ws://192.168.1.9:8765/v1/realtime";
|
|
}
|
|
|
|
// Must be called from a user gesture (button click) so the browser allows audio.
|
|
function ensureAudio() {
|
|
if (!audioCtx) audioCtx = new (window.AudioContext || window.webkitAudioContext)();
|
|
if (audioCtx.state === "suspended") audioCtx.resume();
|
|
}
|
|
|
|
function playPcm16Bytes(bytes) {
|
|
if (!audioCtx) return;
|
|
const n = bytes.length >> 1;
|
|
const f32 = new Float32Array(n);
|
|
for (let i = 0; i < n; i++) {
|
|
let v = bytes[2*i] | (bytes[2*i+1] << 8); // little-endian int16
|
|
if (v >= 32768) v -= 65536; // sign-extend (fixes static)
|
|
f32[i] = v / 32768;
|
|
}
|
|
const buf = audioCtx.createBuffer(1, n, 16000);
|
|
buf.copyToChannel(f32, 0);
|
|
const src = audioCtx.createBufferSource();
|
|
src.buffer = buf;
|
|
src.connect(audioCtx.destination);
|
|
src.start();
|
|
}
|
|
|
|
function connect() {
|
|
getWsUrl().then(url => {
|
|
ws = new WebSocket(url);
|
|
ws.onopen = () => {
|
|
setStatus("已连接 " + url + " · 输入文字发送");
|
|
sendBtn.disabled = false;
|
|
ws.send(JSON.stringify({ type: "session.update", session: {
|
|
type: "realtime", instructions: "你是测试助手,请用中文简短回答。",
|
|
audio: { output: { voice: "Cherry" } } } }));
|
|
};
|
|
ws.onmessage = ev => {
|
|
let m; try { m = JSON.parse(ev.data); } catch { return; }
|
|
const t = m.type || "";
|
|
if (t === "response.created") { acc = []; collecting = true; }
|
|
else if (t === "response.audio.delta" || t === "response.output_audio.delta") {
|
|
if (m.delta && collecting) {
|
|
const raw = atob(m.delta);
|
|
const bytes = new Uint8Array(raw.length);
|
|
for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
|
|
acc.push(bytes);
|
|
}
|
|
}
|
|
else if (t === "response.output_text.done" || t === "response.output_audio_transcript.done") {
|
|
const txt = m.text || m.transcript || "";
|
|
if (txt) {
|
|
if (!chat.lastElementChild || !chat.lastElementChild.classList.contains("ai"))
|
|
addMsg("ai", "🤖 " + txt);
|
|
else chat.lastElementChild.textContent = "🤖 " + txt;
|
|
}
|
|
}
|
|
else if (t === "response.done") {
|
|
collecting = false;
|
|
if (acc.length) {
|
|
const total = acc.reduce((n, b) => n + b.length, 0);
|
|
lastAudio = new Uint8Array(total);
|
|
let off = 0;
|
|
for (const b of acc) { lastAudio.set(b, off); off += b.length; }
|
|
playPcm16Bytes(lastAudio);
|
|
replayBtn.disabled = false;
|
|
}
|
|
setStatus("已连接 · 输入文字发送");
|
|
}
|
|
else if (t === "error") {
|
|
setStatus("错误: " + (m.error?.message || JSON.stringify(m.error)));
|
|
}
|
|
};
|
|
ws.onclose = () => { setStatus("连接已断开,刷新重试"); sendBtn.disabled = true; };
|
|
ws.onerror = () => setStatus("连接失败,确认 s2s 后端(:8765)在运行");
|
|
});
|
|
}
|
|
|
|
function send() {
|
|
const text = input.value.trim();
|
|
if (!text || !ws || ws.readyState !== 1) return;
|
|
ensureAudio(); // resume audio inside the click gesture
|
|
addMsg("user", text);
|
|
ws.send(JSON.stringify({ type: "conversation.item.create", item: {
|
|
type: "message", role: "user", content: [{ type: "input_text", text }] } }));
|
|
ws.send(JSON.stringify({ type: "response.create" }));
|
|
input.value = "";
|
|
setStatus("思考中…");
|
|
}
|
|
sendBtn.onclick = send;
|
|
replayBtn.onclick = () => { ensureAudio(); if (lastAudio) playPcm16Bytes(lastAudio); };
|
|
input.addEventListener("keydown", e => { if (e.key === "Enter") send(); });
|
|
connect();
|
|
</script>
|
|
</body>
|
|
</html>
|