fix(voice): 移动端LiveKit连接稳定性修复(含signal代理与回退)
This commit is contained in:
1 parent
ee6ac473d8
commit
c1a2c3bc00
5 files changed
+702
-43
No files matched your search
@@ -1,19 +1,248 @@
|
||||
import asyncio
|
||||
from typing import Any
|
||||
from urllib.parse import urlencode, urlparse, urlunparse
|
||||
|
||||
import aiohttp
|
||||
from fastapi import APIRouter, Depends
|
||||
from fastapi import Request, WebSocket, WebSocketDisconnect
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.auth import verify_public_key
|
||||
from app.core.config import get_config
|
||||
from app.core.exceptions import AppException
|
||||
from app.core.logger import logger
|
||||
from app.services.grok.services.voice import VoiceService
|
||||
from app.services.reverse.utils.headers import build_ws_headers
|
||||
from app.services.reverse.utils.websocket import WebSocketClient
|
||||
from app.services.token.manager import get_token_manager
|
||||
|
||||
router = APIRouter()
|
||||
DEFAULT_LIVEKIT_URL = "wss://livekit.grok.com"
|
||||
|
||||
|
||||
class VoiceTokenResponse(BaseModel):
|
||||
token: str
|
||||
url: str
|
||||
urls: list[str] | None = None
|
||||
participant_name: str = ""
|
||||
room_name: str = ""
|
||||
ice_servers: list[dict[str, Any]] | None = None
|
||||
signal_proxy_url: str | None = None
|
||||
|
||||
|
||||
def _deep_get(data: dict[str, Any], path: tuple[str, ...]) -> Any:
|
||||
"""按路径安全读取嵌套字段。"""
|
||||
node: Any = data
|
||||
for key in path:
|
||||
if not isinstance(node, dict):
|
||||
return None
|
||||
node = node.get(key)
|
||||
return node
|
||||
|
||||
|
||||
def _first_string(data: dict[str, Any], paths: list[tuple[str, ...]]) -> str:
|
||||
"""按优先级获取第一个非空字符串字段。"""
|
||||
for path in paths:
|
||||
value = _deep_get(data, path)
|
||||
if isinstance(value, str) and value.strip():
|
||||
return value.strip()
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_ice_servers(raw: Any) -> list[dict[str, Any]]:
|
||||
"""归一化 ICE servers,兼容 url/urls 多种结构。"""
|
||||
if not isinstance(raw, list):
|
||||
return []
|
||||
|
||||
result: list[dict[str, Any]] = []
|
||||
for item in raw:
|
||||
if not isinstance(item, dict):
|
||||
continue
|
||||
|
||||
urls_value = item.get("urls")
|
||||
if urls_value is None:
|
||||
urls_value = item.get("url")
|
||||
|
||||
urls: list[str] = []
|
||||
if isinstance(urls_value, str) and urls_value.strip():
|
||||
urls = [urls_value.strip()]
|
||||
elif isinstance(urls_value, list):
|
||||
urls = [u.strip() for u in urls_value if isinstance(u, str) and u.strip()]
|
||||
|
||||
if not urls:
|
||||
continue
|
||||
|
||||
normalized: dict[str, Any] = {"urls": urls}
|
||||
username = item.get("username")
|
||||
credential = item.get("credential")
|
||||
if isinstance(username, str) and username.strip():
|
||||
normalized["username"] = username.strip()
|
||||
if credential is not None:
|
||||
normalized["credential"] = credential
|
||||
result.append(normalized)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def _normalize_ws_url(raw: Any) -> str:
|
||||
"""规范化 ws/wss URL,保留协议、主机与路径。"""
|
||||
if not isinstance(raw, str):
|
||||
return ""
|
||||
value = raw.strip()
|
||||
if not value:
|
||||
return ""
|
||||
if "://" not in value:
|
||||
value = f"wss://{value}"
|
||||
try:
|
||||
parsed = urlparse(value)
|
||||
if parsed.scheme not in ("ws", "wss") or not parsed.netloc:
|
||||
return ""
|
||||
path = (parsed.path or "").rstrip("/")
|
||||
return urlunparse((parsed.scheme, parsed.netloc, path, "", "", ""))
|
||||
except Exception:
|
||||
return ""
|
||||
|
||||
|
||||
def _normalize_ws_url_list(raw: Any) -> list[str]:
|
||||
"""归一化 URL 列表,兼容字符串、列表与逗号分隔形式。"""
|
||||
values: list[str] = []
|
||||
if isinstance(raw, str):
|
||||
values = [v.strip() for v in raw.replace("\n", ",").split(",")]
|
||||
elif isinstance(raw, list):
|
||||
values = [v for v in raw if isinstance(v, str)]
|
||||
else:
|
||||
return []
|
||||
|
||||
result: list[str] = []
|
||||
for value in values:
|
||||
normalized = _normalize_ws_url(value)
|
||||
if normalized and normalized not in result:
|
||||
result.append(normalized)
|
||||
return result
|
||||
|
||||
|
||||
def _extract_connection_info(
|
||||
data: dict[str, Any],
|
||||
) -> tuple[str, list[str], list[dict[str, Any]]]:
|
||||
"""提取连接 URL 列表与 ICE 配置,缺失时使用配置和默认值。"""
|
||||
url_paths = [
|
||||
("url",),
|
||||
("livekitUrl",),
|
||||
("livekit_url",),
|
||||
("livekitServerUrl",),
|
||||
("ws_url",),
|
||||
("serverUrl",),
|
||||
("livekit", "url"),
|
||||
("livekit", "ws_url"),
|
||||
("connection", "url"),
|
||||
("connectionDetails", "url"),
|
||||
("connection_details", "url"),
|
||||
]
|
||||
upstream_primary = _normalize_ws_url(_first_string(data, url_paths))
|
||||
|
||||
url_list_paths = [
|
||||
("urls",),
|
||||
("livekitUrls",),
|
||||
("livekit_urls",),
|
||||
("connection", "urls"),
|
||||
("connectionDetails", "urls"),
|
||||
("connection_details", "urls"),
|
||||
]
|
||||
upstream_urls: list[str] = []
|
||||
for path in url_list_paths:
|
||||
upstream_urls = _normalize_ws_url_list(_deep_get(data, path))
|
||||
if upstream_urls:
|
||||
break
|
||||
|
||||
configured_primary = _normalize_ws_url(get_config("voice.livekit_url", ""))
|
||||
configured_urls = _normalize_ws_url_list(get_config("voice.livekit_urls", []))
|
||||
urls: list[str] = []
|
||||
for candidate in (
|
||||
[configured_primary] + configured_urls + [upstream_primary] + upstream_urls + [DEFAULT_LIVEKIT_URL]
|
||||
):
|
||||
normalized = _normalize_ws_url(candidate)
|
||||
if normalized and normalized not in urls:
|
||||
urls.append(normalized)
|
||||
|
||||
ice_paths = [
|
||||
("iceServers",),
|
||||
("ice_servers",),
|
||||
("rtcConfig", "iceServers"),
|
||||
("rtcConfig", "ice_servers"),
|
||||
("rtc_config", "iceServers"),
|
||||
("rtc_config", "ice_servers"),
|
||||
("connectionDetails", "rtcConfig", "iceServers"),
|
||||
("connectionDetails", "rtc_config", "ice_servers"),
|
||||
("connection_details", "rtcConfig", "iceServers"),
|
||||
]
|
||||
ice_servers: list[dict[str, Any]] = []
|
||||
for path in ice_paths:
|
||||
ice_servers = _normalize_ice_servers(_deep_get(data, path))
|
||||
if ice_servers:
|
||||
break
|
||||
|
||||
return urls[0], urls, ice_servers
|
||||
|
||||
|
||||
def _build_signal_proxy_url(request: Request) -> str:
|
||||
"""生成前端可直连的同域信令代理地址。"""
|
||||
if not bool(get_config("voice.signal_proxy_enabled", True)):
|
||||
return ""
|
||||
|
||||
configured = _normalize_ws_url(get_config("voice.signal_proxy_url", ""))
|
||||
if configured:
|
||||
return configured
|
||||
|
||||
forwarded_host = request.headers.get("x-forwarded-host", "")
|
||||
host = (forwarded_host.split(",")[0].strip() if forwarded_host else "") or request.headers.get("host", "")
|
||||
if not host:
|
||||
return ""
|
||||
|
||||
forwarded_proto = request.headers.get("x-forwarded-proto", "")
|
||||
proto = (forwarded_proto.split(",")[0].strip().lower() if forwarded_proto else "") or request.url.scheme.lower()
|
||||
ws_scheme = "wss" if proto == "https" else "ws"
|
||||
return f"{ws_scheme}://{host}/v1/public/voice/signal"
|
||||
|
||||
|
||||
def _build_upstream_signal_url(websocket: WebSocket, tail: str) -> str:
|
||||
"""把浏览器请求映射到上游 LiveKit 信令 URL。"""
|
||||
upstream_override = _normalize_ws_url(websocket.query_params.get("upstream"))
|
||||
configured_primary = _normalize_ws_url(get_config("voice.livekit_url", ""))
|
||||
configured_urls = _normalize_ws_url_list(get_config("voice.livekit_urls", []))
|
||||
base_url = upstream_override or configured_primary or (
|
||||
configured_urls[0] if configured_urls else DEFAULT_LIVEKIT_URL
|
||||
)
|
||||
|
||||
parsed = urlparse(base_url)
|
||||
base_path = (parsed.path or "").rstrip("/")
|
||||
tail_clean = (tail or "").strip("/")
|
||||
if tail_clean:
|
||||
tail_path = f"/{tail_clean}"
|
||||
if base_path.endswith(tail_path):
|
||||
target_path = base_path
|
||||
elif base_path:
|
||||
target_path = f"{base_path}{tail_path}"
|
||||
else:
|
||||
target_path = tail_path
|
||||
else:
|
||||
target_path = base_path
|
||||
|
||||
query_items = [(k, v) for k, v in websocket.query_params.multi_items() if k != "upstream"]
|
||||
query = urlencode(query_items, doseq=True)
|
||||
return urlunparse((parsed.scheme, parsed.netloc, target_path, "", query, ""))
|
||||
|
||||
|
||||
def _mask_url(url: str) -> str:
|
||||
"""仅保留协议与主机,避免记录敏感参数。"""
|
||||
try:
|
||||
from urllib.parse import urlparse
|
||||
|
||||
parsed = urlparse(url)
|
||||
if parsed.scheme and parsed.netloc:
|
||||
return f"{parsed.scheme}://{parsed.netloc}{parsed.path or ''}"
|
||||
except Exception:
|
||||
pass
|
||||
return url
|
||||
|
||||
|
||||
@router.get(
|
||||
@@ -22,6 +251,7 @@ class VoiceTokenResponse(BaseModel):
|
||||
response_model=VoiceTokenResponse,
|
||||
)
|
||||
async def public_voice_token(
|
||||
request: Request,
|
||||
voice: str = "ara",
|
||||
personality: str = "assistant",
|
||||
speed: float = 1.0,
|
||||
@@ -57,11 +287,41 @@ async def public_voice_token(
|
||||
status_code=502,
|
||||
)
|
||||
|
||||
url, urls, ice_servers = _extract_connection_info(data)
|
||||
signal_proxy_url = _build_signal_proxy_url(request)
|
||||
participant_name = _first_string(
|
||||
data,
|
||||
[
|
||||
("participant_name",),
|
||||
("participantName",),
|
||||
("identity",),
|
||||
],
|
||||
)
|
||||
room_name = _first_string(
|
||||
data,
|
||||
[
|
||||
("room_name",),
|
||||
("roomName",),
|
||||
("room",),
|
||||
],
|
||||
)
|
||||
|
||||
logger.info(
|
||||
"Voice token prepared: "
|
||||
f"url={_mask_url(url)}, urls={len(urls)}, ice_servers={len(ice_servers)}, "
|
||||
f"signal_proxy={'yes' if signal_proxy_url else 'no'}, "
|
||||
f"participant_name={'yes' if participant_name else 'no'}, "
|
||||
f"room_name={'yes' if room_name else 'no'}, token_len={len(token)}"
|
||||
)
|
||||
|
||||
return VoiceTokenResponse(
|
||||
token=token,
|
||||
url="wss://livekit.grok.com",
|
||||
participant_name="",
|
||||
room_name="",
|
||||
url=url,
|
||||
urls=urls,
|
||||
participant_name=participant_name,
|
||||
room_name=room_name,
|
||||
ice_servers=ice_servers or None,
|
||||
signal_proxy_url=signal_proxy_url or None,
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
@@ -74,6 +334,88 @@ async def public_voice_token(
|
||||
)
|
||||
|
||||
|
||||
@router.websocket("/voice/signal")
|
||||
@router.websocket("/voice/signal/{tail:path}")
|
||||
async def public_voice_signal_proxy(websocket: WebSocket, tail: str = ""):
|
||||
"""同域信令代理:用于移动端无法直连 livekit.grok.com 的场景。"""
|
||||
upstream_conn = None
|
||||
await websocket.accept()
|
||||
try:
|
||||
upstream_url = _build_upstream_signal_url(websocket, tail)
|
||||
ws_client = WebSocketClient()
|
||||
upstream_conn = await ws_client.connect(
|
||||
upstream_url,
|
||||
headers=build_ws_headers(origin="https://grok.com"),
|
||||
timeout=get_config("voice.timeout"),
|
||||
)
|
||||
upstream_ws = upstream_conn.ws
|
||||
logger.info(f"Voice signal proxy connected: {_mask_url(upstream_url)}")
|
||||
|
||||
async def _client_to_upstream():
|
||||
while True:
|
||||
message = await websocket.receive()
|
||||
msg_type = message.get("type")
|
||||
if msg_type == "websocket.disconnect":
|
||||
break
|
||||
if msg_type != "websocket.receive":
|
||||
continue
|
||||
text_data = message.get("text")
|
||||
if text_data is not None:
|
||||
await upstream_ws.send_str(text_data)
|
||||
continue
|
||||
bytes_data = message.get("bytes")
|
||||
if bytes_data is not None:
|
||||
await upstream_ws.send_bytes(bytes_data)
|
||||
|
||||
async def _upstream_to_client():
|
||||
async for msg in upstream_ws:
|
||||
if msg.type == aiohttp.WSMsgType.TEXT:
|
||||
await websocket.send_text(msg.data)
|
||||
elif msg.type == aiohttp.WSMsgType.BINARY:
|
||||
await websocket.send_bytes(msg.data)
|
||||
elif msg.type in (
|
||||
aiohttp.WSMsgType.CLOSE,
|
||||
aiohttp.WSMsgType.CLOSED,
|
||||
aiohttp.WSMsgType.ERROR,
|
||||
):
|
||||
break
|
||||
|
||||
relay_tasks = [
|
||||
asyncio.create_task(_client_to_upstream()),
|
||||
asyncio.create_task(_upstream_to_client()),
|
||||
]
|
||||
done, pending = await asyncio.wait(
|
||||
relay_tasks, return_when=asyncio.FIRST_COMPLETED
|
||||
)
|
||||
|
||||
for task in pending:
|
||||
task.cancel()
|
||||
await asyncio.gather(*pending, return_exceptions=True)
|
||||
|
||||
for task in done:
|
||||
exc = task.exception()
|
||||
if exc:
|
||||
raise exc
|
||||
|
||||
except WebSocketDisconnect:
|
||||
logger.debug("Voice signal proxy: client disconnected")
|
||||
except Exception as e:
|
||||
logger.warning(f"Voice signal proxy error: {e}")
|
||||
finally:
|
||||
if upstream_conn:
|
||||
try:
|
||||
await upstream_conn.close()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
from starlette.websockets import WebSocketState
|
||||
|
||||
if websocket.client_state == WebSocketState.CONNECTED:
|
||||
await websocket.close(code=1000)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
@router.get("/verify", dependencies=[Depends(verify_public_key)])
|
||||
async def public_verify_api():
|
||||
"""验证 Public Key"""
|
||||
|
||||
@@ -116,7 +116,11 @@ const LOCALE_MAP = {
|
||||
|
||||
"voice": {
|
||||
"label": "语音配置",
|
||||
"timeout": { title: "请求超时", desc: "Voice 请求超时时间(秒)。" }
|
||||
"timeout": { title: "请求超时", desc: "Voice 请求超时时间(秒)。" },
|
||||
"livekit_url": { title: "LiveKit 地址", desc: "优先使用的 LiveKit 信令地址,留空则使用上游返回值。" },
|
||||
"livekit_urls": { title: "LiveKit 地址列表", desc: "可配置多个信令地址,连接时会按顺序回退。" },
|
||||
"signal_proxy_enabled": { title: "Signal 代理", desc: "开启后返回同域 WebSocket 代理地址,移动端可优先尝试。" },
|
||||
"signal_proxy_url": { title: "代理地址", desc: "同域 signal 代理的自定义地址,留空将自动按当前域名生成。" }
|
||||
},
|
||||
|
||||
|
||||
|
||||
+309
-30
@@ -5,6 +5,8 @@
|
||||
let Track;
|
||||
let room = null;
|
||||
let visualizerTimer = null;
|
||||
let isConnecting = false;
|
||||
let suppressDisconnectLog = false;
|
||||
|
||||
const startBtn = document.getElementById('startBtn');
|
||||
const stopBtn = document.getElementById('stopBtn');
|
||||
@@ -122,12 +124,269 @@
|
||||
throw new Error(`当前环境不支持麦克风权限,${secureHint}`);
|
||||
}
|
||||
|
||||
function getLivekitHost(rawUrl) {
|
||||
if (typeof rawUrl !== 'string' || !rawUrl.trim()) {
|
||||
return '-';
|
||||
}
|
||||
try {
|
||||
return new URL(rawUrl).host || rawUrl;
|
||||
} catch (err) {
|
||||
return rawUrl;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLivekitUrl(rawUrl, fallback = '') {
|
||||
if (typeof rawUrl !== 'string' || !rawUrl.trim()) {
|
||||
return fallback;
|
||||
}
|
||||
const value = rawUrl.trim();
|
||||
const withProtocol = value.includes('://') ? value : `wss://${value}`;
|
||||
try {
|
||||
const u = new URL(withProtocol);
|
||||
if (!u.protocol.startsWith('ws')) {
|
||||
return fallback;
|
||||
}
|
||||
const path = (u.pathname || '').replace(/\/+$/, '');
|
||||
return `${u.protocol}//${u.host}${path}`;
|
||||
} catch (err) {
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLivekitUrls(rawUrls, fallbackUrl = 'wss://livekit.grok.com') {
|
||||
const result = [];
|
||||
const push = (value) => {
|
||||
const normalized = normalizeLivekitUrl(value, '');
|
||||
if (!normalized || result.includes(normalized)) {
|
||||
return;
|
||||
}
|
||||
result.push(normalized);
|
||||
};
|
||||
|
||||
if (Array.isArray(rawUrls)) {
|
||||
for (const value of rawUrls) {
|
||||
push(value);
|
||||
}
|
||||
} else {
|
||||
push(rawUrls);
|
||||
}
|
||||
|
||||
push(fallbackUrl);
|
||||
if (!result.length) {
|
||||
result.push('wss://livekit.grok.com');
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function isSignalProxyUrl(rawUrl) {
|
||||
try {
|
||||
const parsed = new URL(rawUrl);
|
||||
const path = (parsed.pathname || '').replace(/\/+$/, '');
|
||||
return path.endsWith('/voice/signal') || path.endsWith('/voice/signal/rtc');
|
||||
} catch (err) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeIceServers(raw) {
|
||||
if (!Array.isArray(raw)) {
|
||||
return [];
|
||||
}
|
||||
const normalized = [];
|
||||
for (const item of raw) {
|
||||
if (!item || typeof item !== 'object') {
|
||||
continue;
|
||||
}
|
||||
const urlsRaw = item.urls || item.url;
|
||||
let urls = [];
|
||||
if (typeof urlsRaw === 'string') {
|
||||
const value = urlsRaw.trim();
|
||||
if (value) {
|
||||
urls = [value];
|
||||
}
|
||||
} else if (Array.isArray(urlsRaw)) {
|
||||
urls = urlsRaw
|
||||
.filter((u) => typeof u === 'string')
|
||||
.map((u) => u.trim())
|
||||
.filter((u) => u.length > 0);
|
||||
}
|
||||
if (!urls.length) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const entry = { urls };
|
||||
if (typeof item.username === 'string' && item.username.trim()) {
|
||||
entry.username = item.username.trim();
|
||||
}
|
||||
if (item.credential !== undefined && item.credential !== null) {
|
||||
entry.credential = item.credential;
|
||||
}
|
||||
normalized.push(entry);
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
function buildConnectOptions(iceServers, forceRelay = false) {
|
||||
const options = {
|
||||
autoSubscribe: true,
|
||||
maxRetries: 1,
|
||||
websocketTimeout: 30000,
|
||||
peerConnectionTimeout: 20000
|
||||
};
|
||||
const rtcConfig = {};
|
||||
if (Array.isArray(iceServers) && iceServers.length > 0) {
|
||||
rtcConfig.iceServers = iceServers;
|
||||
}
|
||||
if (forceRelay) {
|
||||
rtcConfig.iceTransportPolicy = 'relay';
|
||||
}
|
||||
if (Object.keys(rtcConfig).length > 0) {
|
||||
options.rtcConfig = rtcConfig;
|
||||
}
|
||||
return options;
|
||||
}
|
||||
|
||||
function buildCandidateUrls(rawUrls) {
|
||||
const seeds = Array.isArray(rawUrls) ? rawUrls : [rawUrls];
|
||||
const candidates = [];
|
||||
const push = (u) => {
|
||||
if (!u || candidates.includes(u)) {
|
||||
return;
|
||||
}
|
||||
candidates.push(u);
|
||||
};
|
||||
|
||||
for (const rawUrl of seeds) {
|
||||
const main = normalizeLivekitUrl(rawUrl, '').replace(/\/+$/, '');
|
||||
if (!main) {
|
||||
continue;
|
||||
}
|
||||
|
||||
push(main);
|
||||
if (main.endsWith('/rtc')) {
|
||||
push(main.slice(0, -4));
|
||||
} else {
|
||||
push(`${main}/rtc`);
|
||||
}
|
||||
|
||||
// 某些移动网络对默认端口推断不稳定,追加 :443 变体做兜底。
|
||||
try {
|
||||
const parsed = new URL(main);
|
||||
if (parsed.protocol === 'wss:' && !parsed.port) {
|
||||
const base443 = `${parsed.protocol}//${parsed.hostname}:443`;
|
||||
const path = parsed.pathname && parsed.pathname !== '/' ? parsed.pathname : '';
|
||||
push(`${base443}${path}`);
|
||||
if (path.endsWith('/rtc')) {
|
||||
push(base443);
|
||||
} else {
|
||||
push(`${base443}/rtc`);
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
if (!candidates.length) {
|
||||
push('wss://livekit.grok.com');
|
||||
push('wss://livekit.grok.com/rtc');
|
||||
}
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function bindRoomEvents(targetRoom) {
|
||||
targetRoom.on(RoomEvent.ParticipantConnected, (p) => log(`参与者已连接: ${p.identity}`));
|
||||
targetRoom.on(RoomEvent.ParticipantDisconnected, (p) => log(`参与者已断开: ${p.identity}`));
|
||||
targetRoom.on(RoomEvent.TrackSubscribed, (track) => {
|
||||
log(`订阅音轨: ${track.kind}`);
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach();
|
||||
element.autoplay = true;
|
||||
element.playsInline = true;
|
||||
if (audioRoot) {
|
||||
audioRoot.appendChild(element);
|
||||
} else {
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
targetRoom.on(RoomEvent.Disconnected, () => {
|
||||
if (!suppressDisconnectLog) {
|
||||
log('已断开连接');
|
||||
}
|
||||
if (!isConnecting) {
|
||||
resetUI();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function createRoomInstance() {
|
||||
const targetRoom = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true
|
||||
});
|
||||
bindRoomEvents(targetRoom);
|
||||
return targetRoom;
|
||||
}
|
||||
|
||||
async function connectWithFallbacks(rawUrls, token, iceServers) {
|
||||
const urlCandidates = buildCandidateUrls(rawUrls);
|
||||
const strategies = [{ forceRelay: false, label: '默认' }];
|
||||
if (Array.isArray(iceServers) && iceServers.length > 0) {
|
||||
strategies.push({ forceRelay: true, label: 'relay' });
|
||||
}
|
||||
|
||||
let lastError = null;
|
||||
let attempt = 0;
|
||||
|
||||
suppressDisconnectLog = true;
|
||||
try {
|
||||
for (const candidateUrl of urlCandidates) {
|
||||
for (const strategy of strategies) {
|
||||
attempt += 1;
|
||||
const connectOptions = buildConnectOptions(iceServers, strategy.forceRelay);
|
||||
log(`尝试连接 #${attempt} (${strategy.label}): ${candidateUrl}`);
|
||||
|
||||
room = createRoomInstance();
|
||||
try {
|
||||
await room.connect(candidateUrl, token, connectOptions);
|
||||
return {
|
||||
usedUrl: candidateUrl,
|
||||
usedProxy: isSignalProxyUrl(candidateUrl),
|
||||
usedRelay: strategy.forceRelay,
|
||||
attempts: attempt
|
||||
};
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
const message = err && err.message ? err.message : String(err || '');
|
||||
log(`连接失败 #${attempt} (${strategy.label}): ${message}`, 'warn');
|
||||
try {
|
||||
await room.disconnect();
|
||||
} catch (disconnectErr) {
|
||||
// ignore
|
||||
}
|
||||
room = null;
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
suppressDisconnectLog = false;
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
throw lastError;
|
||||
}
|
||||
throw new Error('连接失败:未找到可用连接策略');
|
||||
}
|
||||
|
||||
async function startSession() {
|
||||
if (!ensureLiveKit()) {
|
||||
return;
|
||||
}
|
||||
|
||||
let localTracks = [];
|
||||
try {
|
||||
isConnecting = true;
|
||||
const authHeader = await ensurePublicKey();
|
||||
if (authHeader === null) {
|
||||
toast('请先配置 Public Key', 'error');
|
||||
@@ -156,53 +415,73 @@
|
||||
throw new Error(`获取 Token 失败: ${response.status}`);
|
||||
}
|
||||
|
||||
const { token, url } = await response.json();
|
||||
const payload = await response.json();
|
||||
const token = typeof payload.token === 'string' ? payload.token.trim() : '';
|
||||
const url = typeof payload.url === 'string' && payload.url.trim()
|
||||
? payload.url.trim()
|
||||
: 'wss://livekit.grok.com';
|
||||
const urls = normalizeLivekitUrls(payload.urls, url);
|
||||
const signalProxyUrl = normalizeLivekitUrl(payload.signal_proxy_url || '', '');
|
||||
const isMobile = /Android|webOS|iPhone|iPad|iPod|Mobile/i.test(navigator.userAgent || '');
|
||||
const connectUrlSeeds = [];
|
||||
if (signalProxyUrl && isMobile) {
|
||||
connectUrlSeeds.push(signalProxyUrl);
|
||||
}
|
||||
connectUrlSeeds.push(...urls);
|
||||
if (signalProxyUrl && !isMobile) {
|
||||
connectUrlSeeds.push(signalProxyUrl);
|
||||
}
|
||||
const iceServers = normalizeIceServers(payload.ice_servers);
|
||||
const livekitHost = getLivekitHost(connectUrlSeeds[0] || url);
|
||||
if (!token) {
|
||||
throw new Error('服务端未返回有效 Token');
|
||||
}
|
||||
log(`获取 Token 成功 (${voiceSelect.value}, ${personalitySelect.value}, ${speedRange.value}x)`);
|
||||
log(`连接服务器: ${livekitHost}${iceServers.length ? `, ICE=${iceServers.length}` : ''}`);
|
||||
if (signalProxyUrl) {
|
||||
log(`signal 代理已就绪: ${signalProxyUrl}${isMobile ? ' (移动端优先)' : ' (回退使用)'}`);
|
||||
}
|
||||
|
||||
room = new Room({
|
||||
adaptiveStream: true,
|
||||
dynacast: true
|
||||
});
|
||||
log('正在请求麦克风权限...');
|
||||
ensureMicSupport();
|
||||
localTracks = await createLocalTracks({ audio: true, video: false });
|
||||
log('麦克风权限已授权');
|
||||
|
||||
room.on(RoomEvent.ParticipantConnected, (p) => log(`参与者已连接: ${p.identity}`));
|
||||
room.on(RoomEvent.ParticipantDisconnected, (p) => log(`参与者已断开: ${p.identity}`));
|
||||
room.on(RoomEvent.TrackSubscribed, (track) => {
|
||||
log(`订阅音轨: ${track.kind}`);
|
||||
if (track.kind === Track.Kind.Audio) {
|
||||
const element = track.attach();
|
||||
if (audioRoot) {
|
||||
audioRoot.appendChild(element);
|
||||
} else {
|
||||
document.body.appendChild(element);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
room.on(RoomEvent.Disconnected, () => {
|
||||
log('已断开连接');
|
||||
resetUI();
|
||||
});
|
||||
|
||||
await room.connect(url, token);
|
||||
log('已连接到 LiveKit 服务器');
|
||||
const connectResult = await connectWithFallbacks(connectUrlSeeds, token, iceServers);
|
||||
if (connectResult.usedRelay) {
|
||||
log('relay 模式连接成功', 'warn');
|
||||
}
|
||||
if (connectResult.usedProxy) {
|
||||
log('通过同域 signal 代理连接成功', 'warn');
|
||||
}
|
||||
log(`已连接到 LiveKit 服务器(尝试 ${connectResult.attempts} 次)`);
|
||||
|
||||
setStatus('connected', '通话中');
|
||||
setButtons(true);
|
||||
|
||||
log('正在开启麦克风...');
|
||||
ensureMicSupport();
|
||||
const tracks = await createLocalTracks({ audio: true, video: false });
|
||||
for (const track of tracks) {
|
||||
for (const track of localTracks) {
|
||||
await room.localParticipant.publishTrack(track);
|
||||
}
|
||||
log('语音已开启');
|
||||
toast('语音连接成功', 'success');
|
||||
} catch (err) {
|
||||
for (const track of localTracks) {
|
||||
try {
|
||||
track.stop();
|
||||
} catch (stopErr) {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
const message = err && err.message ? err.message : '连接失败';
|
||||
log(`错误: ${message}`, 'error');
|
||||
if (/could not establish pc connection/i.test(message)) {
|
||||
log('提示: 该错误通常与网络环境或 ICE/STUN/TURN 配置相关', 'warn');
|
||||
}
|
||||
toast(message, 'error');
|
||||
setStatus('error', '连接错误');
|
||||
startBtn.disabled = false;
|
||||
} finally {
|
||||
isConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,24 +3,32 @@
|
||||
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1, user-scalable=no, viewport-fit=cover, interactive-widget=overlays-content">
|
||||
<title>Grok2API - LiveKit 陪聊</title>
|
||||
<link rel="icon" href="/static/common/img/favicon/favicon.ico">
|
||||
<link rel="manifest" href="/manifest.webmanifest">
|
||||
<meta name="theme-color" content="#fafafa">
|
||||
<meta name="mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-capable" content="yes">
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default">
|
||||
<meta name="apple-mobile-web-app-title" content="Grok2API">
|
||||
<link rel="apple-touch-icon" href="/static/public/pwa/apple-touch-icon.png">
|
||||
<script src="https://cdn.tailwindcss.com"></script>
|
||||
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-sans/style.css" rel="stylesheet">
|
||||
<link href="https://cdn.jsdelivr.net/npm/geist@1.0.0/dist/fonts/geist-mono/style.css" rel="stylesheet">
|
||||
<link href="/static/common/css/common.css?v=0.3.0" rel="stylesheet">
|
||||
<link href="/static/public/css/voice.css?v=0.3.0" rel="stylesheet">
|
||||
<script src="/static/common/js/theme.js?v=0.1.0"></script>
|
||||
<link href="/static/common/css/common.css?v=0.3.7" rel="stylesheet">
|
||||
<link href="/static/public/css/voice.css?v=0.3.1" rel="stylesheet">
|
||||
<link href="/static/common/css/toast.css" rel="stylesheet">
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.7.3/dist/livekit-client.umd.min.js"></script>
|
||||
<script src="https://cdn.jsdelivr.net/npm/livekit-client@2.11.4/dist/livekit-client.umd.min.js"></script>
|
||||
</head>
|
||||
|
||||
<body class="min-h-screen flex flex-col" style="background-color: var(--bg);">
|
||||
<body class="public-page min-h-screen flex flex-col" style="background-color: var(--bg);">
|
||||
<div id="toast-container" class="toast-container"></div>
|
||||
<div id="app-header"></div>
|
||||
|
||||
<main class="space-y-6 flex-1 container mx-auto max-w-5xl px-6 py-8 fade-in">
|
||||
<div class="space-y-6">
|
||||
<main class="space-y-4 sm:space-y-6 flex-1 container mx-auto max-w-5xl px-4 sm:px-6 py-5 sm:py-8 fade-in">
|
||||
<div class="space-y-4 sm:space-y-6">
|
||||
<div class="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 class="text-2xl font-semibold tracking-tight">LiveKit 陪聊</h2>
|
||||
@@ -145,13 +153,31 @@
|
||||
</div>
|
||||
</main>
|
||||
|
||||
<div class="mobile-fab" data-mobile-fab>
|
||||
<div class="mobile-fab-panel">
|
||||
<div class="mobile-fab-actions">
|
||||
<button class="geist-button" data-proxy-click="#startBtn" type="button">开始会话</button>
|
||||
<button class="geist-button-outline" data-proxy-click="#stopBtn" type="button">停止会话</button>
|
||||
<button class="geist-button-outline" data-proxy-click="#clearLogBtn" type="button">清空日志</button>
|
||||
<button class="geist-button-outline" data-proxy-click="#copyLogBtn" type="button">复制日志</button>
|
||||
</div>
|
||||
</div>
|
||||
<button class="mobile-fab-toggle" data-fab-toggle type="button" aria-label="打开操作菜单">
|
||||
<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<path d="M12 5v14M5 12h14" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div id="app-footer"></div>
|
||||
|
||||
<script src="/static/common/js/admin-auth.js?v=0.3.0"></script>
|
||||
<script src="/static/common/js/public-header.js?v=0.3.0"></script>
|
||||
<script src="/static/common/js/public-header.js?v=0.3.3"></script>
|
||||
<script src="/static/common/js/footer.js?v=0.3.0"></script>
|
||||
<script src="/static/common/js/mobile-fab.js?v=0.1.1"></script>
|
||||
<script src="/static/common/js/toast.js"></script>
|
||||
<script src="/static/public/js/voice.js?v=0.3.0"></script>
|
||||
<script src="/static/common/js/pwa.js?v=0.1.1"></script>
|
||||
<script src="/static/public/js/voice.js?v=0.3.3"></script>
|
||||
</body>
|
||||
|
||||
</html>
|
||||
@@ -117,6 +117,14 @@ stream_timeout = 60
|
||||
[voice]
|
||||
# Voice 请求超时时间(秒)
|
||||
timeout = 60
|
||||
# LiveKit 信令地址(可选,优先于上游返回)
|
||||
livekit_url = ""
|
||||
# LiveKit 信令地址候选列表(可选,支持多地址回退)
|
||||
livekit_urls = []
|
||||
# 是否启用同域 signal 代理(用于移动端兜底)
|
||||
signal_proxy_enabled = true
|
||||
# signal 代理地址(可选,留空则按当前域名自动生成)
|
||||
signal_proxy_url = ""
|
||||
|
||||
# ==================== 资产配置 ====================
|
||||
[asset]
|
||||
|
||||
Reference in new issue
Block a user