Files
DPM/backend/vendor/s2s-cloud/demo/textchat.html
T

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>