根因: cli_tools.process_tool_call 在启用 UnifiedToolManager 时直接返回
ToolResult 对象,而 7 个调用点全部按 dict 用 result.get('result'),触发
AttributeError;FC 路径 _execute_tool_by_name 也可能把 ToolResult 透传出去,
后续 result[:5000] 会再炸 TypeError。
- tool_result.py: 新增 normalize_tool_text / normalize_tool_dict,
统一收敛 ToolResult / dict / str / None 三种历史返回形态。
- cli_tools.process_tool_call: 统一返回 dict(附带 success/error_code/
tool_result),回退 handler 路径同样归一。
- cli_core._execute_tool_by_name: handler / memory / scheduler 返回值
归一成 str,保证 FC 路径可安全切片。
- openai/ollama/manual 引擎、websocket_server、cli_clawli: 取工具结果改用
normalize_tool_text(带本地兜底实现,避免循环导入)。
- cli_clawli: 顺带修复只回传最后一个工具结果的 bug,改为汇总全部。
- 测试: 23 项全过;纯格式/讯飞 500 降级回归冒烟通过。
1182 lines
48 KiB
Python
1182 lines
48 KiB
Python
"""
|
||
WebSocket 服务端模块
|
||
用于 Clawli 安卓客户端连接
|
||
|
||
支持两种模式:
|
||
1. 本地模式 (/remote start) - 局域网直连,需要设置密码
|
||
2. 代理模式 (/remote proxy) - 通过中继服务器,需要设置密码
|
||
|
||
功能:
|
||
- 消息收发
|
||
- 工具调用状态推送
|
||
- 任意文件上传保存
|
||
- 图片识别
|
||
"""
|
||
|
||
import asyncio
|
||
import json
|
||
import threading
|
||
import time
|
||
import base64
|
||
import hashlib
|
||
import socket
|
||
import os
|
||
from datetime import datetime
|
||
from typing import Optional, Dict, List
|
||
|
||
try:
|
||
from xcli_core.tool_result import normalize_tool_text
|
||
except Exception:
|
||
def normalize_tool_text(result, default="无结果"):
|
||
"""兜底归一化:ToolResult / dict / str 统一成字符串"""
|
||
if result is None:
|
||
return default
|
||
if isinstance(result, dict):
|
||
return str(result.get("result", default))
|
||
return str(result)
|
||
|
||
# 全局变量
|
||
websocket_server = None # 当前活动的服务器实例(供外部访问)
|
||
proxy_client = None
|
||
|
||
# 文件保存目录
|
||
CLAWLI_FILES_DIR = os.path.join(os.path.dirname(os.path.abspath(__file__)), "clawli_files")
|
||
|
||
# 确保目录存在
|
||
os.makedirs(CLAWLI_FILES_DIR, exist_ok=True)
|
||
|
||
try:
|
||
import websockets
|
||
WEBSOCKET_AVAILABLE = True
|
||
except ImportError:
|
||
WEBSOCKET_AVAILABLE = False
|
||
|
||
|
||
def get_file_category(filename: str) -> str:
|
||
"""根据文件扩展名获取分类"""
|
||
ext = filename.lower().split('.')[-1] if '.' in filename else ''
|
||
|
||
# 图片
|
||
if ext in ['jpg', 'jpeg', 'png', 'gif', 'bmp', 'webp', 'svg']:
|
||
return 'image'
|
||
# 视频
|
||
if ext in ['mp4', 'avi', 'mov', 'wmv', 'flv', 'mkv', 'webm']:
|
||
return 'video'
|
||
# 音频
|
||
if ext in ['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a']:
|
||
return 'audio'
|
||
# 文档
|
||
if ext in ['txt', 'md', 'pdf', 'doc', 'docx', 'xls', 'xlsx', 'ppt', 'pptx', 'json', 'csv', 'xml']:
|
||
return 'document'
|
||
# 代码
|
||
if ext in ['py', 'js', 'ts', 'java', 'c', 'cpp', 'h', 'css', 'html', 'sql', 'sh']:
|
||
return 'code'
|
||
# 压缩包
|
||
if ext in ['zip', 'rar', '7z', 'tar', 'gz']:
|
||
return 'archive'
|
||
return 'file'
|
||
|
||
|
||
def save_uploaded_file(file_base64: str, filename: str) -> dict:
|
||
"""
|
||
保存上传的文件到 clawli_files 目录
|
||
返回: {success: bool, path: str, error: str, size: int}
|
||
"""
|
||
try:
|
||
# 解码文件
|
||
file_data = base64.b64decode(file_base64)
|
||
|
||
# 生成唯一文件名(时间戳 + 原始文件名)
|
||
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||
safe_name = "".join(c if c.isalnum() or c in '._-' else '_' for c in filename)
|
||
unique_name = f"{timestamp}_{safe_name}"
|
||
|
||
# 保存路径
|
||
file_path = os.path.join(CLAWLI_FILES_DIR, unique_name)
|
||
|
||
# 写入文件
|
||
with open(file_path, 'wb') as f:
|
||
f.write(file_data)
|
||
|
||
return {
|
||
'success': True,
|
||
'path': file_path,
|
||
'filename': unique_name,
|
||
'original_name': filename,
|
||
'size': len(file_data),
|
||
'category': get_file_category(filename)
|
||
}
|
||
except Exception as e:
|
||
return {
|
||
'success': False,
|
||
'error': str(e)
|
||
}
|
||
|
||
|
||
# ============== 本地服务器 ==============
|
||
|
||
class LocalServer:
|
||
"""本地WebSocket服务器"""
|
||
|
||
DEFAULT_PORT = 9079
|
||
|
||
def __init__(self, cli_instance=None, port=None):
|
||
self.cli = cli_instance
|
||
self.port = port or self.DEFAULT_PORT
|
||
self.password = ""
|
||
self.running = False
|
||
self.server = None
|
||
self.clients: Dict = {}
|
||
self.loop = None
|
||
self.thread = None
|
||
|
||
def set_cli(self, cli_instance):
|
||
self.cli = cli_instance
|
||
|
||
def set_password(self, password: str):
|
||
self.password = password
|
||
|
||
def start(self, password: str = None):
|
||
if password:
|
||
self.password = password
|
||
|
||
if not self.password:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}错误: 请先设置PC密码{Style.RESET_ALL}")
|
||
print(f"{Fore.YELLOW}用法: /remote start <端口> <密码>{Style.RESET_ALL}")
|
||
return False
|
||
|
||
if self.running:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.YELLOW}服务器已在运行{Style.RESET_ALL}")
|
||
return True
|
||
|
||
self.running = True
|
||
# 设置 Clawli 模式标志
|
||
if self.cli:
|
||
self.cli.is_clawli_mode = True
|
||
self.thread = threading.Thread(target=self._run_server, daemon=True)
|
||
self.thread.start()
|
||
time.sleep(0.5)
|
||
|
||
if self.running:
|
||
self._print_status()
|
||
return True
|
||
return False
|
||
|
||
def _run_server(self):
|
||
try:
|
||
async def handle_client(websocket):
|
||
client_addr = websocket.remote_address
|
||
authenticated = False
|
||
|
||
try:
|
||
async for message in websocket:
|
||
try:
|
||
data = json.loads(message)
|
||
msg_type = data.get("type", "")
|
||
|
||
if msg_type == "heartbeat":
|
||
await websocket.send(json.dumps({"type": "heartbeat_ack"}))
|
||
continue
|
||
|
||
if msg_type == "auth":
|
||
client_password = data.get("password", "")
|
||
if client_password == self.password:
|
||
authenticated = True
|
||
self.clients[websocket] = {"address": client_addr}
|
||
await websocket.send(json.dumps({
|
||
"type": "auth_success",
|
||
"message": "认证成功"
|
||
}))
|
||
else:
|
||
await websocket.send(json.dumps({
|
||
"type": "auth_failed",
|
||
"message": "密码错误"
|
||
}))
|
||
continue
|
||
|
||
if not authenticated:
|
||
await websocket.send(json.dumps({
|
||
"type": "error",
|
||
"message": "请先认证"
|
||
}))
|
||
continue
|
||
|
||
# 文本消息
|
||
if msg_type == "message":
|
||
content = data.get("content", "")
|
||
files = data.get("files", []) # 文件信息列表
|
||
response = await self._process_message(content, files, websocket)
|
||
await websocket.send(json.dumps({
|
||
"type": "message",
|
||
"content": response
|
||
}))
|
||
|
||
# 单个文件上传(兼容旧版)
|
||
elif msg_type == "file":
|
||
file_base64 = data.get("file", "")
|
||
filename = data.get("filename", "file")
|
||
file_text = data.get("text", "")
|
||
|
||
response = await self._process_single_file(
|
||
file_base64, filename, file_text, websocket
|
||
)
|
||
await websocket.send(json.dumps({
|
||
"type": "message",
|
||
"content": response
|
||
}))
|
||
|
||
except json.JSONDecodeError:
|
||
await websocket.send(json.dumps({
|
||
"type": "error",
|
||
"message": "JSON格式错误"
|
||
}))
|
||
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
# 不打印断开连接的异常,避免不必要的错误提示
|
||
if "no close frame" not in str(e):
|
||
print(f"{Fore.RED}客户端连接异常 [{client_addr}]: {e}{Style.RESET_ALL}")
|
||
finally:
|
||
if websocket in self.clients:
|
||
del self.clients[websocket]
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.YELLOW}客户端断开连接 [{client_addr}], 等待新连接...{Style.RESET_ALL}")
|
||
|
||
self.loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(self.loop)
|
||
|
||
async def start_async():
|
||
# 使用外层已创建的事件循环
|
||
self.server = await websockets.serve(
|
||
handle_client,
|
||
"0.0.0.0",
|
||
self.port,
|
||
ping_interval=None, # 禁用 ping,保持永久连接
|
||
ping_timeout=None,
|
||
max_size=100 * 1024 * 1024 # 100MB
|
||
)
|
||
# 保持服务器运行
|
||
try:
|
||
await asyncio.sleep(float('inf'))
|
||
except (KeyboardInterrupt, SystemExit):
|
||
pass
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}服务器运行异常: {e}{Style.RESET_ALL}")
|
||
|
||
# 循环运行服务器,确保持续运行
|
||
while self.running:
|
||
try:
|
||
self.loop.run_until_complete(start_async())
|
||
except KeyboardInterrupt:
|
||
break
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
if self.running: # 只有在需要运行时才打印错误
|
||
print(f"{Fore.RED}服务器重启: {e}{Style.RESET_ALL}")
|
||
import time
|
||
time.sleep(1) # 等待后重试
|
||
|
||
except ImportError:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}错误: websockets 库未安装{Style.RESET_ALL}")
|
||
self.running = False
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
# 只在非断连错误时打印
|
||
if "no close frame" not in str(e) and "closed" not in str(e).lower():
|
||
print(f"{Fore.RED}服务器异常: {e}{Style.RESET_ALL}")
|
||
# 服务器异常时尝试保持运行状态
|
||
# 不设置 running = False,让服务器继续等待连接
|
||
|
||
async def _send_tool_status(self, websocket, tool_name: str, args: str, status: str, result: str = ""):
|
||
"""发送工具/文件处理状态"""
|
||
try:
|
||
# 确保所有值都不是 None,并转换为字符串
|
||
data = {
|
||
"type": "tool_status",
|
||
"tool": str(tool_name) if tool_name else "",
|
||
"args": str(args)[:100] if args else "",
|
||
"status": str(status) if status else "",
|
||
"result": str(result)[:500] if result else ""
|
||
}
|
||
json_str = json.dumps(data, ensure_ascii=False)
|
||
await websocket.send(json_str)
|
||
except Exception as e:
|
||
print(f"[Clawli] 发送工具状态失败: {e}")
|
||
|
||
async def _send_image(self, websocket, image_data: str, filename: str = "image.png", caption: str = ""):
|
||
"""发送图片到手机端
|
||
|
||
Args:
|
||
websocket: WebSocket连接
|
||
image_data: 图片数据(文件路径或base64编码)
|
||
filename: 图片文件名
|
||
caption: 图片说明文字
|
||
"""
|
||
try:
|
||
# 判断是文件路径还是base64
|
||
if os.path.isfile(image_data):
|
||
# 是文件路径,读取并编码
|
||
with open(image_data, 'rb') as f:
|
||
file_data = f.read()
|
||
image_base64 = base64.b64encode(file_data).decode('utf-8')
|
||
filename = os.path.basename(image_data)
|
||
else:
|
||
# 假设是base64编码
|
||
image_base64 = image_data
|
||
|
||
data = {
|
||
"type": "image",
|
||
"image": image_base64,
|
||
"filename": filename,
|
||
"caption": caption
|
||
}
|
||
json_str = json.dumps(data, ensure_ascii=False)
|
||
await websocket.send(json_str)
|
||
print(f"[Clawli] 图片已发送: {filename}")
|
||
return True
|
||
except Exception as e:
|
||
print(f"[Clawli] 发送图片失败: {e}")
|
||
return False
|
||
|
||
def send_image_to_clients(self, image_path: str, caption: str = "") -> bool:
|
||
"""向所有连接的客户端发送图片(同步接口,供外部调用)
|
||
|
||
Args:
|
||
image_path: 图片文件路径
|
||
caption: 图片说明文字
|
||
|
||
Returns:
|
||
bool: 是否成功发送
|
||
"""
|
||
if not self.clients:
|
||
print("[Clawli] 没有连接的客户端")
|
||
return False
|
||
|
||
if not os.path.isfile(image_path):
|
||
print(f"[Clawli] 图片文件不存在: {image_path}")
|
||
return False
|
||
|
||
# 在事件循环中异步发送
|
||
if self.loop:
|
||
future = asyncio.run_coroutine_threadsafe(
|
||
self._broadcast_image(image_path, caption),
|
||
self.loop
|
||
)
|
||
try:
|
||
return future.result(timeout=10)
|
||
except Exception as e:
|
||
print(f"[Clawli] 发送图片超时: {e}")
|
||
return False
|
||
return False
|
||
|
||
async def _broadcast_image(self, image_path: str, caption: str = ""):
|
||
"""向所有客户端广播图片"""
|
||
success = False
|
||
for ws in list(self.clients.keys()):
|
||
if await self._send_image(ws, image_path, caption=caption):
|
||
success = True
|
||
return success
|
||
|
||
async def _process_single_file(self, file_base64: str, filename: str, text: str, websocket=None) -> str:
|
||
"""处理单个文件上传"""
|
||
try:
|
||
# 发送状态
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "文件上传", filename, "calling")
|
||
|
||
# 保存文件
|
||
result = save_uploaded_file(file_base64, filename)
|
||
|
||
if not result['success']:
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "文件上传", filename, "error", result.get('error', '未知错误'))
|
||
return f"文件上传失败: {result.get('error', '未知错误')}"
|
||
|
||
file_path = result['path']
|
||
category = result['category']
|
||
|
||
if websocket:
|
||
size = result.get('size', 0)
|
||
size_str = f"{size/1024:.1f}KB" if size < 1024*1024 else f"{size/1024/1024:.1f}MB"
|
||
await self._send_tool_status(websocket, "文件上传", filename, "success",
|
||
f"已保存: {result.get('filename', filename)} ({size_str})")
|
||
|
||
# 根据文件类型处理
|
||
if category == 'image':
|
||
# 图片走图像识别
|
||
return await self._process_image_file(file_path, text, websocket)
|
||
else:
|
||
# 其他文件,构建消息
|
||
return await self._process_message(f"@{file_path}", [], websocket, text)
|
||
|
||
except Exception as e:
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "文件上传", filename, "error", str(e))
|
||
return f"处理文件时出错: {str(e)}"
|
||
|
||
async def _process_image_file(self, file_path: str, text: str, websocket=None) -> str:
|
||
"""处理图片文件(走图像识别)"""
|
||
if not self.cli:
|
||
return "错误: CLI未初始化"
|
||
|
||
try:
|
||
# 检查图像识别引擎
|
||
image_engine = getattr(self.cli, 'image_engine', None)
|
||
if not image_engine:
|
||
try:
|
||
from image_engine import get_image_engine
|
||
image_engine = get_image_engine()
|
||
except:
|
||
return f"@{file_path}" # 没有图像引擎,直接返回路径
|
||
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "calling")
|
||
|
||
# 读取图片
|
||
with open(file_path, 'rb') as f:
|
||
image_data = f.read()
|
||
|
||
# 调用图像识别
|
||
try:
|
||
result = image_engine.analyze(image_data, text or "请描述这张图片")
|
||
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "success", result[:200] if result else "无结果")
|
||
|
||
# 返回识别结果 + 文件路径
|
||
return await self._process_message(f"@{file_path}", [], websocket,
|
||
f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}")
|
||
|
||
except Exception as e:
|
||
if websocket:
|
||
await self._send_tool_status(websocket, "图像识别", text or "分析图片", "error", str(e))
|
||
return f"@{file_path}\n\n图像识别失败: {str(e)}"
|
||
|
||
except Exception as e:
|
||
return f"@{file_path}\n\n处理图片时出错: {str(e)}"
|
||
|
||
async def _process_message(self, content: str, files: List[dict] = None, websocket=None, extra_text: str = "") -> str:
|
||
"""处理消息(支持工具调用循环)"""
|
||
if not self.cli:
|
||
return "错误: CLI未初始化"
|
||
|
||
try:
|
||
if not self.cli.current_engine:
|
||
return "错误: 没有可用的AI引擎"
|
||
|
||
# 构建完整消息
|
||
full_content = content
|
||
if extra_text:
|
||
full_content = f"{content}\n{extra_text}"
|
||
|
||
# 添加clawli标识
|
||
full_content = f"{full_content}\n\n[此消息由clawli手机远程发送]"
|
||
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "user",
|
||
"content": full_content
|
||
})
|
||
|
||
max_loops = 10
|
||
loop_count = 0
|
||
current_input = full_content
|
||
final_response = ""
|
||
|
||
while loop_count < max_loops:
|
||
loop_count += 1
|
||
|
||
system_prompt = self.cli._build_system_prompt(
|
||
self.cli.get_liugin_usage_prompts()
|
||
)
|
||
|
||
response = self.cli.current_engine.generate_response(
|
||
current_input,
|
||
system_prompt=system_prompt
|
||
)
|
||
|
||
text_content, json_data = self.cli._parse_mixed_response(response)
|
||
|
||
# 调试:打印原始响应和解析结果
|
||
print(f"[Clawli] AI响应: {response[:200]}...")
|
||
print(f"[Clawli] 解析结果: text={text_content[:50] if text_content else 'None'}..., json={json_data}")
|
||
|
||
if json_data:
|
||
tool_calls = None
|
||
|
||
# 支持多种JSON格式(MCP/Plugin/OpenAI/Anthropic)
|
||
if isinstance(json_data, dict):
|
||
# 小狸自有格式
|
||
if json_data.get('action') == 'use_tool':
|
||
tool_calls = [json_data]
|
||
elif 'tool' in json_data and 'args' in json_data:
|
||
tool_calls = [json_data]
|
||
# OpenAI function_call 格式
|
||
elif 'function_call' in json_data:
|
||
fc = json_data['function_call']
|
||
if isinstance(fc, dict):
|
||
args = fc.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': fc.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
# MCP/Anthropic 格式
|
||
elif 'name' in json_data and 'arguments' in json_data:
|
||
args = json_data.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': json_data.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
# OpenAI tool_calls 格式
|
||
elif json_data.get('type') == 'function' and 'function' in json_data:
|
||
func = json_data['function']
|
||
args = func.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': func.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
elif isinstance(json_data, list):
|
||
tool_calls = []
|
||
for item in json_data:
|
||
if isinstance(item, dict):
|
||
if item.get('action') == 'use_tool':
|
||
tool_calls.append(item)
|
||
elif 'tool' in item and 'args' in item:
|
||
tool_calls.append(item)
|
||
elif 'name' in item and 'arguments' in item:
|
||
args = item.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': item.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
if not tool_calls:
|
||
tool_calls = None
|
||
|
||
if tool_calls:
|
||
print(f"[Clawli] 检测到工具调用: {tool_calls}")
|
||
results = []
|
||
for tool_data in tool_calls:
|
||
tool_name = tool_data.get('tool', '未知工具')
|
||
tool_args = tool_data.get('args', '')
|
||
|
||
if websocket:
|
||
await self._send_tool_status(websocket, tool_name, tool_args, "calling")
|
||
|
||
result = self.cli.process_tool_call(tool_data)
|
||
result_text = normalize_tool_text(result)
|
||
results.append(result_text)
|
||
|
||
if websocket:
|
||
await self._send_tool_status(websocket, tool_name, tool_args, "success", result_text)
|
||
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": response
|
||
})
|
||
|
||
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in results])
|
||
current_input = f"{tool_result_str}\n请根据工具执行结果继续回答。\n\n[此消息由clawli手机远程发送]"
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "user",
|
||
"content": current_input
|
||
})
|
||
continue
|
||
|
||
if isinstance(json_data, dict) and json_data.get('message'):
|
||
final_response = json_data['message']
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": final_response
|
||
})
|
||
return final_response
|
||
|
||
final_response = text_content if text_content else response
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": final_response
|
||
})
|
||
return final_response
|
||
|
||
return "错误: 工具调用次数超限"
|
||
|
||
except Exception as e:
|
||
return f"处理消息时出错: {str(e)}"
|
||
|
||
def stop(self):
|
||
if not self.running:
|
||
return
|
||
self.running = False
|
||
# 清除 Clawli 模式标志
|
||
if self.cli:
|
||
self.cli.is_clawli_mode = False
|
||
if self.server and self.loop:
|
||
self.loop.call_soon_threadsafe(self.server.close)
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.YELLOW}本地服务器已停止{Style.RESET_ALL}")
|
||
|
||
def _print_status(self):
|
||
from colorama import Fore, Style
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.connect(("8.8.8.8", 80))
|
||
local_ip = s.getsockname()[0]
|
||
s.close()
|
||
except:
|
||
local_ip = "未知"
|
||
|
||
print(f"\n{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||
print(f"{Fore.GREEN}[本地模式] 远程服务已启动{Style.RESET_ALL}")
|
||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}本机IP: {Fore.YELLOW}{local_ip}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}端口: {Fore.YELLOW}{self.port}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}PC密码: {Fore.YELLOW}{'*' * len(self.password)}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}文件目录: {Fore.YELLOW}{CLAWLI_FILES_DIR}{Style.RESET_ALL}")
|
||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||
print(f"{Fore.GREEN}安卓端输入 {local_ip}:{self.port} 和PC密码即可连接{Style.RESET_ALL}\n")
|
||
|
||
def get_status(self) -> dict:
|
||
try:
|
||
s = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||
s.connect(("8.8.8.8", 80))
|
||
local_ip = s.getsockname()[0]
|
||
s.close()
|
||
except:
|
||
local_ip = "未知"
|
||
return {
|
||
"running": self.running,
|
||
"mode": "local",
|
||
"port": self.port,
|
||
"local_ip": local_ip,
|
||
"connected_clients": len(self.clients),
|
||
"files_dir": CLAWLI_FILES_DIR
|
||
}
|
||
|
||
|
||
# ============== 代理客户端 ==============
|
||
|
||
class ProxyClient:
|
||
"""代理模式客户端"""
|
||
|
||
def __init__(self, cli_instance=None):
|
||
self.cli = cli_instance
|
||
self.server_url = ""
|
||
self.server_password = ""
|
||
self.pc_password = ""
|
||
self.port = 0
|
||
self.websocket = None
|
||
self.running = False
|
||
self.loop = None
|
||
self.thread = None
|
||
|
||
def set_cli(self, cli_instance):
|
||
self.cli = cli_instance
|
||
|
||
def start(self, server_host: str, server_port: int,
|
||
server_password: str, pc_port: int, pc_password: str):
|
||
self.server_url = f"ws://{server_host}:{server_port}"
|
||
self.server_password = server_password
|
||
self.port = pc_port
|
||
self.pc_password = pc_password
|
||
|
||
if not self.server_password or not self.pc_password:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}错误: 请提供服务器密码和PC密码{Style.RESET_ALL}")
|
||
return False
|
||
|
||
self.running = True
|
||
self.thread = threading.Thread(target=self._run_client, daemon=True)
|
||
self.thread.start()
|
||
time.sleep(1)
|
||
return self.running
|
||
|
||
def _run_client(self):
|
||
try:
|
||
async def connect():
|
||
async with websockets.connect(self.server_url, max_size=100*1024*1024) as ws:
|
||
self.websocket = ws
|
||
|
||
await ws.send(json.dumps({
|
||
"type": "auth_server",
|
||
"password": self.server_password
|
||
}))
|
||
|
||
response = await ws.recv()
|
||
data = json.loads(response)
|
||
|
||
if data.get("type") != "auth_server_success":
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}服务器认证失败{Style.RESET_ALL}")
|
||
self.running = False
|
||
return
|
||
|
||
await ws.send(json.dumps({
|
||
"type": "register_pc",
|
||
"port": self.port,
|
||
"password": self.pc_password
|
||
}))
|
||
|
||
response = await ws.recv()
|
||
data = json.loads(response)
|
||
|
||
if data.get("type") == "register_success":
|
||
self.port = data.get("port", self.port)
|
||
self._print_status()
|
||
await self._main_loop(ws)
|
||
else:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}注册失败{Style.RESET_ALL}")
|
||
self.running = False
|
||
|
||
self.loop = asyncio.new_event_loop()
|
||
asyncio.set_event_loop(self.loop)
|
||
self.loop.run_until_complete(connect())
|
||
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}连接失败: {e}{Style.RESET_ALL}")
|
||
self.running = False
|
||
|
||
async def _main_loop(self, ws):
|
||
try:
|
||
async for message in ws:
|
||
try:
|
||
data = json.loads(message)
|
||
msg_type = data.get("type", "")
|
||
|
||
if msg_type == "heartbeat_ack":
|
||
continue
|
||
if msg_type in ["android_connected", "android_disconnected"]:
|
||
continue
|
||
|
||
if msg_type == "message":
|
||
content = data.get("content", "")
|
||
files = data.get("files", [])
|
||
response = await self._process_message(content, files)
|
||
await ws.send(json.dumps({"type": "message", "content": response}))
|
||
|
||
if msg_type == "file":
|
||
file_base64 = data.get("file", "")
|
||
filename = data.get("filename", "file")
|
||
file_text = data.get("text", "")
|
||
response = await self._process_single_file(file_base64, filename, file_text, ws)
|
||
await ws.send(json.dumps({"type": "message", "content": response}))
|
||
|
||
if msg_type == "disconnected":
|
||
break
|
||
|
||
except json.JSONDecodeError:
|
||
pass
|
||
|
||
except Exception as e:
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.RED}连接断开: {e}{Style.RESET_ALL}")
|
||
finally:
|
||
self.running = False
|
||
|
||
async def _process_single_file(self, file_base64: str, filename: str, text: str) -> str:
|
||
"""处理单个文件"""
|
||
result = save_uploaded_file(file_base64, filename)
|
||
|
||
if not result['success']:
|
||
return f"文件上传失败: {result['error']}"
|
||
|
||
file_path = result['path']
|
||
category = result['category']
|
||
|
||
if category == 'image':
|
||
return await self._process_image_file(file_path, text)
|
||
else:
|
||
return await self._process_message(f"@{file_path}", [], text)
|
||
|
||
async def _process_image_file(self, file_path: str, text: str) -> str:
|
||
"""处理图片"""
|
||
if not self.cli:
|
||
return f"@{file_path}"
|
||
|
||
try:
|
||
image_engine = getattr(self.cli, 'image_engine', None)
|
||
if not image_engine:
|
||
try:
|
||
from image_engine import get_image_engine
|
||
image_engine = get_image_engine()
|
||
except:
|
||
return f"@{file_path}"
|
||
|
||
with open(file_path, 'rb') as f:
|
||
image_data = f.read()
|
||
|
||
result = image_engine.analyze(image_data, text or "请描述这张图片")
|
||
return await self._process_message(f"@{file_path}", [], f"图片内容: {result}\n\n{text}" if text else f"图片内容: {result}")
|
||
|
||
except Exception as e:
|
||
return f"@{file_path}\n\n图像识别失败: {str(e)}"
|
||
|
||
async def _process_message(self, content: str, files: List[dict] = None, extra_text: str = "") -> str:
|
||
"""处理消息"""
|
||
if not self.cli:
|
||
return "错误: CLI未初始化"
|
||
|
||
try:
|
||
if not self.cli.current_engine:
|
||
return "错误: 没有可用的AI引擎"
|
||
|
||
full_content = content
|
||
if extra_text:
|
||
full_content = f"{content}\n{extra_text}"
|
||
|
||
full_content = f"{full_content}\n\n[此消息由clawli手机远程发送]"
|
||
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "user",
|
||
"content": full_content
|
||
})
|
||
|
||
max_loops = 10
|
||
loop_count = 0
|
||
current_input = full_content
|
||
|
||
while loop_count < max_loops:
|
||
loop_count += 1
|
||
|
||
system_prompt = self.cli._build_system_prompt(
|
||
self.cli.get_liugin_usage_prompts()
|
||
)
|
||
|
||
response = self.cli.current_engine.generate_response(
|
||
current_input,
|
||
system_prompt=system_prompt
|
||
)
|
||
|
||
text_content, json_data = self.cli._parse_mixed_response(response)
|
||
|
||
# 调试:打印原始响应和解析结果
|
||
print(f"[Clawli] AI响应: {response[:200]}...")
|
||
print(f"[Clawli] 解析结果: text={text_content[:50] if text_content else 'None'}..., json={json_data}")
|
||
|
||
if json_data:
|
||
tool_calls = None
|
||
|
||
# 支持多种JSON格式(MCP/Plugin/OpenAI/Anthropic)
|
||
if isinstance(json_data, dict):
|
||
# 小狸自有格式
|
||
if json_data.get('action') == 'use_tool':
|
||
tool_calls = [json_data]
|
||
elif 'tool' in json_data and 'args' in json_data:
|
||
tool_calls = [json_data]
|
||
# OpenAI function_call 格式
|
||
elif 'function_call' in json_data:
|
||
fc = json_data['function_call']
|
||
if isinstance(fc, dict):
|
||
args = fc.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': fc.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
# MCP/Anthropic 格式
|
||
elif 'name' in json_data and 'arguments' in json_data:
|
||
args = json_data.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': json_data.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
# OpenAI tool_calls 格式
|
||
elif json_data.get('type') == 'function' and 'function' in json_data:
|
||
func = json_data['function']
|
||
args = func.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls = [{
|
||
'tool': func.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
}]
|
||
elif isinstance(json_data, list):
|
||
tool_calls = []
|
||
for item in json_data:
|
||
if isinstance(item, dict):
|
||
if item.get('action') == 'use_tool':
|
||
tool_calls.append(item)
|
||
elif 'tool' in item and 'args' in item:
|
||
tool_calls.append(item)
|
||
elif 'name' in item and 'arguments' in item:
|
||
args = item.get('arguments', '')
|
||
# arguments 可能是 JSON 字符串,需要解析
|
||
if isinstance(args, str) and args.startswith('{'):
|
||
try:
|
||
args = json.loads(args)
|
||
except:
|
||
pass
|
||
tool_calls.append({
|
||
'tool': item.get('name', ''),
|
||
'args': args if isinstance(args, str) else '',
|
||
'arguments': args if isinstance(args, dict) else None
|
||
})
|
||
if not tool_calls:
|
||
tool_calls = None
|
||
|
||
if tool_calls:
|
||
print(f"[Clawli] 检测到工具调用: {tool_calls}")
|
||
results = []
|
||
for tool_data in tool_calls:
|
||
result = self.cli.process_tool_call(tool_data)
|
||
results.append(normalize_tool_text(result))
|
||
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": response
|
||
})
|
||
|
||
tool_result_str = "\n".join([f"工具执行结果: {r}" for r in results])
|
||
current_input = f"{tool_result_str}\n请根据工具执行结果继续回答。\n\n[此消息由clawli手机远程发送]"
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "user",
|
||
"content": current_input
|
||
})
|
||
continue
|
||
|
||
if isinstance(json_data, dict) and json_data.get('message'):
|
||
final_response = json_data['message']
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": final_response
|
||
})
|
||
return final_response
|
||
|
||
final_response = text_content if text_content else response
|
||
self.cli.shared_conversation_history.append({
|
||
"role": "assistant",
|
||
"content": final_response
|
||
})
|
||
return final_response
|
||
|
||
return "错误: 工具调用次数超限"
|
||
|
||
except Exception as e:
|
||
return f"处理消息时出错: {str(e)}"
|
||
|
||
def stop(self):
|
||
if not self.running:
|
||
return
|
||
self.running = False
|
||
if self.websocket and self.loop:
|
||
async def close():
|
||
await self.websocket.close()
|
||
self.loop.call_soon_threadsafe(lambda: asyncio.create_task(close()))
|
||
from colorama import Fore, Style
|
||
print(f"{Fore.YELLOW}代理连接已断开{Style.RESET_ALL}")
|
||
|
||
async def _send_image(self, image_data: str, filename: str = "image.png", caption: str = ""):
|
||
"""发送图片到手机端(通过代理服务器)
|
||
|
||
Args:
|
||
image_data: 图片数据(文件路径或base64编码)
|
||
filename: 图片文件名
|
||
caption: 图片说明文字
|
||
"""
|
||
try:
|
||
if not self.websocket:
|
||
print("[Clawli Proxy] 没有活动的 WebSocket 连接")
|
||
return False
|
||
|
||
# 判断是文件路径还是base64
|
||
if os.path.isfile(image_data):
|
||
with open(image_data, 'rb') as f:
|
||
file_data = f.read()
|
||
image_base64 = base64.b64encode(file_data).decode('utf-8')
|
||
filename = os.path.basename(image_data)
|
||
else:
|
||
image_base64 = image_data
|
||
|
||
data = {
|
||
"type": "image",
|
||
"image": image_base64,
|
||
"filename": filename,
|
||
"caption": caption
|
||
}
|
||
await self.websocket.send(json.dumps(data, ensure_ascii=False))
|
||
print(f"[Clawli Proxy] 图片已发送: {filename}")
|
||
return True
|
||
except Exception as e:
|
||
print(f"[Clawli Proxy] 发送图片失败: {e}")
|
||
return False
|
||
|
||
def send_image_to_clients(self, image_path: str, caption: str = "") -> bool:
|
||
"""发送图片(同步接口,供外部调用)"""
|
||
if not self.websocket or not self.running:
|
||
print("[Clawli Proxy] 未连接到代理服务器")
|
||
return False
|
||
|
||
if not os.path.isfile(image_path):
|
||
print(f"[Clawli Proxy] 图片文件不存在: {image_path}")
|
||
return False
|
||
|
||
if self.loop:
|
||
future = asyncio.run_coroutine_threadsafe(
|
||
self._send_image(image_path, caption=caption),
|
||
self.loop
|
||
)
|
||
try:
|
||
return future.result(timeout=10)
|
||
except Exception as e:
|
||
print(f"[Clawli Proxy] 发送图片超时: {e}")
|
||
return False
|
||
return False
|
||
|
||
def _print_status(self):
|
||
from colorama import Fore, Style
|
||
print(f"\n{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||
print(f"{Fore.GREEN}[代理模式] 已连接到中继服务器{Style.RESET_ALL}")
|
||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}服务器: {Fore.YELLOW}{self.server_url}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}端口: {Fore.YELLOW}{self.port}{Style.RESET_ALL}")
|
||
print(f"{Fore.WHITE}文件目录: {Fore.YELLOW}{CLAWLI_FILES_DIR}{Style.RESET_ALL}")
|
||
print(f"{Fore.CYAN}{'='*50}{Style.RESET_ALL}\n")
|
||
|
||
def get_status(self) -> dict:
|
||
return {
|
||
"running": self.running,
|
||
"mode": "proxy",
|
||
"server": self.server_url,
|
||
"port": self.port,
|
||
"files_dir": CLAWLI_FILES_DIR
|
||
}
|
||
|
||
|
||
# ============== 全局接口 ==============
|
||
|
||
_local_server: Optional[LocalServer] = None
|
||
_proxy_client: Optional[ProxyClient] = None
|
||
|
||
|
||
def start_local_server(cli_instance, port: int = None, password: str = None) -> bool:
|
||
global _local_server, _proxy_client, websocket_server
|
||
|
||
if _proxy_client:
|
||
_proxy_client.stop()
|
||
_proxy_client = None
|
||
|
||
if _local_server is None:
|
||
_local_server = LocalServer(cli_instance, port)
|
||
else:
|
||
_local_server.set_cli(cli_instance)
|
||
if port:
|
||
_local_server.port = port
|
||
|
||
result = _local_server.start(password)
|
||
|
||
# 更新全局 websocket_server 引用
|
||
if result:
|
||
websocket_server = _local_server
|
||
else:
|
||
websocket_server = None
|
||
|
||
return result
|
||
|
||
|
||
def start_proxy_client(cli_instance, server_host: str, server_port: int,
|
||
server_password: str, pc_port: int, pc_password: str) -> bool:
|
||
global _local_server, _proxy_client, websocket_server
|
||
|
||
if _local_server:
|
||
_local_server.stop()
|
||
_local_server = None
|
||
|
||
if _proxy_client is None:
|
||
_proxy_client = ProxyClient(cli_instance)
|
||
else:
|
||
_proxy_client.set_cli(cli_instance)
|
||
|
||
result = _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password)
|
||
|
||
# 更新全局 websocket_server 引用
|
||
if result:
|
||
websocket_server = _proxy_client
|
||
else:
|
||
websocket_server = None
|
||
|
||
return result
|
||
|
||
return _proxy_client.start(server_host, server_port, server_password, pc_port, pc_password)
|
||
|
||
|
||
def stop_all():
|
||
global _local_server, _proxy_client, websocket_server
|
||
|
||
if _local_server:
|
||
_local_server.stop()
|
||
if _proxy_client:
|
||
_proxy_client.stop()
|
||
websocket_server = None
|
||
|
||
|
||
def get_status() -> dict:
|
||
if _local_server and _local_server.running:
|
||
return _local_server.get_status()
|
||
if _proxy_client and _proxy_client.running:
|
||
return _proxy_client.get_status()
|
||
return {"running": False}
|
||
|
||
|
||
def is_running() -> bool:
|
||
return get_status().get("running", False)
|
||
|
||
|
||
def get_active_server():
|
||
"""获取当前活动的服务器实例(本地或代理)"""
|
||
global _local_server, _proxy_client
|
||
|
||
if _local_server and _local_server.running:
|
||
return _local_server
|
||
if _proxy_client and _proxy_client.running:
|
||
return _proxy_client
|
||
return None
|