添加qiming-rcoder模块
This commit is contained in:
260
qiming-rcoder/docker/rcoder-agent-runner/ime_server.py
Normal file
260
qiming-rcoder/docker/rcoder-agent-runner/ime_server.py
Normal file
@@ -0,0 +1,260 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
ime_server.py - 本地输入法透传服务
|
||||
接收浏览器发送的文本,使用 xdotool 输入到当前焦点窗口
|
||||
|
||||
工作原理:
|
||||
1. 用户在浏览器中使用本地输入法(如搜狗输入法)输入中文
|
||||
2. 前端通过 WebSocket 发送完整的文本到此服务
|
||||
3. 此服务使用 xdotool 将文本输入到远程桌面的当前焦点窗口
|
||||
|
||||
端口: 6091 (可通过 IME_PORT 环境变量配置)
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import subprocess
|
||||
import os
|
||||
import signal
|
||||
import sys
|
||||
from datetime import datetime
|
||||
|
||||
# 尝试导入 websockets,如果失败则提供友好提示
|
||||
try:
|
||||
from websockets.server import serve
|
||||
from websockets.exceptions import ConnectionClosed
|
||||
except ImportError:
|
||||
print("[IME] Error: websockets module not found. Install with: pip3 install websockets")
|
||||
sys.exit(1)
|
||||
|
||||
|
||||
def log(level: str, message: str):
|
||||
"""格式化日志输出"""
|
||||
timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
|
||||
print(f"[{timestamp}] [{level}] [IME] {message}", flush=True)
|
||||
|
||||
|
||||
def sanitize_text(text: str) -> str:
|
||||
"""
|
||||
清理文本,防止命令注入和异常字符
|
||||
|
||||
安全检查:
|
||||
1. 长度限制 (1000 字符)
|
||||
2. 过滤危险控制字符 (NULL, ESC)
|
||||
3. 使用 -- 参数分隔符防止注入
|
||||
|
||||
Args:
|
||||
text: 待验证的文本
|
||||
|
||||
Returns:
|
||||
清理后的文本
|
||||
|
||||
Raises:
|
||||
ValueError: 如果文本包含危险内容
|
||||
"""
|
||||
# 1. 长度限制
|
||||
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")
|
||||
|
||||
return text
|
||||
|
||||
|
||||
def type_text_xdotool(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
使用 xdotool 输入文本
|
||||
|
||||
Args:
|
||||
text: 要输入的文本
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
try:
|
||||
env = {**os.environ, 'DISPLAY': ':0'}
|
||||
|
||||
# 使用 xdotool type 输入文本
|
||||
# --clearmodifiers: 清除修饰键状态(避免 Ctrl/Alt 等键干扰)
|
||||
# --delay: 每个字符之间的延迟(毫秒),帮助应用程序处理
|
||||
result = subprocess.run(
|
||||
['xdotool', 'type', '--clearmodifiers', '--delay', '10', '--', text],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=10
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
else:
|
||||
return False, result.stderr.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "xdotool timeout"
|
||||
except FileNotFoundError:
|
||||
return False, "xdotool not found"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
def type_text_clipboard(text: str) -> tuple[bool, str]:
|
||||
"""
|
||||
备用方案: 通过剪贴板粘贴文本
|
||||
当 xdotool type 对某些字符不支持时使用
|
||||
|
||||
Args:
|
||||
text: 要输入的文本
|
||||
|
||||
Returns:
|
||||
(success, error_message)
|
||||
"""
|
||||
try:
|
||||
env = {**os.environ, 'DISPLAY': ':0'}
|
||||
|
||||
# 1. 将文本写入剪贴板
|
||||
process = subprocess.Popen(
|
||||
['xclip', '-selection', 'clipboard'],
|
||||
stdin=subprocess.PIPE,
|
||||
env=env
|
||||
)
|
||||
process.communicate(input=text.encode('utf-8'), timeout=5)
|
||||
|
||||
if process.returncode != 0:
|
||||
return False, "Failed to copy to clipboard"
|
||||
|
||||
# 2. 模拟 Ctrl+V 粘贴
|
||||
result = subprocess.run(
|
||||
['xdotool', 'key', '--clearmodifiers', 'ctrl+v'],
|
||||
env=env,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=5
|
||||
)
|
||||
|
||||
if result.returncode == 0:
|
||||
return True, ""
|
||||
else:
|
||||
return False, result.stderr.strip()
|
||||
|
||||
except subprocess.TimeoutExpired:
|
||||
return False, "Clipboard operation timeout"
|
||||
except FileNotFoundError as e:
|
||||
return False, f"Required tool not found: {e}"
|
||||
except Exception as e:
|
||||
return False, str(e)
|
||||
|
||||
|
||||
async def handle_client(websocket):
|
||||
"""处理来自前端的 WebSocket 连接"""
|
||||
client_addr = websocket.remote_address
|
||||
log("INFO", f"Client connected: {client_addr}")
|
||||
|
||||
try:
|
||||
async for message in websocket:
|
||||
try:
|
||||
data = json.loads(message)
|
||||
msg_type = data.get('type', '')
|
||||
|
||||
if msg_type == 'text':
|
||||
text = data.get('text', '')
|
||||
method = data.get('method', 'xdotool') # 默认使用 xdotool
|
||||
|
||||
if not text:
|
||||
await websocket.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'Empty text'
|
||||
}))
|
||||
continue
|
||||
|
||||
# 安全验证
|
||||
try:
|
||||
text = sanitize_text(text)
|
||||
except ValueError as e:
|
||||
log("WARN", f"Text validation failed: {e}")
|
||||
await websocket.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': str(e)
|
||||
}))
|
||||
continue
|
||||
|
||||
# 根据方法选择输入方式
|
||||
if method == 'clipboard':
|
||||
success, error = type_text_clipboard(text)
|
||||
else:
|
||||
success, error = type_text_xdotool(text)
|
||||
|
||||
if success:
|
||||
log("INFO", f"Typed ({method}): {text[:50]}{'...' if len(text) > 50 else ''}")
|
||||
await websocket.send(json.dumps({'status': 'ok'}))
|
||||
else:
|
||||
log("ERROR", f"Failed to type: {error}")
|
||||
await websocket.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': error
|
||||
}))
|
||||
|
||||
elif msg_type == 'ping':
|
||||
# 心跳检测
|
||||
await websocket.send(json.dumps({'type': 'pong'}))
|
||||
|
||||
else:
|
||||
log("WARN", f"Unknown message type: {msg_type}")
|
||||
await websocket.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': f'Unknown type: {msg_type}'
|
||||
}))
|
||||
|
||||
except json.JSONDecodeError:
|
||||
log("ERROR", f"Invalid JSON: {message[:100]}")
|
||||
await websocket.send(json.dumps({
|
||||
'status': 'error',
|
||||
'message': 'Invalid JSON'
|
||||
}))
|
||||
|
||||
except ConnectionClosed:
|
||||
log("INFO", f"Client disconnected: {client_addr}")
|
||||
except Exception as e:
|
||||
log("ERROR", f"Client error: {e}")
|
||||
finally:
|
||||
log("INFO", f"Connection closed: {client_addr}")
|
||||
|
||||
|
||||
async def main():
|
||||
"""主函数"""
|
||||
port = int(os.environ.get('IME_PORT', 6091))
|
||||
host = os.environ.get('IME_HOST', '0.0.0.0')
|
||||
|
||||
log("INFO", f"Starting IME input server on {host}:{port}...")
|
||||
log("INFO", "Protocol: WebSocket")
|
||||
log("INFO", "Message format: {\"type\": \"text\", \"text\": \"要输入的文本\"}")
|
||||
|
||||
# 优雅关闭处理
|
||||
stop = asyncio.Event()
|
||||
|
||||
def signal_handler():
|
||||
log("INFO", "Received shutdown signal")
|
||||
stop.set()
|
||||
|
||||
loop = asyncio.get_running_loop()
|
||||
for sig in (signal.SIGTERM, signal.SIGINT):
|
||||
loop.add_signal_handler(sig, signal_handler)
|
||||
|
||||
async with serve(handle_client, host, port):
|
||||
log("INFO", f"IME server is ready, listening on ws://{host}:{port}")
|
||||
await stop.wait()
|
||||
|
||||
log("INFO", "IME server stopped")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
try:
|
||||
asyncio.run(main())
|
||||
except KeyboardInterrupt:
|
||||
log("INFO", "Server interrupted")
|
||||
except Exception as e:
|
||||
log("ERROR", f"Server error: {e}")
|
||||
sys.exit(1)
|
||||
Reference in New Issue
Block a user