Files
qiming/qiming-rcoder/docker/rcoder-agent-runner/ime_passthrough.js
2026-06-01 13:54:52 +08:00

316 lines
9.2 KiB
JavaScript
Raw Permalink 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.
/**
* ime_passthrough.js - 本地输入法透传前端脚本
*
* 使用方法:
* 1. 将此文件放到 noVNC 目录中
* 2. 在 vnc.html 的 </body> 前添加:
* <script src="ime_passthrough.js"></script>
*
* 工作原理:
* 1. 创建一个隐藏的输入框,用于捕获用户的输入法输入
* 2. 当用户点击 VNC 画面时,焦点切换到隐藏输入框
* 3. 用户使用本地输入法(如搜狗输入法)输入中文
* 4. 输入完成后,通过 WebSocket 发送到 IME 服务器
* 5. 服务器使用 xdotool 将文本输入到远程桌面
*/
(function () {
'use strict';
// 配置
const CONFIG = {
// IME 服务端口(与 ime_server.py 中的端口一致)
IME_PORT: 6091,
// 重连间隔(毫秒)
RECONNECT_INTERVAL: 3000,
// 心跳间隔(毫秒)
HEARTBEAT_INTERVAL: 30000,
// 是否启用调试日志
DEBUG: false
};
// 日志函数
function log(level, message) {
if (level === 'DEBUG' && !CONFIG.DEBUG) return;
const timestamp = new Date().toISOString();
console.log(`[${timestamp}] [${level}] [IME] ${message}`);
}
// 状态变量
let imeWebSocket = null;
let imeInput = null;
let isComposing = false;
let reconnectTimer = null;
let heartbeatTimer = null;
/**
* 创建隐藏的输入框
*/
function createImeInput() {
if (imeInput) return imeInput;
imeInput = document.createElement('textarea');
imeInput.id = 'ime-capture';
imeInput.setAttribute('autocomplete', 'off');
imeInput.setAttribute('autocorrect', 'off');
imeInput.setAttribute('autocapitalize', 'off');
imeInput.setAttribute('spellcheck', 'false');
imeInput.style.cssText = `
position: fixed;
left: -9999px;
top: 50%;
width: 1px;
height: 1px;
opacity: 0;
pointer-events: none;
z-index: -1;
`;
document.body.appendChild(imeInput);
// 监听输入法事件
imeInput.addEventListener('compositionstart', onCompositionStart);
imeInput.addEventListener('compositionend', onCompositionEnd);
imeInput.addEventListener('input', onInput);
imeInput.addEventListener('keydown', onKeyDown);
log('INFO', 'IME input element created');
return imeInput;
}
/**
* 连接到 IME WebSocket 服务
*/
function connectWebSocket() {
if (imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN) {
return;
}
// 构建 WebSocket URL使用当前页面的主机名
const wsUrl = `ws://${location.hostname}:${CONFIG.IME_PORT}`;
log('INFO', `Connecting to IME server: ${wsUrl}`);
try {
imeWebSocket = new WebSocket(wsUrl);
imeWebSocket.onopen = function () {
log('INFO', 'Connected to IME server');
clearReconnectTimer();
startHeartbeat();
};
imeWebSocket.onclose = function (event) {
log('INFO', `Disconnected from IME server (code: ${event.code})`);
stopHeartbeat();
scheduleReconnect();
};
imeWebSocket.onerror = function (error) {
log('ERROR', 'WebSocket error');
// onclose 会自动触发
};
imeWebSocket.onmessage = function (event) {
try {
const data = JSON.parse(event.data);
if (data.status === 'ok') {
log('DEBUG', 'Text sent successfully');
} else if (data.status === 'error') {
log('ERROR', `Server error: ${data.message}`);
} else if (data.type === 'pong') {
log('DEBUG', 'Heartbeat pong received');
}
} catch (e) {
log('ERROR', `Failed to parse response: ${e}`);
}
};
} catch (e) {
log('ERROR', `Failed to connect: ${e}`);
scheduleReconnect();
}
}
/**
* 发送文本到 IME 服务器
*/
function sendText(text, method = 'xdotool') {
if (!text) return;
if (!imeWebSocket || imeWebSocket.readyState !== WebSocket.OPEN) {
log('WARN', 'WebSocket not connected, text not sent');
return;
}
const message = JSON.stringify({
type: 'text',
text: text,
method: method
});
try {
imeWebSocket.send(message);
log('DEBUG', `Sent text: ${text.substring(0, 50)}${text.length > 50 ? '...' : ''}`);
} catch (e) {
log('ERROR', `Failed to send text: ${e}`);
}
}
/**
* 输入法开始组合
*/
function onCompositionStart(e) {
isComposing = true;
log('DEBUG', 'Composition started');
}
/**
* 输入法完成组合
*/
function onCompositionEnd(e) {
isComposing = false;
const text = e.data;
if (text) {
log('DEBUG', `Composition ended: ${text}`);
sendText(text);
}
// 清空输入框
setTimeout(() => {
if (imeInput) imeInput.value = '';
}, 10);
}
/**
* 普通输入(非输入法)
*/
function onInput(e) {
// 如果正在使用输入法组合,不处理
if (isComposing) return;
const text = imeInput.value;
if (text) {
log('DEBUG', `Direct input: ${text}`);
sendText(text);
imeInput.value = '';
}
}
/**
* 键盘按下事件
* 用于处理特殊键(如回车、退格等)
*/
function onKeyDown(e) {
// 如果正在使用输入法组合,让输入法处理
if (isComposing) return;
// 这些键由 VNC 处理,不需要 IME 透传
// 用户可以点击 VNC 画面来让 VNC 处理键盘
}
/**
* 定时重连
*/
function scheduleReconnect() {
if (reconnectTimer) return;
log('INFO', `Will reconnect in ${CONFIG.RECONNECT_INTERVAL}ms`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
connectWebSocket();
}, CONFIG.RECONNECT_INTERVAL);
}
function clearReconnectTimer() {
if (reconnectTimer) {
clearTimeout(reconnectTimer);
reconnectTimer = null;
}
}
/**
* 心跳保活
*/
function startHeartbeat() {
stopHeartbeat();
heartbeatTimer = setInterval(() => {
if (imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN) {
try {
imeWebSocket.send(JSON.stringify({ type: 'ping' }));
log('DEBUG', 'Heartbeat ping sent');
} catch (e) {
log('ERROR', `Heartbeat failed: ${e}`);
}
}
}, CONFIG.HEARTBEAT_INTERVAL);
}
function stopHeartbeat() {
if (heartbeatTimer) {
clearInterval(heartbeatTimer);
heartbeatTimer = null;
}
}
/**
* 初始化 IME 透传功能
*/
function init() {
log('INFO', 'Initializing IME passthrough...');
// 创建隐藏输入框
createImeInput();
// 连接 WebSocket
connectWebSocket();
// 监听 VNC 画面点击,切换焦点到隐藏输入框
// 需要找到 noVNC 的 screen 元素
const setupClickHandler = () => {
// noVNC 可能使用不同的元素 ID
const vncScreen = document.getElementById('screen') ||
document.querySelector('canvas') ||
document.querySelector('.noVNC_canvas');
if (vncScreen) {
vncScreen.addEventListener('click', () => {
if (imeInput) {
imeInput.focus();
log('DEBUG', 'IME input focused');
}
});
log('INFO', 'VNC screen click handler attached');
} else {
log('WARN', 'VNC screen element not found, will retry');
setTimeout(setupClickHandler, 1000);
}
};
// 等待 DOM 完全加载
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', setupClickHandler);
} else {
setupClickHandler();
}
log('INFO', 'IME passthrough initialized');
log('INFO', 'Usage: Click on VNC screen, then type using your local input method');
}
// 页面加载后初始化
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
// 导出到全局(方便调试)
window.IMEPassthrough = {
sendText: sendText,
connect: connectWebSocket,
isConnected: () => imeWebSocket && imeWebSocket.readyState === WebSocket.OPEN,
setDebug: (enabled) => { CONFIG.DEBUG = enabled; }
};
})();