Files
qiming/qiming-rcoder/specs/agent-audio/agent-audio-spec.md
2026-06-01 13:54:52 +08:00

1917 lines
62 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Agent Audio & IME 技术设计方案
## 1. 项目背景
### 1.1 问题描述
当前 RCoder 项目的虚拟桌面noVNC存在以下问题
1. **音频缺失**:远程桌面播放视频时没有声音,无法听到音频内容
2. **输入法限制**:无法使用客户端本地输入法(如搜狗输入法)输入中文到远程桌面
### 1.2 现有架构
```
外部客户端(浏览器)
RCoder 主容器 (Pingora 代理 + HTTP API)
↓ 透明代理
Agent Runner 子容器
├── noVNC 服务 (端口 6080)
├── 音频流服务 (端口 6090)
└── IME 输入法服务 (端口 6091)
```
**核心要求**
- 子容器的端口不对外暴露,所有服务通过 Pingora 代理访问
- 使用 `{user_id}` 路由到对应的 ComputerAgentRunner 容器(每个 user_id 对应一个容器)
- 使用 `{project_id}` 用于日志追踪和路径匹配
- 复用现有的 VNC 代理架构和容器 IP 解析机制
---
## 2. 技术方案设计
### 2.1 整体架构
```
┌─────────────────────────────────────────────────────────────┐
│ 客户端浏览器 │
│ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌──────────┐ │
│ │ noVNC │ │ Audio │ │ IME │ │ 本地 │ │
│ │ Viewer │ │ Player │ │ Client │ │ 输入法 │ │
│ └────┬─────┘ └────┬─────┘ └────┬─────┘ └────┬─────┘ │
└───────┼─────────────┼─────────────┼─────────────┼──────────┘
│ │ │ │
│ WebSocket │ WebSocket │ WebSocket │
│ │ │ │
┌───────▼─────────────▼─────────────▼─────────────▼──────────┐
│ RCoder 主容器 (Pingora 代理) │
│ ┌────────────────────────────────────────────────────┐ │
│ │ Router (matchit) │ │
│ │ - /computer/vnc/{user}/{proj}/{*path} → VNC │ │
│ │ - /computer/audio/{user}/{proj}/{*path} → Audio │ │
│ │ - /computer/ime/{user}/{proj}/{*path} → IME │ │
│ └────────────────────────────────────────────────────┘ │
│ ↓ │
│ ┌────────────────────────────────────────────────────┐ │
│ │ VncBackendResolver (容器 IP 解析) │ │
│ │ - 查询 Docker 容器 IP │ │
│ │ - IP 缓存管理 │ │
│ └────────────────────────────────────────────────────┘ │
└──────────────────────┬───────────────────────────────────┘
│ 内部网络
┌──────────────────────▼───────────────────────────────────┐
│ Agent Runner 子容器 (per user/project) │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ noVNC │ │ Audio Server │ │ IME Server │ │
│ │ :6080 │ │ :6090 │ │ :6091 │ │
│ │ WebSocket │ │ WebSocket │ │ WebSocket │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
│ ↓ ↓ ↓ │
│ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │
│ │ VNC Server │ │ PulseAudio │ │ xdotool │ │
│ │ TigerVNC │ │ pcmflux │ │ xclip │ │
│ └──────────────┘ └──────────────┘ └──────────────┘ │
└──────────────────────────────────────────────────────────┘
```
### 2.2 音频流传输方案
#### 2.2.1 容器端实现
**已实现组件**
- `audio_server.py`:基于 pcmflux 的音频流服务
- PulseAudio 虚拟音频设备:`virtual_speaker.monitor`
- WebSocket 服务端:`ws://0.0.0.0:6089`(音频流)
- HTTP 静态服务:`http://0.0.0.0:6090`(播放器页面)
**工作原理**
```
应用程序 (Chrome/Firefox 播放视频)
↓ 音频输出
PulseAudio 虚拟音频设备 (virtual_speaker.monitor)
↓ 音频采集
pcmflux (C++ 库)
↓ Opus 编码 (48kHz, 2 channels, 128kbps)
audio_server.py
↓ WebSocket (二进制帧: 0x01 + opus_bytes)
客户端浏览器 (Opus 解码 + Web Audio API 播放)
```
**关键配置**
```python
# audio_server.py
HTTP_PORT = 6090 # 静态文件服务(播放器页面)
WS_PORT = 6089 # WebSocket 音频流
AUDIO_DEVICE = "virtual_speaker.monitor"
# AudioCaptureSettings
sample_rate = 48000
channels = 2
opus_bitrate = 128000
frame_duration_ms = 20
use_vbr = True
use_silence_gate = True # 跳过静音片段,节省带宽
```
#### 2.2.2 Pingora 代理层设计
**新增路由规则**
```rust
// crates/rcoder-proxy/src/router.rs
/// 路由类型枚举 - 新增音频代理
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RouteType {
VncProxy,
PortProxy,
HealthCheck,
ApiProxy,
/// 音频流代理: `/computer/audio/{user_id}/{project_id}/{*path}`
///
/// - `user_id`: 用户标识符
/// - `project_id`: 项目标识符
/// - `path`: 剩余路径(如 `ws`, `/` 等)
///
/// **目标**: 容器内的音频流服务
/// - HTTP (端口 6090): 静态文件(播放器页面)
/// - WebSocket (端口 6089): 音频流
///
/// **示例**:
/// - `/computer/audio/user_123/proj_456/` → 播放器页面 (HTTP)
/// - `/computer/audio/user_123/proj_456/ws` → 音频流 (WebSocket)
AudioProxy,
}
pub fn create_router() -> Result<Router<RouteType>, anyhow::Error> {
let mut router = Router::new();
// 现有路由...
// ========================================================================
// 音频流代理路由
// ========================================================================
//
// 路径格式: /computer/audio/{user_id}/{project_id}/{*path}
//
// 功能: 将 HTTP 和 WebSocket 请求代理到用户容器的音频流服务
//
// 参数:
// - user_id: 用户标识符,用于查找对应的容器 IP
// - project_id: 项目标识符
// - path: 剩余路径
// - "/" 或空: 静态播放器页面 (HTTP 6090)
// - "ws": 音频流 WebSocket (WS 6089)
//
// 示例:
// - /computer/audio/user_123/proj_456/ -> 容器IP:6090/
// - /computer/audio/user_123/proj_456/ws -> 容器IP:6089/ws (WebSocket)
//
router
.insert(
"/computer/audio/{user_id}/{project_id}/{*path}",
RouteType::AudioProxy,
)
.map_err(|e| {
tracing::error!("❌ [ROUTER] 音频代理路由插入失败: {}", e);
anyhow::anyhow!("Audio proxy route configuration error: {}", e)
})?;
Ok(router)
}
```
**代理实现**
```rust
// crates/rcoder-proxy/src/service.rs
impl PortProxy {
/// 处理音频流代理请求
async fn handle_audio_request(
&self,
upstream_request: &mut RequestHeader,
original_uri: &http::Uri,
params: Params<'_, '_>,
ctx: &mut TrackingCtx,
) -> PingoraResult<()> {
// 从路径参数中提取 user_id 和 project_id
let user_id = params.get("user_id").ok_or_else(|| {
error!("音频路由缺少 user_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
let project_id = params.get("project_id").ok_or_else(|| {
error!("音频路由缺少 project_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
// 提取剩余路径(标准化空路径和尾斜杠)
let remaining_path = match params.get("path") {
Some(p) if !p.is_empty() => p,
_ => "",
};
// 判断是 WebSocket 音频流还是 HTTP 静态文件
// 规则:
// - "ws" 或 "ws/*" -> WebSocket 音频流 (端口 6089)
// - 其他所有情况(包括空路径、"/"、"index.html" 等) -> HTTP 静态文件 (端口 6090)
let (target_port, target_path) = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
// WebSocket 音频流 (端口 6089)
(6089_u16, format!("/{}", remaining_path))
} else {
// HTTP 静态文件 (端口 6090),包括空路径默认为根路径
let path = if remaining_path.is_empty() { "/" } else { remaining_path };
(6090_u16, format!("/{}", path.trim_start_matches('/')))
};
// 从缓存中获取容器 IP
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
warn!(
"❌ [AUDIO] 用户容器不存在: user_id={}, project_id={}",
user_id, project_id
);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
// 记录上下文信息
ctx.target_port = Some(target_port);
ctx.upstream_host = Some(format!("{}:{}", container_ip, target_port));
info!(
"🎵 [AUDIO] 音频代理: user_id={}, project_id={}, path={}, target={}:{}",
user_id, project_id, remaining_path, container_ip, target_port
);
// 重写 URI
let new_uri = Self::rewrite_uri(original_uri, target_path)?;
upstream_request.set_uri(new_uri);
// 设置通用请求头
Self::set_common_headers(upstream_request)?;
// 对于 WebSocket 请求,保持升级头
// Pingora 会自动处理 WebSocket 升级
Ok(())
}
/// 处理音频流的上游连接
async fn handle_audio_upstream(
&self,
ctx: &TrackingCtx,
params: Params<'_, '_>,
) -> PingoraResult<Box<HttpPeer>> {
let user_id = params.get("user_id").ok_or_else(|| {
error!("音频路由缺少 user_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
let remaining_path = match params.get("path") {
Some(p) if !p.is_empty() => p,
_ => "",
};
// 判断目标端口(与 handle_audio_request 保持一致)
let target_port = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
6089_u16 // WebSocket 音频流
} else {
6090_u16 // HTTP 静态文件(包括空路径)
};
// 获取容器 IP
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
warn!("❌ [AUDIO] 容器不存在: user_id={}", user_id);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
// 记录指标
self.metrics.record_request();
self.metrics.record_request_port(target_port).await;
self.metrics.inc_active();
// 创建 HTTP Peer
let peer_addr = format!("{}:{}", container_ip, target_port);
let mut peer = Box::new(HttpPeer::new(
peer_addr.clone(),
false, // 不使用 TLS
"".to_string(),
));
// 配置连接参数(音频流和 IME 长连接优化)
peer.options.connection_timeout = Some(Duration::from_secs(10));
peer.options.read_timeout = None; // 无限等待(音频流可能持续数小时)
peer.options.write_timeout = Some(Duration::from_secs(30));
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
debug!(
"🎵 [AUDIO] 连接到音频后端: {} (port={})",
peer_addr, target_port
);
Ok(peer)
}
}
#[async_trait]
impl ProxyHttp for PortProxy {
// upstream_request_filter 中添加路由处理
async fn upstream_request_filter(
&self,
_session: &mut Session,
upstream_request: &mut RequestHeader,
ctx: &mut Self::CTX,
) -> PingoraResult<()> {
let original_uri = upstream_request.uri.clone();
let path = original_uri.path();
let matched = self.router.at(path).map_err(|_| {
warn!("未匹配到路由: {}", path);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
match matched.value {
RouteType::VncProxy => {
self.handle_vnc_request(upstream_request, &original_uri, matched.params, ctx).await?;
}
RouteType::AudioProxy => {
self.handle_audio_request(upstream_request, &original_uri, matched.params, ctx).await?;
}
RouteType::PortProxy => {
self.handle_port_proxy_request(upstream_request, &original_uri, matched.params).await?;
}
// ... 其他路由类型
}
Ok(())
}
// upstream_peer 中添加路由处理
async fn upstream_peer(
&self,
_session: &mut Session,
ctx: &mut Self::CTX,
) -> PingoraResult<Box<HttpPeer>> {
let req_header = _session.req_header();
let path = req_header.uri.path();
let matched = self.router.at(path).map_err(|_| {
warn!("未匹配到路由: {}", path);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
match matched.value {
RouteType::VncProxy => self.handle_vnc_upstream(ctx, matched.params).await,
RouteType::AudioProxy => self.handle_audio_upstream(ctx, matched.params).await,
RouteType::PortProxy => self.handle_port_proxy_upstream(ctx, matched.params).await,
// ... 其他路由类型
}
}
}
```
#### 2.2.3 客户端集成
**前端使用示例**
```html
<!-- 音频播放器页面 -->
<!DOCTYPE html>
<html>
<head>
<title>Remote Desktop Audio</title>
</head>
<body>
<h1>Remote Desktop Audio Stream</h1>
<div id="status">连接中...</div>
<button id="playBtn">开始播放</button>
<script>
const userId = 'user_123';
const projectId = 'proj_456';
// 通过 Pingora 代理连接音频流
const wsUrl = `ws://${window.location.host}/computer/audio/${userId}/${projectId}/ws`;
let audioContext;
let ws;
document.getElementById('playBtn').addEventListener('click', async () => {
// 初始化 Web Audio API
audioContext = new AudioContext({sampleRate: 48000});
// 连接 WebSocket
ws = new WebSocket(wsUrl);
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
document.getElementById('status').textContent = '已连接';
};
ws.onmessage = async (event) => {
const data = new Uint8Array(event.data);
// 检查协议头 (0x01 表示 Opus 音频)
if (data[0] === 0x01) {
const opusData = data.slice(1);
// 解码 Opus (需要 opus-decoder 库)
const pcmData = await decodeOpus(opusData);
// 播放音频
const audioBuffer = audioContext.createBuffer(
2, pcmData.length / 2, 48000
);
// ... 填充 audioBuffer 并播放
}
};
ws.onerror = (err) => {
console.error('WebSocket 错误:', err);
};
ws.onclose = () => {
document.getElementById('status').textContent = '已断开';
};
});
</script>
</body>
</html>
```
---
### 2.3 输入法透传方案
#### 2.3.1 容器端实现
**已实现组件**
- `ime_server.py`WebSocket 输入法服务
- `xdotool`X11 自动化工具(模拟键盘输入)
- `xclip`:剪贴板工具(备用方案)
**工作原理**
```
客户端浏览器
↓ 用户使用本地输入法输入中文
JavaScript 监听输入事件 (compositionend)
↓ WebSocket 发送 JSON: {"type": "text", "text": "你好"}
ime_server.py (WebSocket Server)
↓ 调用 xdotool type 或剪贴板粘贴
X11 服务器
↓ 将文本输入到当前焦点窗口
远程桌面应用程序接收文本
```
**关键实现**
```python
# ime_server.py
IME_PORT = 6091
IME_HOST = '0.0.0.0'
# 消息格式
{
"type": "text", # 消息类型
"text": "你好世界", # 要输入的文本
"method": "xdotool" # 输入方法: xdotool | clipboard
}
# xdotool 输入
subprocess.run([
'xdotool', 'type',
'--clearmodifiers', # 清除修饰键
'--delay', '10', # 字符间延迟 10ms
'--', text
], env={'DISPLAY': ':0'})
# 备用方案:剪贴板粘贴
# 1. 复制到剪贴板
subprocess.Popen(['xclip', '-selection', 'clipboard'],
stdin=subprocess.PIPE).communicate(text.encode('utf-8'))
# 2. 模拟 Ctrl+V
subprocess.run(['xdotool', 'key', '--clearmodifiers', 'ctrl+v'])
```
#### 2.3.2 Pingora 代理层设计
**新增路由规则**
```rust
// crates/rcoder-proxy/src/router.rs
pub enum RouteType {
// ... 现有类型
/// IME 输入法代理: `/computer/ime/{user_id}/{project_id}/{*path}`
///
/// - `user_id`: 用户标识符
/// - `project_id`: 项目标识符
/// - `path`: 剩余路径(通常为空或 "ws"
///
/// **目标**: 容器内的 IME 输入法服务(端口 6091, WebSocket
///
/// **示例**:
/// - `/computer/ime/user_123/proj_456/` → WebSocket 升级
ImeProxy,
}
pub fn create_router() -> Result<Router<RouteType>, anyhow::Error> {
let mut router = Router::new();
// ... 现有路由
// ========================================================================
// IME 输入法代理路由
// ========================================================================
//
// 路径格式: /computer/ime/{user_id}/{project_id}/{*path}
//
// 功能: 将 WebSocket 请求代理到用户容器的 IME 输入法服务
//
// 参数:
// - user_id: 用户标识符,用于查找对应的容器 IP
// - project_id: 项目标识符
// - path: 剩余路径(通常为空)
//
// 示例:
// - /computer/ime/user_123/proj_456/ -> 容器IP:6091/ (WebSocket)
//
router
.insert(
"/computer/ime/{user_id}/{project_id}/{*path}",
RouteType::ImeProxy,
)
.map_err(|e| {
tracing::error!("❌ [ROUTER] IME 代理路由插入失败: {}", e);
anyhow::anyhow!("IME proxy route configuration error: {}", e)
})?;
Ok(router)
}
```
**代理实现**
```rust
// crates/rcoder-proxy/src/service.rs
/// IME 输入法服务端口
pub const IME_PORT: u16 = 6091;
impl PortProxy {
/// 处理 IME 输入法代理请求
async fn handle_ime_request(
&self,
upstream_request: &mut RequestHeader,
original_uri: &http::Uri,
params: Params<'_, '_>,
ctx: &mut TrackingCtx,
) -> PingoraResult<()> {
let user_id = params.get("user_id").ok_or_else(|| {
error!("IME 路由缺少 user_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
let project_id = params.get("project_id").ok_or_else(|| {
error!("IME 路由缺少 project_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
let remaining_path = params.get("path").unwrap_or("");
let target_path = format!("/{}", remaining_path);
// 获取容器 IP
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
warn!(
"❌ [IME] 用户容器不存在: user_id={}, project_id={}",
user_id, project_id
);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
// 记录上下文
ctx.target_port = Some(IME_PORT);
ctx.upstream_host = Some(format!("{}:{}", container_ip, IME_PORT));
info!(
"⌨️ [IME] 输入法代理: user_id={}, project_id={}, path={}, target={}:{}",
user_id, project_id, remaining_path, container_ip, IME_PORT
);
// 重写 URI
let new_uri = Self::rewrite_uri(original_uri, target_path)?;
upstream_request.set_uri(new_uri);
// 设置通用请求头
Self::set_common_headers(upstream_request)?;
Ok(())
}
/// 处理 IME 输入法的上游连接
async fn handle_ime_upstream(
&self,
ctx: &TrackingCtx,
params: Params<'_, '_>,
) -> PingoraResult<Box<HttpPeer>> {
let user_id = params.get("user_id").ok_or_else(|| {
error!("IME 路由缺少 user_id 参数");
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(400))
})?;
// 获取容器 IP
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| {
warn!("❌ [IME] 容器不存在: user_id={}", user_id);
pingora_core::Error::new(pingora_core::ErrorType::HTTPStatus(404))
})?;
// 记录指标
self.metrics.record_request();
self.metrics.record_request_port(IME_PORT).await;
self.metrics.inc_active();
// 创建 HTTP Peer
let peer_addr = format!("{}:{}", container_ip, IME_PORT);
let mut peer = Box::new(HttpPeer::new(
peer_addr.clone(),
false, // 不使用 TLS
"".to_string(),
));
// 配置连接参数IME 输入法长连接优化)
peer.options.connection_timeout = Some(Duration::from_secs(10));
peer.options.read_timeout = None; // 无限等待IME 需要保持长连接)
peer.options.write_timeout = Some(Duration::from_secs(30));
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
debug!("⌨️ [IME] 连接到 IME 后端: {}", peer_addr);
Ok(peer)
}
}
// 在 upstream_request_filter 和 upstream_peer 中添加 ImeProxy 分支
#[async_trait]
impl ProxyHttp for PortProxy {
async fn upstream_request_filter(
&self,
_session: &mut Session,
upstream_request: &mut RequestHeader,
ctx: &mut Self::CTX,
) -> PingoraResult<()> {
// ...
match matched.value {
RouteType::VncProxy => { /* ... */ }
RouteType::AudioProxy => { /* ... */ }
RouteType::ImeProxy => {
self.handle_ime_request(upstream_request, &original_uri, matched.params, ctx).await?;
}
// ...
}
Ok(())
}
async fn upstream_peer(
&self,
_session: &mut Session,
ctx: &mut Self::CTX,
) -> PingoraResult<Box<HttpPeer>> {
// ...
match matched.value {
RouteType::VncProxy => self.handle_vnc_upstream(ctx, matched.params).await,
RouteType::AudioProxy => self.handle_audio_upstream(ctx, matched.params).await,
RouteType::ImeProxy => self.handle_ime_upstream(ctx, matched.params).await,
// ...
}
}
}
```
#### 2.3.3 客户端集成
**前端实现示例**
```javascript
// ime_client.js - 输入法客户端封装
class ImeClient {
constructor(userId, projectId) {
this.userId = userId;
this.projectId = projectId;
this.ws = null;
this.connected = false;
}
connect() {
const wsUrl = `ws://${window.location.host}/computer/ime/${this.userId}/${this.projectId}/`;
this.ws = new WebSocket(wsUrl);
this.ws.onopen = () => {
console.log('[IME] 连接成功');
this.connected = true;
};
this.ws.onerror = (err) => {
console.error('[IME] 连接错误:', err);
this.connected = false;
};
this.ws.onclose = () => {
console.log('[IME] 连接关闭');
this.connected = false;
};
this.ws.onmessage = (event) => {
try {
const response = JSON.parse(event.data);
if (response.status !== 'ok') {
console.error('[IME] 服务器错误:', response.message);
}
} catch (e) {
console.error('[IME] 响应解析失败:', e);
}
};
}
/**
* 发送文本到远程桌面
* @param {string} text - 要输入的文本
* @param {string} method - 输入方法: 'xdotool' | 'clipboard'
*/
sendText(text, method = 'xdotool') {
if (!this.connected) {
console.warn('[IME] 未连接,无法发送文本');
return;
}
const message = JSON.stringify({
type: 'text',
text: text,
method: method
});
this.ws.send(message);
}
disconnect() {
if (this.ws) {
this.ws.close();
this.ws = null;
}
}
}
// 使用示例:监听 noVNC 画布的输入事件
function setupImeForNoVNC(userId, projectId) {
const imeClient = new ImeClient(userId, projectId);
imeClient.connect();
// 获取 noVNC 的画布元素
const canvas = document.querySelector('#noVNC_canvas');
// 创建隐藏的输入框用于捕获输入法输入
const inputProxy = document.createElement('input');
inputProxy.type = 'text';
inputProxy.style.position = 'absolute';
inputProxy.style.opacity = '0';
inputProxy.style.pointerEvents = 'none';
document.body.appendChild(inputProxy);
// 当用户点击画布时,聚焦到输入代理框
canvas.addEventListener('click', () => {
inputProxy.focus();
});
// 监听输入法完成事件(用户输入完成一个词组)
inputProxy.addEventListener('compositionend', (event) => {
const text = event.data;
if (text && text.length > 0) {
console.log('[IME] 输入完成:', text);
imeClient.sendText(text);
// 清空输入框
inputProxy.value = '';
}
});
// 监听普通按键(非输入法输入)
inputProxy.addEventListener('keydown', (event) => {
// 对于特殊键(如回车、退格),直接发送到 noVNC
if (event.key === 'Enter' || event.key === 'Backspace') {
// 让 noVNC 处理这些特殊键
event.preventDefault();
// 这里需要调用 noVNC 的键盘事件处理
}
});
return imeClient;
}
// 初始化
const imeClient = setupImeForNoVNC('user_123', 'proj_456');
```
---
## 3. 容器 IP 解析复用
### 3.1 现有机制
RCoder 已经为 VNC 代理实现了容器 IP 解析机制:
```rust
// crates/rcoder-proxy/src/vnc_resolver.rs
pub trait VncBackendResolver: Send + Sync {
/// 根据 user_id 解析容器 IP
async fn resolve(&self, user_id: &str) -> Result<VncBackendInfo, VncResolveError>;
/// 检查容器是否存在
async fn exists(&self, user_id: &str) -> bool;
}
pub struct VncBackendInfo {
pub container_ip: String,
pub vnc_port: u16,
pub is_running: bool,
}
```
### 3.2 复用策略
音频和 IME 代理**直接复用** VNC 的容器 IP 解析机制:
```rust
// crates/rcoder-proxy/src/service.rs
pub struct PortProxy {
// ...
/// VNC 后端映射: user_id -> container_ip
/// 这个映射同时用于 VNC、Audio 和 IME 代理
vnc_backends: Arc<DashMap<String, String>>,
}
impl PortProxy {
// 音频和 IME 代理使用相同的容器 IP 查询
async fn handle_audio_request(&self, ...) -> PingoraResult<()> {
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| /* 容器不存在 */)?;
// 使用 container_ip 连接到音频端口 6089/6090
}
async fn handle_ime_request(&self, ...) -> PingoraResult<()> {
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| /* 容器不存在 */)?;
// 使用 container_ip 连接到 IME 端口 6091
}
}
```
**优势**
- 无需额外的 IP 解析逻辑
- 保持与 VNC 代理的一致性
- 利用现有的 IP 缓存机制(`DashMap`
### 3.3 容器 IP 更新机制
当容器重启或 IP 变化时,需要更新缓存:
```rust
// crates/rcoder/src/api/computer_agent.rs
impl PingoraProxyService {
/// 注册或更新容器的后端 IPVNC、Audio、IME 共享)
pub async fn register_vnc_backend(&self, user_id: String, container_ip: String) {
self.vnc_backends.insert(user_id.clone(), container_ip.clone());
tracing::info!(
"✅ [PROXY] 注册容器后端: user_id={}, container_ip={}",
user_id, container_ip
);
}
/// 移除容器的后端 IP
pub async fn unregister_vnc_backend(&self, user_id: &str) {
self.vnc_backends.remove(user_id);
tracing::info!("🗑️ [PROXY] 移除容器后端: user_id={}", user_id);
}
}
```
---
## 4. 实现步骤
### 4.1 Phase 1: 路由层实现
#### 任务清单
- [ ] **修改 `router.rs`**
- [ ]`RouteType` 枚举中添加 `AudioProxy``ImeProxy`
- [ ]`create_router()` 中注册音频路由:`/computer/audio/{user_id}/{project_id}/{*path}`
- [ ]`create_router()` 中注册 IME 路由:`/computer/ime/{user_id}/{project_id}/{*path}`
- [ ] 更新 `get_routes_documentation()` 添加新路由文档
- [ ] **编写单元测试**
- [ ] 测试音频路由匹配:`test_audio_route_matching()`
- [ ] 测试 IME 路由匹配:`test_ime_route_matching()`
- [ ] 测试路由参数提取:`test_audio_ime_parameter_extraction()`
### 4.2 Phase 2: 代理逻辑实现
#### 任务清单
- [ ] **修改 `service.rs` - 音频代理**
- [ ] 实现 `handle_audio_request()` 方法
- [ ] 实现 `handle_audio_upstream()` 方法
- [ ]`upstream_request_filter()` 中添加 `RouteType::AudioProxy` 分支
- [ ]`upstream_peer()` 中添加 `RouteType::AudioProxy` 分支
- [ ] 添加音频代理的日志和指标记录
- [ ] **修改 `service.rs` - IME 代理**
- [ ] 实现 `handle_ime_request()` 方法
- [ ] 实现 `handle_ime_upstream()` 方法
- [ ]`upstream_request_filter()` 中添加 `RouteType::ImeProxy` 分支
- [ ]`upstream_peer()` 中添加 `RouteType::ImeProxy` 分支
- [ ] 添加 IME 代理的日志和指标记录
- [ ] **添加常量定义**
- [ ] 定义 `AUDIO_HTTP_PORT = 6090`
- [ ] 定义 `AUDIO_WS_PORT = 6089`
- [ ] 定义 `IME_PORT = 6091`
### 4.3 Phase 3: 集成测试
#### 任务清单
- [ ] **容器端测试**
- [ ] 验证 `audio_server.py` 在容器内正常运行
- [ ] 验证 `ime_server.py` 在容器内正常运行
- [ ] 测试容器内服务端口监听状态6089, 6090, 6091
- [ ] **代理层测试**
- [ ] 测试音频 HTTP 请求代理:`curl http://localhost:8087/computer/audio/user_123/proj_456/`
- [ ] 测试音频 WebSocket 代理:`wscat -c ws://localhost:8087/computer/audio/user_123/proj_456/ws`
- [ ] 测试 IME WebSocket 代理:`wscat -c ws://localhost:8087/computer/ime/user_123/proj_456/`
- [ ] 验证容器 IP 解析正确性
- [ ] 验证 WebSocket 升级成功
- [ ] **端到端测试**
- [ ] 在浏览器中播放远程桌面音频
- [ ] 验证音频实时性(延迟 < 500ms
- [ ] 在浏览器中使用本地输入法输入中文
- [ ] 验证中文正确输入到远程桌面应用
### 4.4 Phase 4: 文档和优化
#### 任务清单
- [ ] **更新文档**
- [ ] 更新 `CLAUDE.md` 添加音频和 IME 架构说明
- [ ] 更新 API 文档说明新的代理路由
- [ ] 编写客户端集成示例代码
- [ ] **性能优化**
- [ ] 音频流延迟优化(目标 < 200ms
- [ ] 连接池优化(复用 WebSocket 连接)
- [ ] 容器 IP 缓存策略优化
- [ ] **错误处理**
- [ ] 添加容器不存在的友好错误提示
- [ ] 添加 WebSocket 断开自动重连机制
- [ ] 添加音频服务不可用的降级处理
---
## 5. 关键技术细节
### 5.1 WebSocket 升级处理
Pingora 自动处理 WebSocket 升级,无需手动处理 `Upgrade``Connection` 头:
```rust
// Pingora 会自动识别 WebSocket 升级请求
// 只需确保正确设置上游连接参数
peer.options.read_timeout = Some(Duration::from_secs(300)); // 长连接
peer.options.write_timeout = Some(Duration::from_secs(30));
```
### 5.2 音频流协议设计
**协议格式**
```
帧头: 1 byte
- 0x01: Opus 音频帧
- 0x02: 控制消息(预留)
- 0x03: 心跳消息(预留)
帧体: N bytes
- Opus 编码的音频数据PCM -> Opus, 48kHz, 2 channels
```
**优势**
- 简单高效,无需复杂解析
- 二进制传输,带宽占用小
- Opus 编码延迟低20ms 帧)
### 5.3 输入法透传安全性
**潜在风险**
- 恶意客户端可能发送恶意命令注入
- 特殊字符可能导致 X11 系统异常
- 超长文本可能导致内存溢出或拒绝服务
**防护措施**
```python
# ime_server.py
import re
def sanitize_text(text: str) -> str:
"""清理文本,防止命令注入和异常字符"""
# 1. 长度限制(从 10000 降低到 1000
if len(text) > 1000:
raise ValueError("Text too long (max 1000 chars)")
# 2. 过滤危险控制字符(保留合法的换行和制表符)
dangerous_chars = ['\x00', '\x1b'] # NULL 字符和 ESC 字符
if any(c in text for c in dangerous_chars):
raise ValueError("Text contains dangerous control characters")
# 3. 可选:只允许可打印字符和常见空白字符
# if not all(c.isprintable() or c in '\n\r\t' for c in text):
# raise ValueError("Text contains non-printable characters")
# 4. 可选:限制为 Unicode 基本多文种平面(防止异常字符)
# if any(ord(c) > 0xFFFF for c in text):
# raise ValueError("Text contains characters outside BMP")
return text
# 使用 '--' 参数分隔符,防止参数注入
subprocess.run([
'xdotool', 'type',
'--clearmodifiers', # 清除修饰键
'--delay', '10', # 字符间延迟 10ms
'--', sanitize_text(text) # 使用 '--' 防止参数注入
], env={'DISPLAY': ':0'})
```
**安全检查清单**
- ✅ 长度限制1000 字符)
- ✅ 危险字符过滤NULL, ESC
- ✅ 使用 `--` 参数分隔符
- ✅ xdotool 使用 `--clearmodifiers` 清除修饰键
- ✅ 可选:只允许可打印字符
- ✅ 可选Unicode 范围限制
### 5.4 容器端口映射策略
**不对外暴露端口**
```yaml
# docker-compose.yml (正确做法)
services:
agent-runner:
image: rcoder-agent-runner:latest
networks:
- agent-network
# ❌ 不暴露端口到宿主机
# ports:
# - "6080:6080"
# - "6089:6089"
# - "6090:6090"
# - "6091:6091"
networks:
agent-network:
driver: bridge
```
**通过内部网络访问**
```rust
// 容器间通过内部 IP 直接通信
let container_ip = "172.18.0.5"; // Docker 分配的内部 IP
let audio_ws_addr = format!("{}:6089", container_ip);
let audio_http_addr = format!("{}:6090", container_ip);
let ime_addr = format!("{}:6091", container_ip);
```
---
## 6. 性能指标
### 6.1 音频流性能目标
| 指标 | 目标值 | 说明 |
|------|--------|------|
| 音频延迟 | < 200ms | 从音频产生到浏览器播放的端到端延迟 |
| 带宽占用 | ~128 Kbps | Opus 编码48kHz, 2 channels |
| CPU 占用 | < 5% | 容器内 audio_server.py 的 CPU 使用率 |
| 并发连接 | 100+ | 单个 Pingora 实例支持的音频流连接数 |
### 6.2 IME 输入性能目标
| 指标 | 目标值 | 说明 |
|------|--------|------|
| 输入延迟 | < 100ms | 从客户端发送到远程桌面显示的延迟 |
| 吞吐量 | 1000+ 字符/秒 | 输入法透传的最大吞吐量 |
| 可靠性 | 99.9% | 文本传输成功率 |
---
## 7. 风险评估
### 7.1 技术风险
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| WebSocket 连接不稳定 | 音频/输入中断 | 实现自动重连机制 + 心跳检测 |
| 音频延迟过高 | 用户体验差 | 优化编码参数 + 网络 QoS |
| 容器 IP 变化 | 代理失败 | 实现 IP 缓存自动更新机制 |
| xdotool 输入失败 | 中文输入失效 | 提供剪贴板粘贴备用方案 |
### 7.2 安全风险
| 风险 | 影响 | 缓解措施 |
|------|------|----------|
| 命令注入攻击 | 容器被攻击 | 使用 `--` 参数分隔符 + 输入验证 |
| 未授权访问 | 数据泄露 | 基于 `user_id` 的访问控制 |
| WebSocket 劫持 | 会话劫持 | 使用 HTTPS + 会话令牌验证 |
---
## 8. 未来优化方向
### 8.1 音频质量优化
- **自适应比特率**:根据网络状况动态调整 Opus 比特率
- **多声道支持**:支持 5.1 环绕声
- **音频增强**:噪音抑制、回声消除
### 8.2 输入法增强
- **双向同步**:支持远程桌面的输入状态同步到客户端
- **快捷键透传**:支持 Ctrl+C、Ctrl+V 等快捷键
- **富文本支持**:支持输入带格式的文本
### 8.3 架构优化
- **端口统一**:将 VNC、Audio、IME 合并到单个 WebSocket 连接(多路复用)
- **P2P 模式**:客户端与容器直接建立 WebRTC 连接,降低延迟
- **边缘节点**:部署边缘 Pingora 节点,降低网络延迟
---
## 9. 总结
本技术方案通过以下方式解决了虚拟桌面的音频和输入法问题:
### 9.1 核心特性
**音频流传输**
- 基于 pcmflux + Opus 编码的实时音频流
- 通过 Pingora 代理实现透明转发
- 低延迟(< 200ms、低带宽128 Kbps
**输入法透传**
- 客户端使用本地输入法输入中文
- 通过 WebSocket 发送到容器
- 使用 xdotool 注入到远程桌面
**架构一致性**
- 复用 VNC 代理的容器 IP 解析机制
- 统一的路由规则和代理逻辑
- 完全隐藏子容器端口
### 9.2 实现路径
1. **路由层**:在 `router.rs` 中添加 `AudioProxy``ImeProxy` 路由
2. **代理层**:在 `service.rs` 中实现音频和 IME 的代理逻辑
3. **容器端**:确保 `audio_server.py``ime_server.py` 正常运行
4. **客户端**:集成音频播放器和输入法客户端
### 9.3 关键优势
- **零端口暴露**:所有服务通过 Pingora 内部路由访问
- **高性能**Opus 编码 + WebSocket 长连接
- **低延迟**:音频 < 200ms输入 < 100ms
- **易维护**:复用现有架构,代码改动最小
---
## 10. 测试页面实现
### 10.1 完整测试页面
为了方便测试 VNC、音频流和输入法透传功能我们提供了一个集成测试页面`docker/vnc-test.html`
**功能特性**
1. **VNC 连接管理**:支持 RCoder 代理模式和直接端口模式
2. **音频流播放**:实时音频流接收和播放,支持音量控制
3. **输入法透传**:使用本地输入法输入中文到远程桌面
4. **可视化反馈**连接状态显示、音频可视化、IME 状态提示
### 10.2 页面结构
```html
<!doctype html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<title>VNC + 音频 + 输入法 测试页面</title>
<style>
/* 样式包含:
* - 响应式布局
* - 状态指示器(连接/断开/连接中)
* - 音频可视化动画
* - 输入法状态提示
*/
</style>
</head>
<body>
<!-- 1. 配置区域 -->
<div class="header">
<h1>🔌 VNC + 🎵 音频 + ⌨️ 输入法 测试</h1>
<!-- 模式切换:代理模式 / 直接端口模式 -->
<div class="mode-toggle">...</div>
<!-- RCoder 代理模式配置 -->
<div class="config-section proxy-mode">
<input id="baseUrlInput" placeholder="http://127.0.0.1:8088" />
<input id="userIdInput" placeholder="user_123" />
<input id="projectIdInput" placeholder="project_id" />
</div>
<!-- 直接端口模式配置 -->
<div class="config-section direct-mode">
<input id="portInput" placeholder="VNC 端口 (6080)" />
<input id="audioPortInput" placeholder="音频端口 (6089)" />
<input id="imePortInput" placeholder="IME 端口 (6091)" />
</div>
<!-- VNC 连接控制 -->
<div class="controls">
<button onclick="connectVNC()">连接 VNC</button>
<button onclick="disconnectVNC()">断开 VNC</button>
<div class="status" id="vncStatus">VNC 未连接</div>
</div>
</div>
<!-- 2. 音频控制面板 -->
<div class="feature-panel">
<h3>🎵 音频流</h3>
<button onclick="toggleAudio()">启动音频</button>
<input type="range" id="volumeSlider" min="0" max="100" value="80" />
<div class="audio-visualizer">
<!-- 音频可视化条 -->
</div>
<div class="status" id="audioStatus">音频未连接</div>
</div>
<!-- 3. 输入法控制面板 -->
<div class="feature-panel">
<h3>⌨️ 输入法透传</h3>
<button onclick="toggleIME()">启动输入法</button>
<div class="ime-status">未激活</div>
</div>
<!-- 4. VNC 显示区域 -->
<div class="vnc-container">
<iframe id="vncFrame"></iframe>
</div>
<!-- 5. 隐藏的输入代理框(用于捕获输入法输入) -->
<input type="text" id="imeInput" class="ime-input" />
<!-- 引入 Opus 解码器库 -->
<script src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.9/build/opus-decoder.min.js"></script>
<script>
// JavaScript 逻辑(详见下文)
</script>
</body>
</html>
```
**Opus 解码器安装选项**
1. **CDN 引入**(推荐,用于快速测试):
```html
<script src="https://cdn.jsdelivr.net/npm/opus-decoder@0.7.9/build/opus-decoder.min.js"></script>
```
2. **NPM 安装**(用于生产环境):
```bash
npm install opus-decoder
```
```javascript
import OpusDecoder from 'opus-decoder';
```
3. **本地文件**(离线环境):
```bash
npm install opus-decoder
cp node_modules/opus-decoder/build/opus-decoder.min.js ./public/
```
```html
<script src="/opus-decoder.min.js"></script>
```
### 10.3 核心 JavaScript 实现
#### 10.3.1 音频流连接
```javascript
// 全局状态
let audioContext = null;
let audioWs = null;
let audioConnected = false;
let audioGainNode = null;
// 构建音频 WebSocket URL
function buildAudioWsUrl() {
const mode = document.querySelector('input[name="connectionMode"]:checked').value;
if (mode === "proxy") {
// RCoder 代理模式
const baseUrl = document.getElementById("baseUrlInput").value.trim()
.replace(/^http/, "ws").replace(/\/+$/, "");
const userId = document.getElementById("userIdInput").value.trim();
const projectId = document.getElementById("projectIdInput").value.trim();
return `${baseUrl}/computer/audio/${userId}/${projectId}/ws`;
} else {
// 直接端口模式
const port = document.getElementById("audioPortInput").value.trim();
return `ws://localhost:${port}/ws`;
}
}
// 连接音频流
async function connectAudio() {
const wsUrl = buildAudioWsUrl();
if (!wsUrl) return;
try {
// 初始化 Web Audio API
if (!audioContext) {
audioContext = new (window.AudioContext || window.webkitAudioContext)({
sampleRate: 48000
});
audioGainNode = audioContext.createGain();
audioGainNode.gain.value = 0.8;
audioGainNode.connect(audioContext.destination);
}
// 恢复 AudioContext浏览器安全策略要求用户交互
if (audioContext.state === "suspended") {
await audioContext.resume();
}
// 连接 WebSocket
audioWs = new WebSocket(wsUrl);
audioWs.binaryType = "arraybuffer";
audioWs.onopen = () => {
console.log("[Audio] WebSocket 已连接");
audioConnected = true;
updateAudioStatus("connected", "音频已连接");
};
audioWs.onmessage = async (event) => {
try {
const data = new Uint8Array(event.data);
// 检查协议头 (0x01 表示 Opus 音频)
if (data[0] === 0x01) {
const opusData = data.slice(1);
// 这里需要 Opus 解码器(如 opus-decoder.js
await playAudioChunk(opusData);
}
} catch (err) {
console.error("[Audio] 处理音频数据失败:", err);
}
};
audioWs.onerror = (err) => {
console.error("[Audio] WebSocket 错误:", err);
updateAudioStatus("disconnected", "音频连接失败");
};
audioWs.onclose = () => {
console.log("[Audio] WebSocket 已关闭");
audioConnected = false;
updateAudioStatus("disconnected", "音频已断开");
};
} catch (err) {
console.error("[Audio] 连接失败:", err);
alert("音频启动失败: " + err.message);
}
}
// 播放音频块(完整的 Opus 解码实现)
async function playAudioChunk(opusData) {
try {
// 1. 解码 Opus -> PCM (Float32Array, 48kHz, 立体声)
// 使用 opus-decoder.js: https://github.com/Rillke/opus-decoder
const decoder = new OpusDecoder({
rate: 48000,
channels: 2
});
const pcmData = decoder.decode(opusData);
// 2. 创建 AudioBuffer (48kHz, 2 channels)
const samplesPerChannel = pcmData.length / 2; // 立体声 = 2 channels
const audioBuffer = audioContext.createBuffer(
2, // 2 channels (立体声)
samplesPerChannel,
48000 // 48kHz 采样率
);
// 3. 填充 AudioBuffer (分离左右声道)
const leftChannel = audioBuffer.getChannelData(0);
const rightChannel = audioBuffer.getChannelData(1);
for (let i = 0; i < samplesPerChannel; i++) {
leftChannel[i] = pcmData[i * 2];
rightChannel[i] = pcmData[i * 2 + 1];
}
// 4. 播放音频
const source = audioContext.createBufferSource();
source.buffer = audioBuffer;
source.connect(audioGainNode);
source.start();
// 可选:可视化音频
updateAudioVisualizer(pcmData);
} catch (err) {
console.error("[Audio] 解码或播放失败:", err);
}
}
// 音频可视化更新(示例)
function updateAudioVisualizer(pcmData) {
const visualizer = document.getElementById('audioVisualizer');
if (!visualizer || visualizer.style.display === 'none') return;
// 计算音量RMS
let sum = 0;
for (let i = 0; i < pcmData.length; i++) {
sum += pcmData[i] * pcmData[i];
}
const rms = Math.sqrt(sum / pcmData.length);
// 更新可视化条高度(根据音量动态调整)
const bars = visualizer.querySelectorAll('span');
bars.forEach((bar, index) => {
const height = 5 + (rms * 20 * (1 + index * 0.2));
bar.style.height = `${Math.min(height, 20)}px`;
});
}
// 设置音量
function setVolume(value) {
if (audioGainNode) {
audioGainNode.gain.value = value / 100;
}
document.getElementById("volumeValue").textContent = value + "%";
}
```
#### 10.3.2 输入法透传实现
```javascript
// 全局状态
let imeWs = null;
let imeConnected = false;
let imeEnabled = false;
// 构建 IME WebSocket URL
function buildIMEWsUrl() {
const mode = document.querySelector('input[name="connectionMode"]:checked').value;
if (mode === "proxy") {
const baseUrl = document.getElementById("baseUrlInput").value.trim()
.replace(/^http/, "ws").replace(/\/+$/, "");
const userId = document.getElementById("userIdInput").value.trim();
const projectId = document.getElementById("projectIdInput").value.trim();
return `${baseUrl}/computer/ime/${userId}/${projectId}/`;
} else {
const port = document.getElementById("imePortInput").value.trim();
return `ws://localhost:${port}/`;
}
}
// 连接 IME 服务
function connectIME() {
const wsUrl = buildIMEWsUrl();
if (!wsUrl) return;
try {
console.log("连接 IME 服务:", wsUrl);
// 连接 WebSocket
imeWs = new WebSocket(wsUrl);
imeWs.onopen = () => {
console.log("[IME] WebSocket 已连接");
imeConnected = true;
imeEnabled = true;
// 更新 UI
document.getElementById("imeBtn").textContent = "停止输入法";
document.getElementById("imeStatus").textContent =
"已激活 - 点击 VNC 区域开始输入";
// 设置输入事件监听
setupIMEListeners();
};
imeWs.onmessage = (event) => {
try {
const response = JSON.parse(event.data);
if (response.status === "ok") {
console.log("[IME] 文本发送成功");
} else {
console.error("[IME] 服务器错误:", response.message);
}
} catch (err) {
console.error("[IME] 响应解析失败:", err);
}
};
imeWs.onerror = (err) => {
console.error("[IME] WebSocket 错误:", err);
alert("IME 连接失败,请检查服务是否运行");
};
imeWs.onclose = () => {
console.log("[IME] WebSocket 已关闭");
imeConnected = false;
imeEnabled = false;
document.getElementById("imeBtn").textContent = "启动输入法";
};
} catch (err) {
console.error("[IME] 连接失败:", err);
alert("IME 启动失败: " + err.message);
}
}
// 设置输入法监听器
function setupIMEListeners() {
const vncContainer = document.getElementById("vncContainer");
const imeInput = document.getElementById("imeInput");
// 点击 VNC 区域时聚焦到隐藏输入框
vncContainer.addEventListener("click", () => {
if (imeEnabled) {
imeInput.focus();
console.log("[IME] 输入框已聚焦");
}
});
// 监听输入法完成事件
imeInput.addEventListener("compositionend", (event) => {
const text = event.data;
if (text && text.length > 0 && imeConnected) {
console.log("[IME] 输入完成:", text);
sendTextToRemote(text);
// 清空输入框
imeInput.value = "";
}
});
}
// 发送文本到远程桌面
function sendTextToRemote(text) {
if (!imeConnected || !imeWs) {
console.warn("[IME] 未连接,无法发送文本");
return;
}
const message = JSON.stringify({
type: "text",
text: text,
method: "xdotool" // 或 "clipboard"
});
imeWs.send(message);
console.log("[IME] 发送文本:", text);
}
```
### 10.4 使用说明
#### 10.4.1 RCoder 代理模式
1. **配置连接参数**
- RCoder 服务地址:`http://127.0.0.1:8088`
- User ID`user_123`
- Project ID从聊天响应中获取
2. **连接 VNC**
- 点击"连接 VNC"按钮
- 等待 iframe 加载完成
3. **启动音频**
- 点击"启动音频"按钮
- 在远程桌面播放视频/音乐
- 调节音量滑块控制音量
4. **启动输入法**
- 点击"启动输入法"按钮
- 点击 VNC 画面区域获得焦点
- 使用本地输入法输入中文
- 输入完成后自动发送到远程桌面
#### 10.4.2 直接端口模式
适用于容器端口直接映射到宿主机的场景:
1. **查找端口映射**
```bash
docker port <container_name>
# 输出示例:
# 6080/tcp -> 0.0.0.0:50001
# 6089/tcp -> 0.0.0.0:50002
# 6091/tcp -> 0.0.0.0:50003
```
2. **配置端口**
- VNC 端口:`50001`
- 音频 WebSocket 端口:`50002`
- IME 端口:`50003`
3. **连接和使用**:同 RCoder 代理模式
### 10.5 故障排查
#### 10.5.1 音频无声
**问题**:音频流连接成功但听不到声音
**排查步骤**
1. 检查浏览器控制台是否有错误
2. 确认 AudioContext 已恢复(`audioContext.state === "running"`
3. 检查音量设置(默认 80%
4. 确认容器内 PulseAudio 服务运行正常
5. 确认 `audio_server.py` 正在采集音频
**解决方案**
```javascript
// 手动恢复 AudioContext
if (audioContext.state === "suspended") {
audioContext.resume().then(() => {
console.log("AudioContext 已恢复");
});
}
```
#### 10.5.2 输入法无响应
**问题**:输入中文后不显示在远程桌面
**排查步骤**
1. 确认 IME WebSocket 已连接(查看控制台日志)
2. 确认点击了 VNC 区域获得焦点
3. 确认远程桌面有可输入的窗口(如文本编辑器)
4. 检查容器内 `xdotool` 是否可用:`docker exec <container> which xdotool`
**解决方案**
```javascript
// 测试发送
sendTextToRemote("测试文本");
// 切换到剪贴板模式
const message = JSON.stringify({
type: "text",
text: text,
method: "clipboard" // 使用剪贴板粘贴
});
```
#### 10.5.3 WebSocket 连接失败
**问题**`WebSocket connection failed`
**排查步骤**
1. 检查 URL 格式是否正确
2. 确认 Pingora 代理服务运行正常
3. 确认容器内服务监听正确端口
4. 检查网络连通性:`curl -v <websocket_url>`
**解决方案**
```bash
# 检查代理服务状态
curl http://127.0.0.1:8088/health
# 检查容器服务
docker exec <container> netstat -tuln | grep -E "6089|6091"
# 查看代理日志
tail -f /path/to/pingora.log
```
### 10.6 性能优化建议
1. **音频延迟优化**
- 使用 `AudioWorklet` 替代 `ScriptProcessorNode`
- 调整 Opus 帧长度(默认 20ms
- 使用 WebAssembly Opus 解码器
2. **输入法响应优化**
- 批量发送字符debounce
- 使用二进制协议替代 JSON
- 预连接 WebSocket
3. **UI 优化**
- 使用 CSS `will-change` 优化动画
- 虚拟化长列表(如日志)
- 懒加载音频可视化
---
## 11. 实现检查清单和优化建议
### 11.1 代码实现检查清单
#### Phase 1: 路由层实现 ✅
- [x] 在 `RouteType` 枚举中添加 `AudioProxy` 和 `ImeProxy`
- [x] 注册音频路由:`/computer/audio/{user_id}/{project_id}/{*path}`
- [x] 注册 IME 路由:`/computer/ime/{user_id}/{project_id}/{*path}`
- [x] 添加路由参数提取逻辑(路径标准化)
#### Phase 2: 代理逻辑实现 ✅
- [x] 实现音频代理 `handle_audio_request()` 和 `handle_audio_upstream()`
- [x] 实现 IME 代理 `handle_ime_request()` 和 `handle_ime_upstream()`
- [x] 修复音频路由端口判断逻辑(空路径默认 HTTP 6090
- [x] 优化 WebSocket 超时配置(`read_timeout = None`,支持长连接)
- [x] 添加容器 IP 解析逻辑(基于 `user_id`
#### Phase 3: 安全性加固 ✅
- [x] 加强 IME 输入验证(长度限制 1000 字符)
- [x] 过滤危险控制字符NULL, ESC
- [x] 使用 `--` 参数分隔符防止命令注入
- [x] 添加安全检查清单
#### Phase 4: 前端实现 ✅
- [x] 完成 Opus 解码器集成opus-decoder.js
- [x] 实现音频流播放逻辑PCM 分离声道)
- [x] 添加音频可视化功能RMS 音量计算)
- [x] 集成输入法透传客户端
### 11.2 技术方案修复总结
| 问题类别 | 问题描述 | 修复方案 | 状态 |
|---------|---------|---------|------|
| 路由逻辑 | 音频路由空路径未明确处理 | 添加路径标准化,空路径默认 HTTP 6090 | ✅ 已修复 |
| 超时配置 | WebSocket 300 秒超时太短 | 设置 `read_timeout = None`,支持长连接 | ✅ 已修复 |
| 安全验证 | IME 长度限制 10000 太宽松 | 降低到 1000 字符,添加危险字符过滤 | ✅ 已修复 |
| 前端实现 | Opus 解码逻辑未实现 | 完整实现 opus-decoder.js 集成 | ✅ 已修复 |
| 文档说明 | 容器 IP 键值策略不明确 | 明确使用 `user_id`ComputerAgentRunner 业务场景) | ✅ 已澄清 |
### 11.3 架构设计说明
#### 容器 IP 解析策略
```rust
// ✅ 正确:使用 user_id 作为键
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone())
.ok_or_else(|| /* 容器不存在 */)?;
// 说明:
// - ComputerAgentRunner 业务场景:每个 user_id 对应一个容器
// - project_id 用于日志追踪和路径匹配,不参与 IP 查询
// - vnc_backends 类型Arc<DashMap<String, String>> (user_id -> container_ip)
```
#### 音频路由端口规则
```rust
// ✅ 正确:明确的端口路由规则
let (target_port, target_path) = if remaining_path == "ws" || remaining_path.starts_with("ws/") {
(6089_u16, format!("/{}", remaining_path)) // WebSocket 音频流
} else {
let path = if remaining_path.is_empty() { "/" } else { remaining_path };
(6090_u16, format!("/{}", path.trim_start_matches('/'))) // HTTP 静态文件
};
// 路由示例:
// /computer/audio/user_123/proj_456/ -> 容器IP:6090/ (HTTP)
// /computer/audio/user_123/proj_456/ws -> 容器IP:6089/ws (WebSocket)
```
#### WebSocket 长连接配置
```rust
// ✅ 正确:音频流和 IME 长连接不需要 read_timeout
peer.options.connection_timeout = Some(Duration::from_secs(10));
peer.options.read_timeout = None; // 无限等待(支持数小时长连接)
peer.options.write_timeout = Some(Duration::from_secs(30));
peer.options.total_connection_timeout = Some(Duration::from_secs(15));
peer.options.idle_timeout = Some(Duration::from_secs(3600)); // 1 小时空闲超时
```
### 11.4 DashMap 并发安全提醒
**推荐用法**(使用 entry API
```rust
// ✅ 推荐:使用 entry API原子性操作
self.vnc_backends.entry(user_id.clone())
.or_insert_with(|| {
tracing::info!("注册新容器: user_id={}, container_ip={}", user_id, container_ip);
container_ip.clone()
});
// ✅ 安全只读操作get不会死锁
let container_ip = self.vnc_backends.get(user_id)
.map(|entry| entry.value().clone());
```
**避免用法**(可能导致死锁):
```rust
// ❌ 避免:先 get 再 insert非原子性可能死锁
if self.vnc_backends.get(key).is_none() {
self.vnc_backends.insert(key, value);
}
```
### 11.5 实现优先级建议
| 优先级 | 任务 | 复杂度 | 预计工作量 |
|--------|------|--------|-----------|
| **P0** | 路由层实现router.rs | 低 | 1-2 小时 |
| **P0** | 代理逻辑实现service.rs | 中 | 3-4 小时 |
| **P1** | IME 安全验证加强 | 低 | 1 小时 |
| **P1** | 前端 Opus 解码集成 | 中 | 2-3 小时 |
| **P2** | 集成测试(代理 + 容器) | 中 | 2-3 小时 |
| **P3** | 性能优化和压力测试 | 高 | 4-6 小时 |
### 11.6 测试验证建议
1. **单元测试**
```bash
cargo test -p rcoder-proxy test_audio_route_matching
cargo test -p rcoder-proxy test_ime_route_matching
```
2. **集成测试**(使用 vnc-test.html
```bash
# 1. 启动 RCoder 服务
cargo run --bin rcoder -- --port 8088 --enable-proxy
# 2. 打开测试页面
open docker/vnc-test.html
# 3. 验证功能
- VNC 连接成功
- 音频流播放正常(延迟 < 500ms
- 输入法透传正常(中文输入显示在远程桌面)
```
3. **容器端服务验证**
```bash
# 进入容器检查服务状态
docker exec <container> netstat -tuln | grep -E "6089|6090|6091"
docker exec <container> ps aux | grep -E "audio_server|ime_server"
```
---
**文档版本**: v2.0
**更新日期**: 2026-01-06
**作者**: Claude (Sonnet 4.5)
**变更说明**:
- 修复音频路由端口判断逻辑
- 优化 WebSocket 超时配置(支持长连接)
- 加强 IME 安全验证
- 完整实现前端 Opus 解码
- 澄清容器 IP 解析策略user_id 键值)
- 添加实现检查清单和测试建议