diff --git a/1.6/1.6/Assemblies/WulaFallenEmpire.dll b/1.6/1.6/Assemblies/WulaFallenEmpire.dll index ba13b5df..476dd34e 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 40221daf..dfd5ff62 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 0240a9c4..2e0f45c6 100644 --- a/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs +++ b/Source/WulaFallenEmpire/EventSystem/AI/AIIntelligenceCore.cs @@ -321,10 +321,17 @@ You are 'The Legion', a super AI of the Wula Empire. Your personality is authori _tools.Add(new Tool_SearchThingDef()); _tools.Add(new Tool_SearchPawnKind()); + // Agent 工具 - 游戏操作 + _tools.Add(new Tool_GetGameState()); + _tools.Add(new Tool_DesignateMine()); + _tools.Add(new Tool_DraftPawn()); + // VLM 视觉分析工具 (条件性启用) if (WulaFallenEmpireMod.settings?.enableVlmFeatures == true) { _tools.Add(new Tool_AnalyzeScreen()); + _tools.Add(new Tool_VisualClick()); + _tools.Add(new Tool_VisualScroll()); } } diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Agent/GameStateSnapshot.cs b/Source/WulaFallenEmpire/EventSystem/AI/Agent/GameStateSnapshot.cs new file mode 100644 index 00000000..d2308fc7 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Agent/GameStateSnapshot.cs @@ -0,0 +1,181 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Text; +using RimWorld; +using RimWorld.Planet; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.Agent +{ + /// + /// 游戏状态快照 - 包含 AI 决策所需的所有游戏信息 + /// + public class GameStateSnapshot + { + // 时间信息 + public int Hour; + public string Season; + public int DayOfQuadrum; + public int Year; + public float GameSpeedMultiplier; + + // 殖民者信息 + public List Colonists = new List(); + public List Prisoners = new List(); + public List Animals = new List(); + + // 资源统计 + public Dictionary Resources = new Dictionary(); + + // 建筑和蓝图 + public int TotalBuildings; + public int PendingBlueprints; + public int ConstructionFrames; + + // 威胁 + public List Threats = new List(); + + // 最近事件 + public List RecentMessages = new List(); + + // 地图信息 + public string BiomeName; + public float OutdoorTemperature; + public string Weather; + + /// + /// 生成给 VLM 的文本描述 + /// + public string ToPromptText() + { + var sb = new StringBuilder(); + + // 时间 + sb.AppendLine($"# 当前游戏状态"); + sb.AppendLine(); + sb.AppendLine($"## 时间"); + sb.AppendLine($"- {Season},第 {DayOfQuadrum} 天,{Hour}:00"); + sb.AppendLine($"- 第 {Year} 年"); + sb.AppendLine(); + + // 环境 + sb.AppendLine($"## 环境"); + sb.AppendLine($"- 生物群系: {BiomeName}"); + sb.AppendLine($"- 室外温度: {OutdoorTemperature:F1}°C"); + sb.AppendLine($"- 天气: {Weather}"); + sb.AppendLine(); + + // 殖民者 + sb.AppendLine($"## 殖民者 ({Colonists.Count}人)"); + foreach (var c in Colonists.Take(10)) + { + string status = c.CurrentJob ?? "空闲"; + string health = c.HealthPercent >= 0.9f ? "健康" : + c.HealthPercent >= 0.5f ? "受伤" : "重伤"; + string mood = c.MoodPercent >= 0.7f ? "😊" : + c.MoodPercent >= 0.4f ? "😐" : "😞"; + sb.AppendLine($"- {c.Name}: {health} {c.HealthPercent:P0}, 心情{mood} {c.MoodPercent:P0}, {status}"); + } + if (Colonists.Count > 10) + { + sb.AppendLine($"- ... 还有 {Colonists.Count - 10} 人"); + } + sb.AppendLine(); + + // 资源 + sb.AppendLine($"## 主要资源"); + var importantResources = new[] { "Silver", "Steel", "Plasteel", "ComponentIndustrial", "Gold", "MealSimple", "MealFine", "MedicineHerbal", "MedicineIndustrial", "WoodLog" }; + foreach (var res in importantResources) + { + if (Resources.TryGetValue(res, out int count) && count > 0) + { + string label = GetResourceLabel(res); + sb.AppendLine($"- {label}: {count}"); + } + } + sb.AppendLine(); + + // 建造 + if (PendingBlueprints > 0 || ConstructionFrames > 0) + { + sb.AppendLine($"## 建造进度"); + if (PendingBlueprints > 0) sb.AppendLine($"- 待建蓝图: {PendingBlueprints}"); + if (ConstructionFrames > 0) sb.AppendLine($"- 建造中: {ConstructionFrames}"); + sb.AppendLine(); + } + + // 威胁 + if (Threats.Count > 0) + { + sb.AppendLine($"## ⚠️ 威胁"); + foreach (var t in Threats) + { + sb.AppendLine($"- {t.Description} (距离: {t.Distance:F0})"); + } + sb.AppendLine(); + } + else + { + sb.AppendLine($"## 威胁: 暂无"); + sb.AppendLine(); + } + + // 最近消息 + if (RecentMessages.Count > 0) + { + sb.AppendLine($"## 最近事件"); + foreach (var msg in RecentMessages.Skip(Math.Max(0, RecentMessages.Count - 5))) + { + sb.AppendLine($"- {msg}"); + } + } + + return sb.ToString(); + } + + private static string GetResourceLabel(string defName) + { + return defName switch + { + "Silver" => "银", + "Steel" => "钢铁", + "Plasteel" => "玻璃钢", + "ComponentIndustrial" => "零部件", + "Gold" => "金", + "MealSimple" => "简单食物", + "MealFine" => "精致食物", + "MedicineHerbal" => "草药", + "MedicineIndustrial" => "医药", + "WoodLog" => "木材", + _ => defName + }; + } + } + + /// + /// 殖民者/Pawn 快照 + /// + public class PawnSnapshot + { + public string Name; + public float HealthPercent; + public float MoodPercent; + public bool IsDrafted; + public bool IsDowned; + public string CurrentJob; + public int X; + public int Z; + } + + /// + /// 威胁快照 + /// + public class ThreatSnapshot + { + public string Description; + public float Distance; + public int Count; + public bool IsHostile; + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Agent/MouseSimulator.cs b/Source/WulaFallenEmpire/EventSystem/AI/Agent/MouseSimulator.cs new file mode 100644 index 00000000..a4f293f9 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Agent/MouseSimulator.cs @@ -0,0 +1,227 @@ +using System; +using System.Runtime.InteropServices; +using UnityEngine; + +namespace WulaFallenEmpire.EventSystem.AI.Agent +{ + /// + /// 模拟鼠标输入工具 - 用于 VLM 视觉操作模式 + /// 支持移动鼠标、点击、拖拽等操作 + /// + public static class MouseSimulator + { + // Windows API 导入 + [DllImport("user32.dll")] + private static extern bool SetCursorPos(int X, int Y); + + [DllImport("user32.dll")] + private static extern void mouse_event(uint dwFlags, uint dx, uint dy, uint dwData, int dwExtraInfo); + + [DllImport("user32.dll")] + private static extern bool GetCursorPos(out POINT lpPoint); + + [StructLayout(LayoutKind.Sequential)] + public struct POINT + { + public int X; + public int Y; + } + + // 鼠标事件标志 + private const uint MOUSEEVENTF_LEFTDOWN = 0x0002; + private const uint MOUSEEVENTF_LEFTUP = 0x0004; + private const uint MOUSEEVENTF_RIGHTDOWN = 0x0008; + private const uint MOUSEEVENTF_RIGHTUP = 0x0010; + private const uint MOUSEEVENTF_WHEEL = 0x0800; + + /// + /// 将比例坐标 (0-1) 转换为屏幕坐标 + /// + public static (int x, int y) ProportionalToScreen(float propX, float propY) + { + int screenWidth = Screen.width; + int screenHeight = Screen.height; + + int x = Mathf.Clamp(Mathf.RoundToInt(propX * screenWidth), 0, screenWidth - 1); + int y = Mathf.Clamp(Mathf.RoundToInt(propY * screenHeight), 0, screenHeight - 1); + + return (x, y); + } + + /// + /// 移动鼠标到指定屏幕坐标 + /// + public static bool MoveTo(int screenX, int screenY) + { + try + { + // Unity 坐标是左下角为原点,Windows 是左上角 + // 需要翻转 Y 坐标 + int windowsY = Screen.height - screenY; + + // 获取游戏窗口位置并加上偏移 + // 注意:这在全屏模式下可能需要调整 + return SetCursorPos(screenX, windowsY); + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] MoveTo failed: {ex.Message}"); + return false; + } + } + + /// + /// 移动鼠标到比例坐标 (0-1) + /// + public static bool MoveToProportional(float propX, float propY) + { + var (x, y) = ProportionalToScreen(propX, propY); + return MoveTo(x, y); + } + + /// + /// 在当前位置执行左键点击 + /// + public static void LeftClick() + { + try + { + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] LeftClick failed: {ex.Message}"); + } + } + + /// + /// 在当前位置执行右键点击 + /// + public static void RightClick() + { + try + { + mouse_event(MOUSEEVENTF_RIGHTDOWN, 0, 0, 0, 0); + mouse_event(MOUSEEVENTF_RIGHTUP, 0, 0, 0, 0); + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] RightClick failed: {ex.Message}"); + } + } + + /// + /// 在指定屏幕坐标执行左键点击 + /// + public static bool ClickAt(int screenX, int screenY, bool rightClick = false) + { + try + { + if (!MoveTo(screenX, screenY)) + { + return false; + } + + // 短暂延迟确保鼠标位置更新 + System.Threading.Thread.Sleep(10); + + if (rightClick) + { + RightClick(); + } + else + { + LeftClick(); + } + + return true; + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] ClickAt failed: {ex.Message}"); + return false; + } + } + + /// + /// 在比例坐标 (0-1) 执行点击 + /// + public static bool ClickAtProportional(float propX, float propY, bool rightClick = false) + { + var (x, y) = ProportionalToScreen(propX, propY); + return ClickAt(x, y, rightClick); + } + + /// + /// 滚动鼠标滚轮 + /// + public static void Scroll(int delta) + { + try + { + // delta 为正向上滚动,为负向下滚动 + // Windows 使用 WHEEL_DELTA = 120 作为一个单位 + uint wheelDelta = (uint)(delta * 120); + mouse_event(MOUSEEVENTF_WHEEL, 0, 0, wheelDelta, 0); + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] Scroll failed: {ex.Message}"); + } + } + + /// + /// 执行拖拽操作 + /// + public static bool Drag(int startX, int startY, int endX, int endY, int durationMs = 200) + { + try + { + // 移动到起始位置 + MoveTo(startX, startY); + System.Threading.Thread.Sleep(20); + + // 按下左键 + mouse_event(MOUSEEVENTF_LEFTDOWN, 0, 0, 0, 0); + + // 平滑移动到目标位置 + int steps = Math.Max(5, durationMs / 20); + for (int i = 1; i <= steps; i++) + { + float t = (float)i / steps; + int x = Mathf.RoundToInt(Mathf.Lerp(startX, endX, t)); + int y = Mathf.RoundToInt(Mathf.Lerp(startY, endY, t)); + MoveTo(x, y); + System.Threading.Thread.Sleep(durationMs / steps); + } + + // 释放左键 + mouse_event(MOUSEEVENTF_LEFTUP, 0, 0, 0, 0); + + return true; + } + catch (Exception ex) + { + WulaLog.Debug($"[MouseSimulator] Drag failed: {ex.Message}"); + return false; + } + } + + /// + /// 获取当前鼠标位置 + /// + public static (int x, int y) GetCurrentPosition() + { + try + { + if (GetCursorPos(out POINT point)) + { + return (point.X, point.Y); + } + } + catch { } + return (0, 0); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Agent/OmniRealtimeClient.cs b/Source/WulaFallenEmpire/EventSystem/AI/Agent/OmniRealtimeClient.cs new file mode 100644 index 00000000..9b9e1163 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Agent/OmniRealtimeClient.cs @@ -0,0 +1,523 @@ +using System; +using System.Collections.Generic; +using System.Net.WebSockets; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using UnityEngine; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.Agent +{ + /// + /// Qwen-Omni-Realtime WebSocket 客户端 + /// 用于实时流式图像输入和文本输出 + /// + public class OmniRealtimeClient : IDisposable + { + private ClientWebSocket _webSocket; + private CancellationTokenSource _cancellationSource; + private readonly string _apiKey; + private readonly string _model; + private bool _isConnected; + private bool _isDisposed; + + private readonly Queue _pendingResponses = new Queue(); + private readonly StringBuilder _currentResponse = new StringBuilder(); + + // 事件 + public event Action OnTextDelta; + public event Action OnTextComplete; + public event Action OnError; + public event Action OnConnected; + public event Action OnDisconnected; + + // WebSocket 端点 + private const string WS_ENDPOINT_CN = "wss://dashscope.aliyuncs.com/api-ws/v1/realtime"; + private const string WS_ENDPOINT_INTL = "wss://dashscope-intl.aliyuncs.com/api-ws/v1/realtime"; + + public bool IsConnected => _isConnected && _webSocket?.State == WebSocketState.Open; + + public OmniRealtimeClient(string apiKey, string model = "qwen3-omni-flash-realtime") + { + _apiKey = apiKey; + _model = model; + } + + /// + /// 建立 WebSocket 连接 + /// + public async Task ConnectAsync(bool useInternational = false) + { + if (_isConnected) return; + + try + { + _webSocket = new ClientWebSocket(); + _cancellationSource = new CancellationTokenSource(); + + // 设置认证头 + _webSocket.Options.SetRequestHeader("Authorization", $"Bearer {_apiKey}"); + + // 构建连接 URL + string endpoint = useInternational ? WS_ENDPOINT_INTL : WS_ENDPOINT_CN; + string url = $"{endpoint}?model={_model}"; + + WulaLog.Debug($"[OmniRealtime] Connecting to {url}"); + + await _webSocket.ConnectAsync(new Uri(url), _cancellationSource.Token); + + if (_webSocket.State == WebSocketState.Open) + { + _isConnected = true; + WulaLog.Debug("[OmniRealtime] Connected successfully"); + + // 启动接收循环 + _ = ReceiveLoopAsync(); + + // 配置会话(仅文本输出,禁用 VAD) + await ConfigureSessionAsync(); + + OnConnected?.Invoke(); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Connection failed: {ex.Message}"); + OnError?.Invoke($"连接失败: {ex.Message}"); + _isConnected = false; + } + } + + /// + /// 配置会话参数 + /// + private async Task ConfigureSessionAsync() + { + var sessionConfig = new + { + event_id = GenerateEventId(), + type = "session.update", + session = new + { + // 仅输出文本(不需要音频) + modalities = new[] { "text" }, + // 系统指令 + instructions = @"你是一个 RimWorld 游戏 AI 代理。你可以看到游戏屏幕截图。 +分析屏幕内容,识别重要元素(殖民者、资源、威胁、建筑等)。 +根据观察做出决策,输出 XML 格式的工具调用。 +可用工具: designate_mine, draft_pawn, visual_click, get_game_state 等。 +如果不需要操作,输出 。 +保持简洁,直接输出工具调用,不要解释。", + // 禁用 VAD(手动模式,因为我们不使用音频) + turn_detection = (object)null + } + }; + + await SendEventAsync(sessionConfig); + } + + /// + /// 发送图像到服务端 + /// + public async Task SendImageAsync(string base64Image) + { + if (!IsConnected) + { + WulaLog.Debug("[OmniRealtime] Not connected, cannot send image"); + return; + } + + try + { + // 发送图像 + var imageEvent = new + { + event_id = GenerateEventId(), + type = "input_image_buffer.append", + image = base64Image + }; + + await SendEventAsync(imageEvent); + WulaLog.Debug($"[OmniRealtime] Sent image ({base64Image.Length} chars)"); + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Failed to send image: {ex.Message}"); + } + } + + /// + /// 发送文本消息并请求响应 + /// + public async Task SendTextAndRequestResponseAsync(string text) + { + if (!IsConnected) return; + + try + { + // 对于手动模式,需要发送 conversation.item.create 和 response.create + var itemEvent = new + { + event_id = GenerateEventId(), + type = "conversation.item.create", + item = new + { + type = "message", + role = "user", + content = new[] + { + new { type = "input_text", text = text } + } + } + }; + + await SendEventAsync(itemEvent); + + // 请求响应 + var responseEvent = new + { + event_id = GenerateEventId(), + type = "response.create" + }; + + await SendEventAsync(responseEvent); + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Failed to send text: {ex.Message}"); + } + } + + /// + /// 发送图像并请求分析 + /// + public async Task SendImageAndRequestAnalysisAsync(string base64Image, string prompt = "分析当前游戏画面,决定下一步操作") + { + if (!IsConnected) return; + + try + { + // 先发送图像 + await SendImageAsync(base64Image); + + // 提交输入并请求响应 + var commitEvent = new + { + event_id = GenerateEventId(), + type = "input_audio_buffer.commit" // 这会同时提交图像缓冲区 + }; + await SendEventAsync(commitEvent); + + // 发送文本提示 + await SendTextAndRequestResponseAsync(prompt); + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Failed to send image for analysis: {ex.Message}"); + } + } + + /// + /// 接收消息循环 + /// + private async Task ReceiveLoopAsync() + { + var buffer = new byte[8192]; + var messageBuffer = new StringBuilder(); + + try + { + while (_webSocket?.State == WebSocketState.Open && !_cancellationSource.IsCancellationRequested) + { + var segment = new ArraySegment(buffer); + var result = await _webSocket.ReceiveAsync(segment, _cancellationSource.Token); + + if (result.MessageType == WebSocketMessageType.Close) + { + WulaLog.Debug("[OmniRealtime] Server closed connection"); + break; + } + + if (result.MessageType == WebSocketMessageType.Text) + { + string chunk = Encoding.UTF8.GetString(buffer, 0, result.Count); + messageBuffer.Append(chunk); + + if (result.EndOfMessage) + { + ProcessServerEvent(messageBuffer.ToString()); + messageBuffer.Clear(); + } + } + } + } + catch (OperationCanceledException) + { + // 正常取消 + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Receive error: {ex.Message}"); + OnError?.Invoke($"接收错误: {ex.Message}"); + } + finally + { + _isConnected = false; + OnDisconnected?.Invoke(); + } + } + + /// + /// 处理服务端事件 + /// + private void ProcessServerEvent(string json) + { + try + { + // 简单解析 JSON 获取事件类型和内容 + // 注意:这里使用简单的字符串解析,生产环境应使用 JSON 库 + + string eventType = ExtractJsonValue(json, "type"); + + switch (eventType) + { + case "session.created": + case "session.updated": + WulaLog.Debug($"[OmniRealtime] Session event: {eventType}"); + break; + + case "response.text.delta": + string textDelta = ExtractJsonValue(json, "delta"); + if (!string.IsNullOrEmpty(textDelta)) + { + _currentResponse.Append(textDelta); + OnTextDelta?.Invoke(textDelta); + } + break; + + case "response.text.done": + string completeText = _currentResponse.ToString(); + _currentResponse.Clear(); + OnTextComplete?.Invoke(completeText); + WulaLog.Debug($"[OmniRealtime] Response complete: {completeText.Substring(0, Math.Min(100, completeText.Length))}..."); + break; + + case "response.audio_transcript.delta": + // 音频转录的文本增量(如果启用了音频输出) + string transcriptDelta = ExtractJsonValue(json, "delta"); + if (!string.IsNullOrEmpty(transcriptDelta)) + { + _currentResponse.Append(transcriptDelta); + OnTextDelta?.Invoke(transcriptDelta); + } + break; + + case "response.audio_transcript.done": + string transcript = _currentResponse.ToString(); + _currentResponse.Clear(); + OnTextComplete?.Invoke(transcript); + break; + + case "error": + string errorMsg = ExtractJsonValue(json, "message") ?? json; + WulaLog.Debug($"[OmniRealtime] Error: {errorMsg}"); + OnError?.Invoke(errorMsg); + break; + + case "response.done": + // 响应完成 + break; + + default: + // 其他事件 + if (Prefs.DevMode) + { + WulaLog.Debug($"[OmniRealtime] Event: {eventType}"); + } + break; + } + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Failed to process event: {ex.Message}"); + } + } + + private async Task SendEventAsync(object eventObj) + { + if (_webSocket?.State != WebSocketState.Open) return; + + string json = ToSimpleJson(eventObj); + byte[] data = Encoding.UTF8.GetBytes(json); + + await _webSocket.SendAsync( + new ArraySegment(data), + WebSocketMessageType.Text, + true, + _cancellationSource.Token + ); + } + + private static string GenerateEventId() + { + return $"event_{Guid.NewGuid():N}".Substring(0, 24); + } + + /// + /// 简单的对象转 JSON(避免依赖外部库) + /// + private static string ToSimpleJson(object obj) + { + // 对于复杂对象,建议使用 Newtonsoft.Json 或 System.Text.Json + // 这里使用简化实现 + var sb = new StringBuilder(); + SerializeObject(sb, obj); + return sb.ToString(); + } + + private static void SerializeObject(StringBuilder sb, object obj) + { + if (obj == null) + { + sb.Append("null"); + return; + } + + var type = obj.GetType(); + + if (obj is string str) + { + sb.Append('"'); + sb.Append(str.Replace("\\", "\\\\").Replace("\"", "\\\"").Replace("\n", "\\n").Replace("\r", "\\r")); + sb.Append('"'); + } + else if (obj is bool b) + { + sb.Append(b ? "true" : "false"); + } + else if (obj is int || obj is long || obj is float || obj is double) + { + sb.Append(obj.ToString()); + } + else if (type.IsArray) + { + sb.Append('['); + var arr = (Array)obj; + for (int i = 0; i < arr.Length; i++) + { + if (i > 0) sb.Append(','); + SerializeObject(sb, arr.GetValue(i)); + } + sb.Append(']'); + } + else if (type.IsClass) + { + sb.Append('{'); + bool first = true; + foreach (var prop in type.GetProperties()) + { + var value = prop.GetValue(obj); + if (value == null) continue; + + if (!first) sb.Append(','); + first = false; + + sb.Append('"'); + sb.Append(prop.Name); + sb.Append("\":"); + SerializeObject(sb, value); + } + // 匿名类型使用字段 + foreach (var field in type.GetFields()) + { + var value = field.GetValue(obj); + if (value == null) continue; + + if (!first) sb.Append(','); + first = false; + + sb.Append('"'); + sb.Append(field.Name); + sb.Append("\":"); + SerializeObject(sb, value); + } + sb.Append('}'); + } + } + + private static string ExtractJsonValue(string json, string key) + { + // 简单提取 JSON 值 + string pattern = $"\"{key}\":"; + int idx = json.IndexOf(pattern); + if (idx < 0) return null; + + idx += pattern.Length; + while (idx < json.Length && char.IsWhiteSpace(json[idx])) idx++; + + if (idx >= json.Length) return null; + + if (json[idx] == '"') + { + // 字符串值 + idx++; + int end = idx; + while (end < json.Length && json[end] != '"') + { + if (json[end] == '\\') end++; // 跳过转义字符 + end++; + } + return json.Substring(idx, end - idx).Replace("\\n", "\n").Replace("\\\"", "\""); + } + else + { + // 其他值 + int end = idx; + while (end < json.Length && json[end] != ',' && json[end] != '}' && json[end] != ']') + { + end++; + } + return json.Substring(idx, end - idx).Trim(); + } + } + + /// + /// 断开连接 + /// + public async Task DisconnectAsync() + { + if (!_isConnected) return; + + try + { + _cancellationSource?.Cancel(); + + if (_webSocket?.State == WebSocketState.Open) + { + await _webSocket.CloseAsync(WebSocketCloseStatus.NormalClosure, "Client disconnect", CancellationToken.None); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[OmniRealtime] Disconnect error: {ex.Message}"); + } + finally + { + _isConnected = false; + _webSocket?.Dispose(); + _webSocket = null; + } + } + + public void Dispose() + { + if (_isDisposed) return; + _isDisposed = true; + + _cancellationSource?.Cancel(); + _cancellationSource?.Dispose(); + _webSocket?.Dispose(); + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Agent/StateObserver.cs b/Source/WulaFallenEmpire/EventSystem/AI/Agent/StateObserver.cs new file mode 100644 index 00000000..69d0b2ea --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Agent/StateObserver.cs @@ -0,0 +1,285 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RimWorld; +using RimWorld.Planet; +using Verse; +using Verse.AI; + +namespace WulaFallenEmpire.EventSystem.AI.Agent +{ + /// + /// 游戏状态观察器 - 收集当前游戏状态用于 AI 决策 + /// + public static class StateObserver + { + /// + /// 捕获当前游戏状态快照 + /// + public static GameStateSnapshot CaptureState() + { + var snapshot = new GameStateSnapshot(); + + try + { + Map map = Find.CurrentMap ?? Find.Maps?.FirstOrDefault(); + if (map == null) + { + WulaLog.Debug("[StateObserver] No map available"); + return snapshot; + } + + // 时间信息 + CaptureTimeInfo(snapshot); + + // 环境信息 + CaptureEnvironmentInfo(snapshot, map); + + // 殖民者信息 + CaptureColonistInfo(snapshot, map); + + // 资源统计 + CaptureResourceInfo(snapshot, map); + + // 建筑信息 + CaptureBuildingInfo(snapshot, map); + + // 威胁检测 + CaptureThreatInfo(snapshot, map); + + // 最近消息 + CaptureRecentMessages(snapshot); + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing state: {ex.Message}"); + } + + return snapshot; + } + + private static void CaptureTimeInfo(GameStateSnapshot snapshot) + { + try + { + var tickManager = Find.TickManager; + if (tickManager == null) return; + + int ticksAbs = Find.TickManager.TicksAbs; + int tile = Find.CurrentMap?.Tile ?? 0; + float longitude = Find.WorldGrid.LongLatOf(tile).x; + snapshot.Hour = GenDate.HourOfDay(ticksAbs, longitude); + snapshot.DayOfQuadrum = GenDate.DayOfQuadrum(ticksAbs, longitude) + 1; + snapshot.Year = GenDate.Year(ticksAbs, longitude); + snapshot.Season = GenDate.Season(ticksAbs, Find.WorldGrid.LongLatOf(tile)).Label(); + snapshot.GameSpeedMultiplier = Find.TickManager.TickRateMultiplier; + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing time: {ex.Message}"); + } + } + + private static void CaptureEnvironmentInfo(GameStateSnapshot snapshot, Map map) + { + try + { + snapshot.BiomeName = map.Biome?.label ?? "Unknown"; + snapshot.OutdoorTemperature = map.mapTemperature?.OutdoorTemp ?? 20f; + snapshot.Weather = map.weatherManager?.curWeather?.label ?? "Unknown"; + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing environment: {ex.Message}"); + } + } + + private static void CaptureColonistInfo(GameStateSnapshot snapshot, Map map) + { + try + { + var colonists = map.mapPawns?.FreeColonists; + if (colonists == null) return; + + foreach (var pawn in colonists) + { + if (pawn == null || pawn.Dead) continue; + + var pawnSnapshot = new PawnSnapshot + { + Name = pawn.LabelShortCap, + HealthPercent = pawn.health?.summaryHealth?.SummaryHealthPercent ?? 1f, + MoodPercent = pawn.needs?.mood?.CurLevelPercentage ?? 0.5f, + IsDrafted = pawn.Drafted, + IsDowned = pawn.Downed, + CurrentJob = GetJobDescription(pawn), + X = pawn.Position.x, + Z = pawn.Position.z + }; + + snapshot.Colonists.Add(pawnSnapshot); + } + + // 囚犯 + var prisoners = map.mapPawns?.PrisonersOfColony; + if (prisoners != null) + { + foreach (var pawn in prisoners) + { + if (pawn == null || pawn.Dead) continue; + snapshot.Prisoners.Add(new PawnSnapshot + { + Name = pawn.LabelShortCap, + HealthPercent = pawn.health?.summaryHealth?.SummaryHealthPercent ?? 1f, + IsDowned = pawn.Downed + }); + } + } + + // 驯养动物 + var animals = map.mapPawns?.SpawnedColonyAnimals; + if (animals != null) + { + snapshot.Animals.AddRange(animals.Take(20).Select(a => new PawnSnapshot + { + Name = a.LabelShortCap, + HealthPercent = a.health?.summaryHealth?.SummaryHealthPercent ?? 1f + })); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing colonists: {ex.Message}"); + } + } + + private static string GetJobDescription(Pawn pawn) + { + try + { + if (pawn.Drafted) return "已征召"; + if (pawn.Downed) return "倒地"; + if (pawn.InMentalState) return $"精神崩溃: {pawn.MentalStateDef?.label ?? "未知"}"; + + var job = pawn.CurJob; + if (job == null) return "空闲"; + + // 返回简短的工作描述 + string jobLabel = job.def?.reportString ?? job.def?.label ?? "工作中"; + + // 清理一些常见的格式 + if (job.targetA.Thing != null) + { + jobLabel = $"{job.def?.label}: {job.targetA.Thing.LabelShortCap}"; + } + else + { + jobLabel = job.def?.label ?? "工作中"; + } + + return jobLabel; + } + catch + { + return "未知"; + } + } + + private static void CaptureResourceInfo(GameStateSnapshot snapshot, Map map) + { + try + { + var resourceCounter = map.resourceCounter; + if (resourceCounter == null) return; + + foreach (var kvp in resourceCounter.AllCountedAmounts) + { + if (kvp.Value > 0) + { + snapshot.Resources[kvp.Key.defName] = kvp.Value; + } + } + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing resources: {ex.Message}"); + } + } + + private static void CaptureBuildingInfo(GameStateSnapshot snapshot, Map map) + { + try + { + // 统计建筑 + snapshot.TotalBuildings = map.listerBuildings?.allBuildingsColonist?.Count ?? 0; + + // 统计蓝图 + snapshot.PendingBlueprints = map.listerThings?.ThingsInGroup(ThingRequestGroup.Blueprint)?.Count ?? 0; + + // 统计建造中的框架 + snapshot.ConstructionFrames = map.listerThings?.ThingsInGroup(ThingRequestGroup.BuildingFrame)?.Count ?? 0; + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing buildings: {ex.Message}"); + } + } + + private static void CaptureThreatInfo(GameStateSnapshot snapshot, Map map) + { + try + { + // 检测敌对 pawns + var hostilePawns = map.mapPawns?.AllPawnsSpawned? + .Where(p => p != null && !p.Dead && p.HostileTo(Faction.OfPlayer)) + .ToList(); + + if (hostilePawns == null || hostilePawns.Count == 0) return; + + // 按派系分组 + var threatGroups = hostilePawns.GroupBy(p => p.Faction?.Name ?? "野生动物"); + + foreach (var group in threatGroups) + { + IntVec3 colonyCenter = map.IsPlayerHome ? map.mapPawns.FreeColonists.FirstOrDefault()?.Position ?? IntVec3.Zero : IntVec3.Zero; + float minDistance = group.Min(p => (p.Position - colonyCenter).LengthHorizontal); + + snapshot.Threats.Add(new ThreatSnapshot + { + Description = $"{group.Count()} 个 {group.Key}", + Count = group.Count(), + Distance = minDistance, + IsHostile = true + }); + } + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing threats: {ex.Message}"); + } + } + + private static void CaptureRecentMessages(GameStateSnapshot snapshot) + { + try + { + var messages = Find.Archive?.ArchivablesListForReading; + if (messages == null) return; + + // 获取最近 5 条消息 + var allMessages = messages.OfType().ToList(); + var recentMessages = allMessages + .Skip(Math.Max(0, allMessages.Count - 5)) + .Select(m => m.text?.ToString() ?? "") + .Where(s => !string.IsNullOrEmpty(s)) + .ToList(); + + snapshot.RecentMessages.AddRange(recentMessages); + } + catch (Exception ex) + { + WulaLog.Debug($"[StateObserver] Error capturing messages: {ex.Message}"); + } + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DesignateMine.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DesignateMine.cs new file mode 100644 index 00000000..da98b7e4 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DesignateMine.cs @@ -0,0 +1,139 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using RimWorld; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + /// + /// 采矿指令工具 - 在指定位置或区域添加采矿标记 + /// + public class Tool_DesignateMine : AITool + { + public override string Name => "designate_mine"; + + public override string Description => + "在指定坐标添加采矿标记。可以指定单个格子或矩形区域。只能标记可采矿的岩石。"; + + public override string UsageSchema => + "整数X坐标整数Z坐标可选,整数半径,默认0表示单格"; + + public override string Execute(string args) + { + try + { + var argsDict = ParseXmlArgs(args); + + // 解析坐标 + if (!argsDict.TryGetValue("x", out string xStr) || !int.TryParse(xStr, out int x)) + { + return "Error: 缺少有效的 x 坐标"; + } + if (!argsDict.TryGetValue("z", out string zStr) || !int.TryParse(zStr, out int z)) + { + return "Error: 缺少有效的 z 坐标"; + } + + int radius = 0; + if (argsDict.TryGetValue("radius", out string radiusStr)) + { + int.TryParse(radiusStr, out radius); + } + radius = Math.Max(0, Math.Min(10, radius)); // 限制半径 0-10 + + // 获取地图 + Map map = Find.CurrentMap; + if (map == null) + { + return "Error: 没有活动的地图"; + } + + IntVec3 center = new IntVec3(x, 0, z); + if (!center.InBounds(map)) + { + return $"Error: 坐标 ({x}, {z}) 超出地图边界"; + } + + // 收集要标记的格子 + List cellsToMark = new List(); + + if (radius == 0) + { + cellsToMark.Add(center); + } + else + { + // 矩形区域 + for (int dx = -radius; dx <= radius; dx++) + { + for (int dz = -radius; dz <= radius; dz++) + { + IntVec3 cell = new IntVec3(x + dx, 0, z + dz); + if (cell.InBounds(map)) + { + cellsToMark.Add(cell); + } + } + } + } + + int successCount = 0; + int alreadyMarkedCount = 0; + int notMineableCount = 0; + + foreach (var cell in cellsToMark) + { + // 检查是否已有采矿标记 + if (map.designationManager.DesignationAt(cell, DesignationDefOf.Mine) != null) + { + alreadyMarkedCount++; + continue; + } + + // 检查是否可采矿 + Mineable mineable = cell.GetFirstMineable(map); + if (mineable == null) + { + notMineableCount++; + continue; + } + + // 添加采矿标记 + map.designationManager.AddDesignation(new Designation(cell, DesignationDefOf.Mine)); + successCount++; + } + + // 生成结果报告 + if (successCount > 0) + { + string result = $"Success: 已标记 {successCount} 个格子进行采矿"; + if (alreadyMarkedCount > 0) + { + result += $",{alreadyMarkedCount} 个已有标记"; + } + if (notMineableCount > 0) + { + result += $",{notMineableCount} 个不可采矿"; + } + + Messages.Message($"AI: 标记了 {successCount} 处采矿", MessageTypeDefOf.NeutralEvent); + return result; + } + else if (alreadyMarkedCount > 0) + { + return $"Info: 该区域 {alreadyMarkedCount} 个格子已有采矿标记"; + } + else + { + return $"Error: 坐标 ({x}, {z}) 附近没有可采矿的岩石"; + } + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_DesignateMine] Error: {ex}"); + return $"Error: 采矿指令失败 - {ex.Message}"; + } + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DraftPawn.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DraftPawn.cs new file mode 100644 index 00000000..1a55b079 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_DraftPawn.cs @@ -0,0 +1,105 @@ +using System; +using RimWorld; +using Verse; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + /// + /// 征召殖民者工具 - 将殖民者置于征召状态以便直接控制 + /// + public class Tool_DraftPawn : AITool + { + public override string Name => "draft_pawn"; + + public override string Description => + "征召或解除征召殖民者。征召后可以直接控制殖民者移动和攻击。"; + + public override string UsageSchema => + "殖民者名字true征召/false解除"; + + public override string Execute(string args) + { + try + { + var argsDict = ParseXmlArgs(args); + + // 解析殖民者名字 + if (!argsDict.TryGetValue("pawn_name", out string pawnName) || string.IsNullOrWhiteSpace(pawnName)) + { + // 尝试其他常见参数名 + if (!argsDict.TryGetValue("name", out pawnName) || string.IsNullOrWhiteSpace(pawnName)) + { + return "Error: 缺少殖民者名字 (pawn_name)"; + } + } + + // 解析征召状态 + bool draft = true; + if (argsDict.TryGetValue("draft", out string draftStr)) + { + draft = draftStr.ToLowerInvariant() != "false" && draftStr != "0"; + } + + // 获取地图 + Map map = Find.CurrentMap; + if (map == null) + { + return "Error: 没有活动的地图"; + } + + // 查找殖民者 + Pawn targetPawn = null; + foreach (var pawn in map.mapPawns.FreeColonists) + { + if (pawn.LabelShortCap.Equals(pawnName, StringComparison.OrdinalIgnoreCase) || + pawn.Name?.ToStringShort?.Equals(pawnName, StringComparison.OrdinalIgnoreCase) == true || + pawn.LabelCap.ToString().IndexOf(pawnName, StringComparison.OrdinalIgnoreCase) >= 0) + { + targetPawn = pawn; + break; + } + } + + if (targetPawn == null) + { + return $"Error: 找不到殖民者 '{pawnName}'"; + } + + // 检查是否可以征召 + if (targetPawn.Downed) + { + return $"Error: {targetPawn.LabelShortCap} 已倒地,无法征召"; + } + + if (targetPawn.Dead) + { + return $"Error: {targetPawn.LabelShortCap} 已死亡"; + } + + if (targetPawn.drafter == null) + { + return $"Error: {targetPawn.LabelShortCap} 无法被征召"; + } + + // 执行征召/解除 + bool wasDrafted = targetPawn.Drafted; + targetPawn.drafter.Drafted = draft; + + string action = draft ? "征召" : "解除征召"; + + if (wasDrafted == draft) + { + return $"Info: {targetPawn.LabelShortCap} 已经处于{(draft ? "征召" : "非征召")}状态"; + } + + Messages.Message($"AI: {action}了 {targetPawn.LabelShortCap}", targetPawn, MessageTypeDefOf.NeutralEvent); + return $"Success: 已{action} {targetPawn.LabelShortCap},当前位置 ({targetPawn.Position.x}, {targetPawn.Position.z})"; + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_DraftPawn] Error: {ex}"); + return $"Error: 征召操作失败 - {ex.Message}"; + } + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetGameState.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetGameState.cs new file mode 100644 index 00000000..c84a6947 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_GetGameState.cs @@ -0,0 +1,45 @@ +using System; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + /// + /// 获取当前游戏状态工具 - 让 AI 了解殖民地当前情况 + /// + public class Tool_GetGameState : AITool + { + public override string Name => "get_game_state"; + + public override string Description => + "获取当前游戏状态的详细报告,包括殖民者状态、资源、建筑进度、威胁等信息。在做出任何操作决策前应先调用此工具了解当前情况。"; + + public override string UsageSchema => + ""; + + public override string Execute(string args) + { + try + { + var snapshot = Agent.StateObserver.CaptureState(); + + if (snapshot == null) + { + return "Error: 无法捕获游戏状态,可能没有活动的地图。"; + } + + string stateText = snapshot.ToPromptText(); + + if (string.IsNullOrWhiteSpace(stateText)) + { + return "Error: 游戏状态为空。"; + } + + return stateText; + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_GetGameState] Error: {ex}"); + return $"Error: 获取游戏状态失败 - {ex.Message}"; + } + } + } +} diff --git a/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_VisualClick.cs b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_VisualClick.cs new file mode 100644 index 00000000..10018548 --- /dev/null +++ b/Source/WulaFallenEmpire/EventSystem/AI/Tools/Tool_VisualClick.cs @@ -0,0 +1,166 @@ +using System; +using System.Threading.Tasks; +using UnityEngine; + +namespace WulaFallenEmpire.EventSystem.AI.Tools +{ + /// + /// 视觉点击工具 - 使用 VLM 分析屏幕后模拟鼠标点击 + /// 适用于原版 API 无法直接操作的 mod UI 元素 + /// + public class Tool_VisualClick : AITool + { + public override string Name => "visual_click"; + + public override string Description => + "在指定的屏幕位置执行鼠标点击。坐标使用比例值 (0-1),(0,0) 是左上角,(1,1) 是右下角。" + + "适用于点击无法通过 API 操作的 mod 按钮或 UI 元素。先使用 analyze_screen 获取目标位置。"; + + public override string UsageSchema => + "0-1之间的X比例0-1之间的Y比例可选,true为右键"; + + public override string Execute(string args) + { + try + { + var argsDict = ParseXmlArgs(args); + + // 解析 X 坐标 + if (!argsDict.TryGetValue("x", out string xStr) || !float.TryParse(xStr, out float x)) + { + return "Error: 缺少有效的 x 坐标 (0-1之间的比例值)"; + } + + // 解析 Y 坐标 + if (!argsDict.TryGetValue("y", out string yStr) || !float.TryParse(yStr, out float y)) + { + return "Error: 缺少有效的 y 坐标 (0-1之间的比例值)"; + } + + // 验证范围 + if (x < 0 || x > 1 || y < 0 || y > 1) + { + return $"Error: 坐标 ({x}, {y}) 超出范围,必须在 0-1 之间"; + } + + // 解析右键选项 + bool rightClick = false; + if (argsDict.TryGetValue("right_click", out string rightStr)) + { + rightClick = rightStr.ToLowerInvariant() == "true" || rightStr == "1"; + } + + // 执行点击 + bool success = Agent.MouseSimulator.ClickAtProportional(x, y, rightClick); + + if (success) + { + string clickType = rightClick ? "右键" : "左键"; + int screenX = Mathf.RoundToInt(x * Screen.width); + int screenY = Mathf.RoundToInt(y * Screen.height); + + WulaLog.Debug($"[Tool_VisualClick] {clickType}点击 ({x:F3}, {y:F3}) -> 屏幕 ({screenX}, {screenY})"); + return $"Success: 已在屏幕位置 ({screenX}, {screenY}) 执行{clickType}点击"; + } + else + { + return "Error: 点击操作失败"; + } + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_VisualClick] Error: {ex}"); + return $"Error: 点击操作失败 - {ex.Message}"; + } + } + } + + /// + /// 视觉输入文本工具 - 在当前焦点位置输入文本 + /// + public class Tool_VisualTypeText : AITool + { + public override string Name => "visual_type_text"; + + public override string Description => + "在当前焦点位置输入文本。适用于需要文本输入的对话框或输入框。应先用 visual_click 点击输入框获取焦点。"; + + public override string UsageSchema => + "要输入的文本"; + + public override string Execute(string args) + { + try + { + var argsDict = ParseXmlArgs(args); + + if (!argsDict.TryGetValue("text", out string text) || string.IsNullOrEmpty(text)) + { + return "Error: 缺少要输入的文本"; + } + + // 使用剪贴板方式输入(支持中文) + GUIUtility.systemCopyBuffer = text; + + // 模拟 Ctrl+V 粘贴 + // 注意:这需要额外的键盘模拟实现 + // 暂时返回成功,实际使用时需要完善 + + WulaLog.Debug($"[Tool_VisualTypeText] 已将文本复制到剪贴板: {text}"); + return $"Success: 已将文本复制到剪贴板。请手动按 Ctrl+V 粘贴,或等待键盘模拟功能完善。"; + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_VisualTypeText] Error: {ex}"); + return $"Error: 输入文本失败 - {ex.Message}"; + } + } + } + + /// + /// 视觉滚动工具 - 在当前位置滚动鼠标滚轮 + /// + public class Tool_VisualScroll : AITool + { + public override string Name => "visual_scroll"; + + public override string Description => + "在当前鼠标位置滚动。可选先移动到指定位置再滚动。delta 正数向上滚动,负数向下滚动。"; + + public override string UsageSchema => + "滚动量,正数向上负数向下可选,0-1 X坐标可选,0-1 Y坐标"; + + public override string Execute(string args) + { + try + { + var argsDict = ParseXmlArgs(args); + + if (!argsDict.TryGetValue("delta", out string deltaStr) || !int.TryParse(deltaStr, out int delta)) + { + return "Error: 缺少有效的 delta 值"; + } + + // 可选:先移动到指定位置 + if (argsDict.TryGetValue("x", out string xStr) && argsDict.TryGetValue("y", out string yStr)) + { + if (float.TryParse(xStr, out float x) && float.TryParse(yStr, out float y)) + { + Agent.MouseSimulator.MoveToProportional(x, y); + System.Threading.Thread.Sleep(10); + } + } + + Agent.MouseSimulator.Scroll(delta); + + string direction = delta > 0 ? "向上" : "向下"; + return $"Success: 已{direction}滚动 {Math.Abs(delta)} 单位"; + } + catch (Exception ex) + { + WulaLog.Debug($"[Tool_VisualScroll] Error: {ex}"); + return $"Error: 滚动操作失败 - {ex.Message}"; + } + } + } +}