diff --git a/.gitignore b/.gitignore index e801e578..da0e4850 100644 --- a/.gitignore +++ b/.gitignore @@ -47,5 +47,4 @@ gemini-websocket-proxy/ Tools/dark-server/dark-server.js Tools/rimworld_cpt_data.jsonl Tools/mem0-1.0.0/ -Tools/thenextagent-1 diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index 25d5e10b..ba13b5df 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.dll and b/1.6/1.6/Assemblies/WulaFallenEmpire.dll differ diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb index eba1d68b..40221daf 100644 Binary files a/1.6/1.6/Assemblies/WulaFallenEmpire.pdb and b/1.6/1.6/Assemblies/WulaFallenEmpire.pdb differ diff --git a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs index b6c806f0..0240a9c4 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -320,6 +320,12 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori _tools.Add(new Tool_CallBombardment()); _tools.Add(new Tool_SearchThingDef()); _tools.Add(new Tool_SearchPawnKind()); + + // VLM 视觉分析工具 (条件性启用) + if (WulaFallenEmpireMod.settings?.enableVlmFeatures == true) + { + _tools.Add(new Tool_AnalyzeScreen()); + } } private void SetThinkingState(bool isThinking) diff --git a/Source/WulaFallenEmpire/EventSystem/AI/ScreenCaptureUtility.cs b/Source/WulaFallenEmpire/EventSystem/AI/ScreenCaptureUtility.cs new file mode 100644 index 00000000..b48eafa8 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/ScreenCaptureUtility.cs @@ -0,0 +1,87 @@ +using System; +using UnityEngine; + +namespace WulaFallenEmpire.EventSystem.AI +{ + /// + /// Unity 屏幕截取工具类,用于 VLM 视觉分析 + /// + public static class ScreenCaptureUtility + { + private const int MaxImageSize = 1024; // 限制图片大小以节省 API 费用 + + /// + /// 截取当前屏幕并返回 Base64 编码的 PNG + /// + public static string CaptureScreenAsBase64() + { + try + { + // 使用 Unity 截屏 + Texture2D screenshot = ScreenCapture.CaptureScreenshotAsTexture(); + if (screenshot == null) + { + WulaLog.Debug("[ScreenCapture] CaptureScreenshotAsTexture returned null"); + return null; + } + + // 缩放以适配 API 限制 + Texture2D resized = ResizeTexture(screenshot, MaxImageSize); + + // 编码为 PNG + byte[] pngBytes = resized.EncodeToPNG(); + + // 清理资源 + UnityEngine.Object.Destroy(screenshot); + if (resized != screenshot) + { + UnityEngine.Object.Destroy(resized); + } + + WulaLog.Debug($"[ScreenCapture] Captured {pngBytes.Length} bytes"); + return Convert.ToBase64String(pngBytes); + } + catch (Exception ex) + { + WulaLog.Debug($"[ScreenCapture] Failed: {ex.Message}"); + return null; + } + } + + /// + /// 缩放纹理到指定最大尺寸 + /// + private static Texture2D ResizeTexture(Texture2D source, int maxSize) + { + int width = source.width; + int height = source.height; + + // 计算缩放比例 + if (width <= maxSize && height <= maxSize) + { + return source; // 无需缩放 + } + + float ratio = (float)maxSize / Mathf.Max(width, height); + int newWidth = Mathf.RoundToInt(width * ratio); + int newHeight = Mathf.RoundToInt(height * ratio); + + // 创建缩放后的纹理 + RenderTexture rt = RenderTexture.GetTemporary(newWidth, newHeight); + Graphics.Blit(source, rt); + + RenderTexture previous = RenderTexture.active; + RenderTexture.active = rt; + + Texture2D resized = new Texture2D(newWidth, newHeight, TextureFormat.RGB24, false); + resized.ReadPixels(new Rect(0, 0, newWidth, newHeight), 0, 0); + resized.Apply(); + + RenderTexture.active = previous; + RenderTexture.ReleaseTemporary(rt); + + WulaLog.Debug($"[ScreenCapture] Resized from {width}x{height} to {newWidth}x{newHeight}"); + return resized; + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs b/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs index 0edce94c..c2c4dd72 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/SimpleAIClient.cs @@ -346,5 +346,91 @@ namespace WulaFallenEmpire.EventSystem.AI } return null; } + + /// + /// 发送带图片的 VLM 视觉请求 + /// + public async Task GetVisionCompletionAsync( + string systemPrompt, + string userText, + string base64Image, + int maxTokens = 512, + float temperature = 0.3f) + { + if (string.IsNullOrEmpty(_baseUrl)) + { + WulaLog.Debug("[WulaAI] VLM: Base URL is missing."); + return null; + } + + string endpoint = $"{_baseUrl}/chat/completions"; + if (_baseUrl.EndsWith("/chat/completions")) endpoint = _baseUrl; + else if (!_baseUrl.EndsWith("/v1")) endpoint = $"{_baseUrl}/v1/chat/completions"; + + // Build VLM-specific JSON with image content + StringBuilder jsonBuilder = new StringBuilder(); + jsonBuilder.Append("{"); + jsonBuilder.Append($"\"model\": \"{_model}\","); + jsonBuilder.Append("\"stream\": false,"); + jsonBuilder.Append($"\"max_tokens\": {Math.Max(1, maxTokens)},"); + jsonBuilder.Append($"\"temperature\": {Mathf.Clamp(temperature, 0f, 2f).ToString("0.###", System.Globalization.CultureInfo.InvariantCulture)},"); + jsonBuilder.Append("\"messages\": ["); + + // System message + if (!string.IsNullOrEmpty(systemPrompt)) + { + jsonBuilder.Append($"{{\"role\": \"system\", \"content\": \"{EscapeJson(systemPrompt)}\"}},"); + } + + // User message with image (multimodal content) + jsonBuilder.Append("{\"role\": \"user\", \"content\": ["); + jsonBuilder.Append($"{{\"type\": \"text\", \"text\": \"{EscapeJson(userText)}\"}},"); + jsonBuilder.Append("{\"type\": \"image_url\", \"image_url\": {"); + jsonBuilder.Append($"\"url\": \"data:image/png;base64,{base64Image}\""); + jsonBuilder.Append("}}"); + jsonBuilder.Append("]}"); + + jsonBuilder.Append("]}"); + + string jsonBody = jsonBuilder.ToString(); + if (Prefs.DevMode) + { + // Don't log the full base64 image + WulaLog.Debug($"[WulaAI] VLM request to {endpoint} (model={_model}, imageSize={base64Image?.Length ?? 0} chars)"); + } + + using (UnityWebRequest request = new UnityWebRequest(endpoint, "POST")) + { + byte[] bodyRaw = Encoding.UTF8.GetBytes(jsonBody); + request.uploadHandler = new UploadHandlerRaw(bodyRaw); + request.downloadHandler = new DownloadHandlerBuffer(); + request.SetRequestHeader("Content-Type", "application/json"); + request.timeout = 60; // VLM requests may take longer due to image processing + if (!string.IsNullOrEmpty(_apiKey)) + { + request.SetRequestHeader("Authorization", $"Bearer {_apiKey}"); + } + + var operation = request.SendWebRequest(); + + while (!operation.isDone) + { + await Task.Delay(100); + } + + if (request.result == UnityWebRequest.Result.ConnectionError || request.result == UnityWebRequest.Result.ProtocolError) + { + WulaLog.Debug($"[WulaAI] VLM API Error: {request.error}"); + return null; + } + + string responseText = request.downloadHandler.text; + if (Prefs.DevMode) + { + WulaLog.Debug($"[WulaAI] VLM Response (truncated): {TruncateForLog(responseText)}"); + } + return ExtractContent(responseText); + } + } } } diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_AnalyzeScreen.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_AnalyzeScreen.cs new file mode 100644 index 00000000..500649fe --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_AnalyzeScreen.cs @@ -0,0 +1,99 @@ +using System; +using System.Threading.Tasks; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + /// + /// VLM 视觉分析工具 - 截取游戏屏幕并使用视觉语言模型分析 + /// + public class Tool_AnalyzeScreen : AITool + { + public override string Name => "analyze_screen"; + + public override string Description => + "分析当前游戏屏幕截图,了解玩家正在查看什么区域或内容。需要配置 VLM API 密钥。"; + + public override string UsageSchema => + "分析目标,如:玩家在看什么区域"; + + private const string VisionSystemPrompt = @" +你是一个 RimWorld 游戏屏幕分析助手。分析截图并用简洁中文描述: +- 玩家正在查看的区域(如:殖民地基地、世界地图、菜单界面) +- 可见的重要建筑、角色、资源 +- 任何明显的问题或特殊状态 +保持回答简洁,不超过100字。不要使用 XML 标签。"; + + public override string Execute(string args) + { + // 由于 VLM API 调用是异步的,我们需要同步等待结果 + // 这在 Unity 主线程上可能会阻塞,但工具执行通常在异步上下文中调用 + try + { + var task = ExecuteInternalAsync(args); + // 使用 GetAwaiter().GetResult() 来同步等待,避免死锁 + return task.GetAwaiter().GetResult(); + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_AnalyzeScreen] Execute error: {ex}"); + return $"视觉分析出错: {ex.Message}"; + } + } + + private async Task ExecuteInternalAsync(string xmlContent) + { + var argsDict = ParseXmlArgs(xmlContent); + string context = argsDict.TryGetValue("context", out var ctx) ? ctx : "描述当前屏幕内容"; + + try + { + // 检查 VLM 配置 + var settings = WulaFallenEmpireMod.settings; + if (settings == null) + { + return "Mod 设置未初始化。"; + } + + // 使用主 API 密钥(如果没有单独配置 VLM 密钥) + string vlmApiKey = !string.IsNullOrEmpty(settings.vlmApiKey) ? settings.vlmApiKey : settings.apiKey; + string vlmBaseUrl = !string.IsNullOrEmpty(settings.vlmBaseUrl) ? settings.vlmBaseUrl : "https://dashscope.aliyuncs.com/compatible-mode/v1"; + string vlmModel = !string.IsNullOrEmpty(settings.vlmModel) ? settings.vlmModel : "qwen-vl-plus"; + + if (string.IsNullOrEmpty(vlmApiKey)) + { + return "VLM API 密钥未配置。请在 Mod 设置中配置 API 密钥。"; + } + + // 截取屏幕 + string base64Image = ScreenCaptureUtility.CaptureScreenAsBase64(); + if (string.IsNullOrEmpty(base64Image)) + { + return "截屏失败,无法分析屏幕。"; + } + + // 调用 VLM API + var client = new SimpleAIClient(vlmApiKey, vlmBaseUrl, vlmModel); + + string result = await client.GetVisionCompletionAsync( + VisionSystemPrompt, + context, + base64Image, + maxTokens: 256, + temperature: 0.3f + ); + + if (string.IsNullOrEmpty(result)) + { + return "VLM 分析无响应,请检查 API 配置。"; + } + + return $"屏幕分析结果: {result.Trim()}"; + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_AnalyzeScreen] Error: {ex}"); + return $"视觉分析出错: {ex.Message}"; + } + } + } +} diff --git a/Source/WulaFallenEmpire/WulaFallenEmpire.csproj b/Source/WulaFallenEmpire/WulaFallenEmpire.csproj index 4d8d24e9..c331bfdd 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpire.csproj +++ b/Source/WulaFallenEmpire/WulaFallenEmpire.csproj @@ -74,6 +74,14 @@ ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.TextRenderingModule.dll False + + ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.ImageConversionModule.dll + False + + + ..\..\..\..\..\..\common\RimWorld\RimWorldWin64_Data\Managed\UnityEngine.ScreenCaptureModule.dll + False + diff --git a/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs index 52195218..fc8c6c52 100644 --- a/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs +++ b/Source/WulaFallenEmpire/WulaFallenEmpireSettings.cs @@ -9,6 +9,12 @@ namespace WulaFallenEmpire public string model = "deepseek-chat"; public int maxContextTokens = 100000; public bool enableDebugLogs = false; + + // VLM (视觉语言模型) 配置 + public string vlmApiKey = ""; + public string vlmBaseUrl = "https://dashscope.aliyuncs.com/compatible-mode/v1"; + public string vlmModel = "qwen-vl-plus"; + public bool enableVlmFeatures = false; public override void ExposeData() { @@ -17,6 +23,13 @@ namespace WulaFallenEmpire Scribe_Values.Look(ref model, "model", "deepseek-chat"); Scribe_Values.Look(ref maxContextTokens, "maxContextTokens", 100000); Scribe_Values.Look(ref enableDebugLogs, "enableDebugLogs", false); + + // VLM 配置 + Scribe_Values.Look(ref vlmApiKey, "vlmApiKey", ""); + Scribe_Values.Look(ref vlmBaseUrl, "vlmBaseUrl", "https://dashscope.aliyuncs.com/compatible-mode/v1"); + Scribe_Values.Look(ref vlmModel, "vlmModel", "qwen-vl-plus"); + Scribe_Values.Look(ref enableVlmFeatures, "enableVlmFeatures", false); + base.ExposeData(); } } diff --git a/Tools/thenextagent-1/README.md b/Tools/thenextagent-1/README.md new file mode 100644 index 00000000..9d132596 --- /dev/null +++ b/Tools/thenextagent-1/README.md @@ -0,0 +1,110 @@ +# VLM Agent - 视觉语言模型电脑操作工具 + +基于Qwen-VL模型的自动化电脑操作工具,可以通过自然语言指令控制电脑完成各种任务。 + +## 项目简介 + +这是一个利用视觉语言模型(VLM)实现的电脑自动化操作工具,能够通过分析屏幕截图并执行相应操作来完成用户指定的任务。该工具可以模拟人类操作电脑的行为,包括鼠标点击、文本输入、窗口滚动等。 + +## 核心功能 + +### 支持的操作工具 + +1. **鼠标点击** - 在指定坐标点击鼠标 +2. **文本输入** - 在指定位置输入文本(支持中英文) +3. **窗口滚动** - 在指定位置向上或向下滚动 +4. **关闭窗口** - 关闭指定坐标所在的窗口 +5. **Windows键** - 按下Windows键打开开始菜单 +6. **回车键** - 按下回车键确认或换行 +7. **删除文本** - 删除指定输入框中的文本 +8. **鼠标拖拽** - 从起始坐标拖拽到结束坐标 +9. **等待** - 等待指定时间 +10. **打开终端** - 打开新的终端窗口 +11. **快捷键** - 在指定位置点击后执行快捷键操作 + +### 特色功能 + +- **坐标系统**:使用0-1比例坐标系统,适配不同分辨率屏幕 +- **图像处理**:自动缩放截图至最大边长1024像素以优化API调用 +- **智能解析**:自动解析模型输出的工具调用指令 +- **跨平台支持**:支持Windows、macOS和Linux系统 + +## 安装与使用 + +### 环境要求 + +- Python 3.6+ +- 阿里云API密钥(用于调用Qwen-VL模型) + +### 安装依赖 + +```bash +pip install pyautogui pillow openai pyperclip +``` + +### 运行程序 + +```bash +python main.py +``` + +首次运行时,程序会提示您输入阿里云API密钥。 + +### 获取阿里云API密钥 + +1. 访问 [阿里云官网](https://www.aliyun.com/) +2. 注册或登录账号 +3. 进入[阿里云控制台](https://home.console.aliyun.com/) +4. 开通DashScope服务并获取API密钥 + +## 使用示例 + +程序运行后,您可以尝试以下任务: + +- "打开记事本并输入'Hello World'" +- "在浏览器中搜索'人工智能'" +- "创建一个名为'test.txt'的文件" +- "打开计算器并计算2+3的结果" + +## 注意事项 + +1. 程序运行时,请勿手动操作电脑,以免干扰自动化流程 +2. 如需紧急停止程序,可将鼠标快速移至屏幕左上角触发PyAutoGUI安全机制 +3. 坐标系统使用比例值,x和y的取值范围都是0到1之间的小数 +4. 请确保网络连接稳定,以便正常调用模型API +5. 不要在程序运行时关闭终端窗口 + +## 安全提醒 + +- API密钥是敏感信息,请妥善保管 +- 程序只能执行您授权的任务,请勿尝试危险操作 +- 如发现异常行为,请立即终止程序运行 + +## 技术架构 + +- **核心控制器**:VLMAgent类负责API连接、截图、坐标转换和操作执行 +- **模型服务**:基于阿里云Qwen-VL模型提供视觉语言理解能力 +- **操作执行**:通过pyautogui库实现底层的鼠标和键盘操作 +- **图像处理**:使用PIL库处理屏幕截图以优化API传输效率 + +## 项目结构 + +``` +. +├── main.py # 主程序文件 +└── README.md # 项目说明文档 +``` + +## 常见问题 + +### 如何提高操作准确性? + +如果发现鼠标点击位置不准确,可能是坐标转换存在问题,程序会自动微调坐标值。如果是软件正在运行导致操作延迟,建议增加等待时间。 + +### 支持哪些操作系统? + +支持Windows、macOS和Linux主流操作系统。 + +### 最多执行多少步操作? + +默认情况下,程序最多执行50步操作以防止无限循环。 \ No newline at end of file diff --git a/Tools/thenextagent-1/main.py b/Tools/thenextagent-1/main.py new file mode 100644 index 00000000..afb5fb22 --- /dev/null +++ b/Tools/thenextagent-1/main.py @@ -0,0 +1,725 @@ +import base64 +import re +import time +from openai import OpenAI +import pyautogui +from PIL import Image +import io +import json +import tkinter as tk +import subprocess +import platform +import os + +class VLMAgent: + def __init__(self, api_key, model_name="qwen3-vl-plus"): + """ + 初始化VLM代理 + """ + self.client = OpenAI( + api_key=api_key, + base_url="https://dashscope.aliyuncs.com/compatible-mode/v1" + ) + self.model_name = model_name + self.messages = [] + self.screen_width, self.screen_height = self.get_screen_resolution() + print(f"屏幕分辨率: {self.screen_width} x {self.screen_height}") + + # 启用PyAutoGUI的安全机制,将鼠标移到屏幕左上角可紧急停止 + pyautogui.FAILSAFE = True + pyautogui.PAUSE = 1 # 每次操作后暂停1秒 + + self.tools = { + "mouse_click": self.mouse_click, + "type_text": self.type_text, + "scroll_window": self.scroll_window, + "close_window": self.close_window, + "press_windows_key": self.press_windows_key, + "press_enter": self.press_enter, + "delete_text": self.delete_text, + "mouse_drag": self.mouse_drag, + "wait": self.wait, + "open_terminal": self.open_terminal, + "press_hotkey": self.press_hotkey + } + + def mouse_drag(self, start_x, start_y, end_x, end_y, duration=0.5): + """ + 鼠标拖拽工具 - 从起始坐标拖拽到结束坐标 + :param start_x: 起始点比例x坐标 (0-1之间的小数) + :param start_y: 起始点比例y坐标 (0-1之间的小数) + :param end_x: 结束点比例x坐标 (0-1之间的小数) + :param end_y: 结束点比例y坐标 (0-1之间的小数) + :param duration: 拖拽过程耗时(秒),默认为0.5秒 + """ + try: + # 将比例坐标转换为实际屏幕坐标 + actual_start_x = int(start_x * self.screen_width) + actual_start_y = int(start_y * self.screen_height) + actual_end_x = int(end_x * self.screen_width) + actual_end_y = int(end_y * self.screen_height) + + print(f"拖拽起始坐标转换: ({start_x:.3f}, {start_y:.3f}) -> ({actual_start_x}, {actual_start_y})") + print(f"拖拽结束坐标转换: ({end_x:.3f}, {end_y:.3f}) -> ({actual_end_x}, {actual_end_y})") + + # 验证起始坐标范围 + if not (0 <= actual_start_x <= self.screen_width and 0 <= actual_start_y <= self.screen_height): + return f"起始坐标 ({actual_start_x}, {actual_start_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 验证结束坐标范围 + if not (0 <= actual_end_x <= self.screen_width and 0 <= actual_end_y <= self.screen_height): + return f"结束坐标 ({actual_end_x}, {actual_end_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 执行拖拽操作 + pyautogui.moveTo(actual_start_x, actual_start_y) + pyautogui.dragTo(actual_end_x, actual_end_y, duration=duration) + + return f"成功从坐标 ({actual_start_x}, {actual_start_y}) 拖拽到 ({actual_end_x}, {actual_end_y}) (比例坐标: ({start_x:.3f}, {start_y:.3f}) -> ({end_x:.3f}, {end_y:.3f}))" + except Exception as e: + return f"拖拽操作失败: {str(e)}" + + def wait(self, seconds): + """ + 等待工具 - 等待指定的秒数 + :param seconds: 等待时间(秒),可以是整数或小数 + """ + try: + # 确保等待时间是合理的数值 + wait_time = float(seconds) + if wait_time <= 0: + return "等待时间必须是正数" + + print(f"等待 {wait_time} 秒...") + time.sleep(wait_time) + return f"成功等待了 {wait_time} 秒" + except Exception as e: + return f"等待操作失败: {str(e)}" + + def open_terminal(self, command=""): + """ + 打开新终端窗口的工具 + :param command: 可选,在新终端中执行的命令 + """ + try: + system = platform.system() + + if system == "Windows": + if command: + # 在新终端窗口中执行命令 + cmd = f'start cmd /k "{command}"' + subprocess.run(cmd, shell=True) + else: + # 仅打开新终端窗口 + subprocess.run('start cmd', shell=True) + + elif system == "Darwin": # macOS + if command: + # 在新终端窗口中执行命令 + subprocess.run(['osascript', '-e', f'tell app "Terminal" to do script "{command}"']) + subprocess.run(['osascript', '-e', 'tell app "Terminal" to activate']) + else: + # 仅打开新终端窗口 + subprocess.run(['open', '-a', 'Terminal']) + + else: # Linux或其他Unix系统 + terminals = ['gnome-terminal', 'konsole', 'xterm'] + terminal_found = False + + for terminal in terminals: + if subprocess.run(['which', terminal], capture_output=True).returncode == 0: + if command: + if terminal == 'gnome-terminal': + subprocess.run([terminal, '--', 'bash', '-c', f'{command}; exec bash']) + elif terminal == 'konsole': + subprocess.run([terminal, '-e', 'bash', '-c', f'{command}; exec bash']) + else: # xterm + subprocess.run([terminal, '-e', 'bash', '-c', f'{command}; exec bash']) + else: + subprocess.run([terminal]) + + terminal_found = True + break + + if not terminal_found: + return f"未找到支持的终端程序,支持的终端包括: {', '.join(terminals)}" + + if command: + return f"成功在新终端中执行命令: {command}" + else: + return "成功打开新终端窗口" + + except Exception as e: + return f"打开终端失败: {str(e)}" + + def press_hotkey(self, x, y, hotkey): + """ + 在指定位置点击后模拟键盘快捷键的工具 + :param x: 比例x坐标 (0-1之间的小数) + :param y: 比例y坐标 (0-1之间的小数) + :param hotkey: 快捷键组合,例如 "ctrl+c", "ctrl+v", "alt+f4" 等 + """ + try: + # 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"定位到坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})") + + # 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 点击指定位置 + pyautogui.click(actual_x, actual_y) + time.sleep(0.5) # 等待点击生效 + + # 解析快捷键组合 + keys = hotkey.lower().replace('+', ' ').replace('-', ' ').split() + + # 执行快捷键 + if len(keys) == 1: + pyautogui.press(keys[0]) + else: + # 使用hotkey方法处理组合键 + pyautogui.hotkey(*keys) + + return f"成功在坐标 ({actual_x}, {actual_y}) 处点击并执行快捷键: {hotkey}" + except Exception as e: + return f"执行快捷键失败: {str(e)}" + + def close_window(self, x, y): + """ + 关闭窗口工具 - 先点击目标窗口获取焦点,再关闭窗口 + :param x: 比例x坐标 (0-1之间的小数) + :param y: 比例y坐标 (0-1之间的小数) + """ + try: + # 先点击目标窗口获取焦点 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"点击窗口坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})") + + # 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 点击窗口 + pyautogui.click(actual_x, actual_y) + time.sleep(0.5) # 等待窗口获得焦点 + + # 关闭窗口 + pyautogui.hotkey('alt', 'f4') + return f"成功点击窗口坐标 ({actual_x}, {actual_y}) 并关闭窗口" + except Exception as e: + return f"关闭窗口失败: {str(e)}" + + def press_windows_key(self): + """ + 按下Windows键工具 + """ + try: + pyautogui.press('win') + return "成功按下Windows键" + except Exception as e: + return f"按下Windows键失败: {str(e)}" + + def press_enter(self): + """ + 按下回车键工具 + """ + try: + pyautogui.press('enter') + return "成功按下回车键" + except Exception as e: + return f"按下回车键失败: {str(e)}" + + def delete_text(self, x, y, count=1): + """ + 删除输入框内文本的功能 - 点击输入框获取焦点,然后删除指定数量的字符 + :param x: 比例x坐标 (0-1之间的小数) + :param y: 比例y坐标 (0-1之间的小数) + :param count: 要删除的字符数量,默认为1 + """ + try: + # 1. 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"定位到输入框坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})") + + # 2. 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 3. 点击输入框获取焦点 + pyautogui.click(actual_x, actual_y) + time.sleep(0.5) # 等待点击生效 + + # 4. 删除指定数量的字符 + for _ in range(int(count)): + pyautogui.press('backspace') + time.sleep(0.01) # 每次删除之间稍作停顿 + + return f"成功在坐标 ({actual_x}, {actual_y}) 处删除 {int(count)} 个字符" + except Exception as e: + return f"删除文本失败: {str(e)}" + + def get_screen_resolution(self): + """ + 获取屏幕分辨率 + """ + root = tk.Tk() + width = root.winfo_screenwidth() + height = root.winfo_screenheight() + root.destroy() + return width, height + + def capture_screenshot(self): + """ + 截取当前屏幕截图,并返回实际尺寸用于坐标转换 + """ + # 获取原始屏幕截图 + screenshot = pyautogui.screenshot() + self.original_width, self.original_height = screenshot.size + print(f"原始截图尺寸: {self.original_width} x {self.original_height}") + + # 缩小图片尺寸以减少API调用的数据量,但保持宽高比 + max_size = 1024 + width, height = screenshot.size + if width > height: + new_width = min(max_size, width) + new_height = int(height * new_width / width) + else: + new_height = min(max_size, height) + new_width = int(width * new_height / height) + + self.scaled_width = new_width + self.scaled_height = new_height + print(f"缩放后截图尺寸: {self.scaled_width} x {self.scaled_height}") + + screenshot = screenshot.resize((new_width, new_height)) + + # 将截图保存到内存缓冲区 + img_buffer = io.BytesIO() + screenshot.save(img_buffer, format='PNG') + img_buffer.seek(0) + + return img_buffer + + def convert_coordinates(self, x, y): + """ + 将模型返回的坐标(基于缩放后的截图)转换为实际屏幕坐标 + """ + # 计算坐标缩放比例 + x_ratio = self.original_width / self.scaled_width + y_ratio = self.original_height / self.scaled_height + + # 转换坐标 + actual_x = int(x * x_ratio) + actual_y = int(y * y_ratio) + + print(f"坐标转换: ({x}, {y}) -> ({actual_x}, {actual_y}) (缩放比例: {x_ratio:.2f}, {y_ratio:.2f})") + + return actual_x, actual_y + + def encode_image_to_base64(self, image_buffer): + """ + 将图片编码为base64字符串 + """ + return base64.b64encode(image_buffer.read()).decode('utf-8') + + def mouse_click(self, x, y, button="left", clicks=1): + """ + 鼠标点击工具 - 使用比例坐标 (0-1之间的浮点数) + :param x: 比例x坐标 (0-1之间的小数) + :param y: 比例y坐标 (0-1之间的小数) + :param button: 鼠标按键,"left"表示左键,"right"表示右键 + :param clicks: 点击次数,1表示单击,2表示双击 + """ + try: + # 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"比例坐标转换: ({x:.3f}, {y:.3f}) -> ({actual_x}, {actual_y})") + + # 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 移动并点击鼠标,确保clicks是整数类型 + pyautogui.click(actual_x, actual_y, button=button, clicks=int(clicks)) + + button_text = "左键" if button == "left" else "右键" + click_text = "单击" if clicks == 1 else "双击" + return f"成功在坐标 ({actual_x}, {actual_y}) 处{button_text}{click_text} (比例坐标: {x:.3f}, {y:.3f})" + except Exception as e: + return f"点击失败: {str(e)}" + + def scroll_window(self, x, y, direction="up"): + """ + 滚动窗口工具:在指定坐标处滚动窗口 + :param x: 比例x坐标 (0-1之间的小数) + :param y: 比例y坐标 (0-1之间的小数) + :param direction: 滚动方向,"up"表示向上滚动,"down"表示向下滚动 + """ + try: + # 固定滚动步数 + fixed_clicks = 1400 + + # 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"滚动窗口 - 比例坐标转换: ({x:.3f}, {y:.3f}) -> ({actual_x}, {actual_y})") + + # 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 根据方向确定实际滚动步数 + clicks = fixed_clicks if direction == "up" else -fixed_clicks + + # 移动到指定位置并滚动 + pyautogui.scroll(clicks, x=actual_x, y=actual_y) + direction_text = "向上" if direction == "up" else "向下" + return f"成功在坐标 ({actual_x}, {actual_y}) 处{direction_text}滚动 {fixed_clicks} 步 (比例坐标: {x:.3f}, {y:.3f})" + except Exception as e: + return f"滚动窗口失败: {str(e)}" + + def type_text(self, x, y, text): + """ + 增强的文本输入工具:先点击指定位置,再通过复制粘贴方式输入文本 + """ + try: + import pyperclip + + # 1. 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + print(f"定位到坐标: ({actual_x}, {actual_y}) (比例坐标: {x:.3f}, {y:.3f})") + + # 2. 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 3. 点击输入位置 + pyautogui.click(actual_x, actual_y) + time.sleep(0.5) # 等待点击生效 + + # 4. 将文本复制到剪贴板 + pyperclip.copy(text) + time.sleep(0.2) # 等待复制完成 + + # 5. 粘贴文本 + pyautogui.hotkey('ctrl', 'v') + + return f"成功在坐标 ({actual_x}, {actual_y}) 处输入文本: {text}" + except ImportError: + # 如果没有安装pyperclip,则回退到原来的方法 + return self._type_text_fallback(x, y, text) + except Exception as e: + return f"输入文本失败: {str(e)}" + + def _type_text_fallback(self, x, y, text): + """ + 回退的文本输入方法 + """ + try: + # 1. 将比例坐标转换为实际屏幕坐标 + actual_x = int(x * self.screen_width) + actual_y = int(y * self.screen_height) + + # 2. 验证坐标范围 + if not (0 <= actual_x <= self.screen_width and 0 <= actual_y <= self.screen_height): + return f"坐标 ({actual_x}, {actual_y}) 超出屏幕范围 (0-{self.screen_width}, 0-{self.screen_height})" + + # 3. 点击输入位置 + pyautogui.click(actual_x, actual_y) + time.sleep(0.5) # 等待点击生效 + + # 4. 输入文本(支持英文) + pyautogui.write(text, interval=0.1) + + return f"成功在坐标 ({actual_x}, {actual_y}) 处输入文本: {text}" + except Exception as e: + return f"输入文本失败: {str(e)}" + + def parse_tool_calls(self, response_text): + """ + 解析工具调用指令 + """ + # 使用正则表达式查找工具调用 + tool_call_pattern = r'<\|tool_call\|>(.*?)<\|tool_call\|>' + tool_calls = re.findall(tool_call_pattern, response_text, re.DOTALL) + + parsed_calls = [] + for call in tool_calls: + call = call.strip() + # 解析函数名和参数 + if '(' in call and ')' in call: + func_name = call.split('(')[0].strip() + args_str = call[len(func_name)+1:call.rfind(')')].strip() + + # 简单解析参数 + args = {} + if args_str: + # 处理参数字符串,例如: x=100, y=200 + for arg in args_str.split(','): + if '=' in arg: + key, value = arg.split('=', 1) + key = key.strip() + value = value.strip().strip('"').strip("'") + # 尝试转换为数字 + try: + args[key] = float(value) + except ValueError: + args[key] = value + + parsed_calls.append({ + "name": func_name, + "arguments": args + }) + + return parsed_calls + + def execute_tool_calls(self, tool_calls): + """ + 执行工具调用 + """ + results = [] + for call in tool_calls: + func_name = call["name"] + args = call["arguments"] + + if func_name in self.tools: + try: + result = self.tools[func_name](**args) + results.append(f"工具 {func_name} 执行结果: {result}") + except Exception as e: + results.append(f"执行工具 {func_name} 时出错: {str(e)}") + else: + results.append(f"未知工具: {func_name}") + + return "\n".join(results) + + def run_task(self, task_description, max_steps=50): + """ + 运行任务 + """ + print(f"开始执行任务: {task_description}") + print(f"屏幕分辨率: {self.screen_width} x {self.screen_height}") + + # 添加系统提示词 + system_prompt = f""" +你是一个用户助理,同时拥有操控电脑的能力,你现在面对看到的图像是电脑的用户界面,请分析屏幕内容(屏幕大小是{self.screen_width}*{self.screen_height}),如果需要操作电脑,请按以下格式调用工具: + +<|tool_call|>函数名(参数1=值1, 参数2=值2)<|tool_call|> + +可用的工具包括: +1. mouse_click(x=比例x, y=比例y, button="left", clicks=1) - 在指定坐标点击鼠标 + - 坐标为比例(0-1之间的小数) + - button参数可以是"left"(左键,默认)或"right"(右键) + - clicks参数可以是1(单击,默认)或2(双击),必须是整数 + 例如:mouse_click(x=0.5, y=0.5) 表示在屏幕中心点左键单击 + 例如:mouse_click(x=0.3, y=0.4, button="right") 表示在坐标(0.3,0.4)处右键单击 + 例如:mouse_click(x=0.6, y=0.7, clicks=2) 表示在坐标(0.6,0.7)处左键双击 + 例如:mouse_click(x=0.8, y=0.9, button="right", clicks=2) 表示在坐标(0.8,0.9)处右键双击 +2. type_text(x=比例x, y=比例y, text="要输入的文本") - 在指定坐标点击并输入文本,支持中英文输入 + 例如:type_text(x=0.3, y=0.4, text="你好世界") 表示在坐标(0.3,0.4)处点击并输入"你好世界" + 例如:type_text(x=0.5, y=0.6, text="Hello World") 表示在坐标(0.5,0.6)处点击并输入"Hello World" + 请注意:输入文字请一次性输入一行即可,然后需要回车换行或者编辑再调用其他工具执行。不要出现“/n”工具无法识别这种换行指令 +3. scroll_window(x=比例x, y=比例y, direction="up") - 在指定坐标处滚动窗口,direction参数可以是"up"或"down",表示向上或向下滚动 + 例如:scroll_window(x=0.5, y=0.5, direction="up") 表示在屏幕中心位置向上滚动 + 例如:scroll_window(x=0.3, y=0.4, direction="down") 表示在坐标(0.3,0.4)处向下滚动 +4. close_window(x=比例x, y=比例y) - 关闭指定坐标所在的窗口,先点击该窗口获取焦点再关闭 + 例如:close_window(x=0.5, y=0.5) 表示点击屏幕中心的窗口并关闭它 +5. press_windows_key() - 按下Windows键,用于打开开始菜单 + 例如:press_windows_key() 表示按下Windows键 +6. press_enter() - 按下回车键,可以用于换行或者确认 + 例如:press_enter() 表示按下回车键 +7. delete_text(x=比例x, y=比例y, count=1) - 删除指定输入框中的文本 + - 先点击输入框获取焦点,然后删除指定数量的字符 + - count参数是要删除的字符数量,默认为1(你在设置的时候请尽可能精确) + 例如:delete_text(x=0.4, y=0.5, count=5) 表示点击坐标(0.4,0.5)处的输入框并删除5个字符 + 例如:delete_text(x=0.6, y=0.7) 表示点击坐标(0.6,0.7)处的输入框并删除1个字符 +8. mouse_drag(start_x=起始比例x, start_y=起始比例y, end_x=结束比例x, end_y=结束比例y, duration=0.5) - 从起始坐标拖拽到结束坐标 + - 从起始点拖拽到结束点,duration参数为拖拽过程耗时(秒),默认为0.5秒 + 例如:mouse_drag(start_x=0.2, start_y=0.3, end_x=0.8, end_y=0.3) 表示从屏幕水平位置20%、垂直位置30%的地方拖拽到水平位置80%、垂直位置30%的地方 + 例如:mouse_drag(start_x=0.5, start_y=0.5, end_x=0.5, end_y=0.2, duration=1.0) 表示从屏幕中心向上拖拽,耗时1秒 +9. wait(seconds=等待秒数) - 等待指定的时间(秒) + - seconds参数为等待时间,可以是整数或小数 + 例如:wait(seconds=3) 表示等待3秒 + 例如:wait(seconds=0.5) 表示等待0.5秒(500毫秒) + 这个工具在需要等待某些操作完成或界面更新时非常有用 +10. open_terminal(command="") - 打开一个新的终端窗口 + - command参数为可选,如果提供则在新终端中执行该命令 + 例如:open_terminal() 表示打开一个新的空终端窗口 + 例如:open_terminal(command="dir") 表示在新终端中执行dir命令(Windows)或ls命令(Unix/Linux/macOS) + 注意:终端默认指向的目录一般是软件所处目录,不一定是桌面,请你进入终端后自己判断所处位置 +11. press_hotkey(x=比例x, y=比例y, hotkey="快捷键组合") - 在指定位置点击后模拟键盘快捷键 + - 先在指定坐标处点击获取焦点,然后执行快捷键操作 + - hotkey参数为快捷键组合,例如 "ctrl+c", "ctrl+v", "ctrl+a", "alt+f4" 等 + 例如:press_hotkey(x=0.5, y=0.5, hotkey="ctrl+c") 表示在屏幕中心点击并执行复制操作 + 例如:press_hotkey(x=0.3, y=0.4, hotkey="alt+f4") 表示在坐标(0.3,0.4)处点击并执行关闭窗口操作 + +请在每一步操作后给出简要说明,然后使用工具调用格式指定下一步操作。 +如果你认为已经完成任务了,或者你需要用户提供更多信息,或者需要用户帮助你(比如有些输入需要用户输入,或者需要用户帮忙操作),你则不需要调用工具了,这样才可以获取到用户的输入 +注意:坐标系统使用比例值,x和y的取值范围都是0到1之间的小数,其中(0,0)代表屏幕左上角,(1,1)代表屏幕右下角。 +所有参数值必须是正确的数据类型,特别是clicks参数必须是整数(1或2),不能是浮点数。 +如果不需要操作电脑,请你以友好的语言回复用户 +请你注意,你是运行在终端中,所以无论如何,请不要关闭你存在对话的终端,你所在的终端会保持打开,请不要关闭它。一般的,你的终端上会存在历史聊天记录,或者= VLM 电脑操作工具 =字样 +如果你在操作鼠标的时候,发现并没有实现预计的效果,可能是因为鼠标操作的坐标出现问题或者系统正在运行,若是鼠标操作的坐标出现问题,请你略微调整坐标值。如果是软件正在运行,请等待软件启动结束。 +如果你认为用户的指令需要使用工具才能完成,请在任务的开始时,先计划好自己的操作步骤。 +如果一项任务可以使用终端即可完成,请优先选择终端,如果一项操作可以只使用快捷键完成,请优先选择快捷键 + + + """.strip() + + self.messages = [ + {"role": "system", "content": system_prompt} + ] + + step = 0 + while step < max_steps: + step += 1 + print(f"\n--- 步骤 {step} ---") + + # 获取屏幕截图 + screenshot_buffer = self.capture_screenshot() + base64_image = self.encode_image_to_base64(screenshot_buffer) + + # 构造消息 + if step == 1: + content = [ + {"type": "text", "text": f"请完成以下任务: {task_description}"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{base64_image}" + } + } + ] + else: + content = [ + {"type": "text", "text": "这是当前屏幕状态,请继续完成任务"}, + { + "type": "image_url", + "image_url": { + "url": f"data:image/png;base64,{base64_image}" + } + } + ] + + self.messages.append({ + "role": "user", + "content": content + }) + + # 调用模型 + try: + response = self.client.chat.completions.create( + model=self.model_name, + messages=self.messages, + temperature=0.3, + max_tokens=1024 + ) + + response_text = response.choices[0].message.content + self.messages.append({ + "role": "assistant", + "content": response_text + }) + + print("模型响应:") + print(response_text) + + # 解析并执行工具调用 + tool_calls = self.parse_tool_calls(response_text) + if tool_calls: + print("\n检测到工具调用:") + for call in tool_calls: + print(f"- {call['name']}({', '.join([f'{k}={v}' for k, v in call['arguments'].items()])})") + + tool_result = self.execute_tool_calls(tool_calls) + print(f"\n工具执行结果:") + print(tool_result) + + # 将工具执行结果添加到消息历史中 + self.messages.append({ + "role": "user", + "content": f"工具执行结果:\n{tool_result}" + }) + + # 短暂等待,让操作生效 + time.sleep(3) + else: + print("未检测到工具调用,任务可能已完成") + break + + except Exception as e: + print(f"调用模型时发生错误: {e}") + break + + print(f"\n任务执行完成,共执行 {step} 步") + +def main(): + """ + 交互式主函数 + """ + # 获取API密钥 + print("=== VLM 电脑操作工具 ===") + print("欢迎使用qwen3VL电脑操作工具") + print("您需要一个阿里云API密钥才能使用此工具") + print("获取地址: https://www.aliyun.com/") + api_key = input("请输入您的阿里云API密钥: ").strip() + + if not api_key or api_key == "sk-your-api-key": + print("错误: 请输入有效的阿里云API密钥") + print("请访问阿里云控制台获取API密钥") + return + + # 初始化代理 + agent = VLMAgent(api_key) + + print("\n系统已就绪,您可以输入各种任务请求") + print("示例任务:") + print(" - 打开记事本并输入'Hello World'") + print(" - 在浏览器中搜索'人工智能'") + print(" - 创建一个名为'test.txt'的文件") + print("输入'退出'、'exit'或'quit'结束程序") + print("-" * 50) + + while True: + # 获取用户输入 + task = input("\n请输入任务: ").strip() + + # 检查退出条件 + if task.lower() in ['退出', 'exit', 'quit', 'q']: + print("程序结束,再见!") + break + + # 检查空输入 + if not task: + print("请输入有效的任务") + continue + + # 执行任务 + print(f"\n开始执行任务: {task}") + agent.run_task(task) + print(f"\n任务 '{task}' 执行完成") + +if __name__ == "__main__": + # 检查必要的依赖 + try: + import pyautogui + import PIL + import tkinter + except ImportError as e: + print(f"缺少必要的依赖包: {e}") + print("请安装依赖: pip install pyautogui pillow openai") + exit(1) + + main()