- Регистрация
- 24.12.2024
- Сообщения
- 56
- Благодарностей
- 117
- Баллы
- 33
КОНКУРСНЫЙ КЕЙС
ZennoDroid + LLM
ИИ берёт под контроль смартфон
Создание автономных Android-агентов в ZennoDroid
Полная архитектура · Исходные коды · Живые результаты
ZennoDroid + LLM
ИИ берёт под контроль смартфон
Создание автономных Android-агентов в ZennoDroid
Полная архитектура · Исходные коды · Живые результаты
О ЧЁМ СТАТЬЯ Вы устали переписывать шаблоны каждый раз, когда разработчики приложения меняют интерфейс?
В этом кейсе — готовая архитектура автономного мобильного агента, который сам «читает» экран,
сам принимает решения и сам себя проверяет. Никакого хардкода координат. Никаких XPath.
Связка ZennoDroid + локальные LLM (LLaMA 3 / LLaVA) — полный исходный код прилагается.
1. Проблема: почему мобильная автоматизация «ломается»
Если вы работаете с мобильным трафиком и автоматизацией Android-приложений, вы знаете эту боль наизусть. Мобильная вёрстка и логика приложений меняются куда чаще, чем десктопный веб. Сегодня кнопка называется btn_login, завтра это уже FrameLayout без id, а послезавтра разработчики перерисовали весь интерфейс к очередному минорному обновлению.
Классический ZennoDroid-подход — это шаблон с жёстко прописанными координатами или resource-id. Он работает ровно до следующего обновления APK, после чего вы возвращаетесь к девайсу, вручную маппите новые элементы и переписываете сотни экшенов. Цикл повторяется снова и снова.
Что конкретно не так с классическим подходом
- Хрупкость координат. Координата (540, 1280) валидна только для конкретной версии приложения и конкретного разрешения экрана.
- Нет адаптации. Если поп-ап «Оцените нас» сдвинул кнопку вниз, скрипт тапнет в пустоту и зависнет.
- Нет самопроверки. Клик прошёл — но приложение зависло. Скрипт этого не знает и идёт дальше.
- Нет переиспользования. Каждое новое приложение — это новые сотни экшенов с нуля.
2. Концепция: агент вместо кликера
Ключевая идея — перенос логики умных ИИ-агентов из классического ZennoPoster (где мы парсили HTML и DOM-дерево браузера) в суровую среду Android при помощи ZennoDroid.
В браузерной автоматизации у нас был DOM. В Android — только UIAutomator XML. Мы берём этот XML, превращаем его в структурированный JSON, скармливаем локальной LLM (LLaMA 3), и получаем готовую цепочку действий. Затем визуальная модель LLaVA проверяет результат по скриншотам.
Принципиальное отличие от классического подхода
| Компонент | Технология | Роль |
| Классический ZennoDroid | Жёсткие координаты | Ломается при каждом обновлении APK |
| Наш агент | LLM + UIAutomator XML | Сам находит элементы по семантике |
| Классическая проверка | Нет / ручная | Зависание скрипта при сбое |
| Наш Step Verifier | LLaVA + Pixel Diff | Автоматическая верификация каждого шага |
| При сбое координат | Остановка скрипта | COORD-REFRESH: пересчёт на лету |
ГЛАВНЫЙ ПРИНЦИП Скрипт не ищет тупо координату или XPath.
Агент сам разглядывает экран, анализирует иерархию элементов,
принимает осознанные решения о тапах и свайпах,
а затем сам себя проверяет — успешно ли прошло действие.
3. Архитектура: два слоя и семь компонентов
Система разделена на два чётких слоя — Frontend (ZennoDroid, работает с устройством) и Backend (Python + LLM, работает с данными). Взаимодействие идёт через файловый протокол: JSON и TXT-файлы в папке проекта.
Стек технологий
| Компонент | Технология | Роль |
| ZennoDroid | C# сниппеты | Frontend: эмулятор, тапы, скриншоты, ADB |
| AI Element Mapper | C# + UIAutomator | Парсинг XML экрана → компактный JSON |
| Logic Control Module | Python + LLaMA 3 | Анализ DOM → цепочка действий |
| Task Chain Planner | Python + LLM | Разбивка цели на группы по экранам |
| Action Executor | C# + DroidInstance | Физическое выполнение: тап, ввод, скролл |
| Step Verifier | Python + LLaVA | Верификация: Pixel Diff + DOM diff + ИИ-зрение |
| COORD-REFRESH | Python + LLM | Пересчёт координат при сбое без перезапуска |
Файловый протокол взаимодействия
Все компоненты общаются через простой набор файлов. Это решение намеренно: никаких сокетов, никаких зависимостей — только чтение/запись файлов, что идеально для многопоточной среды ZennoDroid.
goal.txt ← высокоуровневая цель пользователя
task.txt ← конкретная задача для текущего экрана
dom_input.txt ← JSON-иерархия экрана (пишет ZennoDroid)
action_chain.json ← цепочка шагов (пишет Logic Control Module)
next_action.txt ← текущий шаг к выполнению
step_status.txt ← статус выполненного шага
screenshot_before.png / screenshot.png ← скрины до/после
verify_result.txt ← результат верификации (OK / RETRY / WAIT)
task_refresh.txt ← промпт для пересчёта координат
context_store.json ← персистентная память агента между итерациями
ШАГ 1 AI Element Mapper — из слепого XML в понятный JSON
Это точка входа всей системы. Прежде чем ИИ сможет принимать решения, ему нужно «увидеть» экран. Android даёт нам UIAutomator XML — сырую иерархию всех View-элементов. Проблема в том, что этот XML содержит тысячи нод, огромное количество мусора и вложенности, которые LLM просто не переварит.
Наш AI Element Mapper (C#, сниппет «получить все») решает эту задачу в три этапа.
Этап 1.1: Получение XML от устройства
C#:
// Ждём рендер и получаем XML-иерархию экрана
System.Threading.Thread.Sleep(800);
string xmlLayout = instance.DroidInstance.Hierarchy.GetLayout();
// Параллельно читаем метаданные приложения
string currentApp = instance.DroidInstance.App.Top;
string currentActivity = instance.DroidInstance.Input.Shell(
"dumpsys activity activities | grep mResumedActivity");
Стандартный UIAutomator работает только с тегами <node>. Но реальные приложения используют FrameLayout, ConstraintLayout, CardView и десятки других типов. Наш парсер обрабатывает все теги, у которых есть атрибут bounds — независимо от имени тега.
C#:
// Парсим ВСЕ открывающие теги с атрибутом bounds
// (не только <node> как в стандартном UIAutomator)
void ParseAllTags(string xml, List<Dict<string,string>> result)
{
// Находим каждый открывающий тег
// Пропускаем теги без bounds (не содержат координаты)
if (!tagBody.Contains("bounds=")) continue;
// Парсим атрибуты: bounds, text, content-desc, resource-id,
// clickable, scrollable, enabled, focused и т.д.
ParseAttrs(tagBody, nameEnd - lt, tagBody.Length, attrs);
}
Каждый элемент классифицируется по семантическому типу: input, button, text, link, checkbox, list, scroll, webview и т.д. Результат — компактный JSON без мусора, готовый для LLM.
JSON:
{
"page": {
"app": "com.android.chrome",
"activity": "com.google.android.apps.chrome.Main",
"screen": { "w": 1080, "h": 1920 }
},
"elements": [
{ "id": 3, "tag": "input", "cx": 540, "cy": 165,
"label": "Search or type URL", "rid": "url_bar",
"focusable": true, "clickable": true },
{ "id": 7, "tag": "button", "cx": 54, "cy": 165,
"label": "Back", "clickable": true }
],
"stats": { "totalRaw": 284, "totalFiltered": 31 }
}
C#:
// ==============================================================================
// ZennoDroid AI Element Mapper v3.0
// Исправлен парсер XML — теперь обрабатывает ВСЕ теги (FrameLayout, TextView и т.д.)
// а не только <node> как в стандартном UIAutomator формате.
//
// Требования:
// Переменная проекта: AiDroidTree (сырой XML)
// Переменная проекта: AiDroidForAI (JSON для ИИ-агента)
// ==============================================================================
foreach (string varName in new[] { "AiDroidTree", "AiDroidForAI" })
{
if (!project.Variables.Keys.Contains(varName))
{
project.SendErrorToLog($"ОШИБКА: Создайте переменную '{varName}' в проекте!", true);
throw new Exception($"Переменная {varName} отсутствует.");
}
}
try
{
System.Threading.Thread.Sleep(800);
string xmlLayout = instance.DroidInstance.Hierarchy.GetLayout();
if (string.IsNullOrWhiteSpace(xmlLayout))
throw new Exception("GetLayout() вернул пустой XML.");
project.Variables["AiDroidTree"].Value = xmlLayout;
project.SendInfoToLog($"XML получен: {xmlLayout.Length} символов", true);
// ─── Метаданные ────────────────────────────────────────────────────────
string currentApp = "";
string currentActivity = "";
try { currentApp = instance.DroidInstance.App.Top ?? ""; } catch {}
try {
currentActivity = instance.DroidInstance.Input.Shell(
"dumpsys activity activities | grep mResumedActivity"
) ?? "";
currentActivity = currentActivity.Replace("\r","").Replace("\n","").Trim();
if (currentActivity.Length > 300) currentActivity = currentActivity.Substring(0, 300);
} catch {}
// ─── Парсинг XML ───────────────────────────────────────────────────────
// Размер экрана из атрибутов корневого тега <hierarchy>
int screenW = ParseAttrInt(xmlLayout, "width", 1600);
int screenH = ParseAttrInt(xmlLayout, "height", 900);
// Парсим ВСЕ открывающие теги у которых есть атрибут bounds
var elements = new System.Collections.Generic.List<
System.Collections.Generic.Dictionary<string,string>
>();
ParseAllTags(xmlLayout, elements);
project.SendInfoToLog($"Элементов найдено: {elements.Count}", true);
// ─── Строим JSON для ИИ ────────────────────────────────────────────────
var sb = new System.Text.StringBuilder();
sb.Append("{\"page\":{");
sb.Append($"\"app\":{Js(currentApp)},");
sb.Append($"\"activity\":{Js(currentActivity)},");
sb.Append($"\"screen\":{{\"w\":{screenW},\"h\":{screenH}}}");
sb.Append("},\"elements\":[");
int nodeId = 0, written = 0;
bool first = true;
foreach (var el in elements)
{
nodeId++;
// ── bounds ──────────────────────────────────────────────────────
string boundsStr = V(el, "bounds");
int[] b = ParseBounds(boundsStr);
if (b == null) continue;
int x1=b[0], y1=b[1], x2=b[2], y2=b[3];
int w=x2-x1, h=y2-y1;
if (w<=0 || h<=0) continue;
int cx=x1+w/2, cy=y1+h/2;
// ── атрибуты ────────────────────────────────────────────────────
string text = V(el,"text");
string desc = V(el,"content-desc");
string resId = V(el,"resource-id");
string cls = V(el,"class");
string pkg = V(el,"package");
string hint = V(el,"hint");
bool clickable = B(el,"clickable");
bool checkable = B(el,"checkable");
bool checked_ = B(el,"checked");
bool enabled = B(el,"enabled");
bool focusable = B(el,"focusable");
bool focused = B(el,"focused");
bool scrollable = B(el,"scrollable");
bool longClick = B(el,"long-clickable");
bool selected = B(el,"selected");
bool displayed = B(el,"displayed");
// Скрытые пропускаем (но если displayed не задан — считаем видимым)
bool visible = !el.ContainsKey("displayed") || displayed;
if (!visible) continue;
// ── тип ─────────────────────────────────────────────────────────
string tagName = V(el, "__tag"); // имя XML-тега
string uiType = ClassifyElement(tagName, cls, clickable, checkable, scrollable);
// ── лейбл ───────────────────────────────────────────────────────
string label = FirstNE(text, desc, hint);
if (!string.IsNullOrEmpty(label) && label.Length > 200)
label = label.Substring(0,200) + "…";
// ── короткие имена ──────────────────────────────────────────────
string shortId = (!string.IsNullOrEmpty(resId) && resId.Contains(":id/"))
? resId.Substring(resId.IndexOf(":id/")+4) : resId;
string shortCls = (!string.IsNullOrEmpty(cls) && cls.Contains("."))
? cls.Substring(cls.LastIndexOf('.')+1) : cls;
// ── пишем объект ────────────────────────────────────────────────
if (!first) sb.Append(",");
first = false;
sb.Append("{");
sb.Append($"\"id\":{nodeId}");
sb.Append($",\"tag\":{Js(uiType)}");
sb.Append($",\"x\":{x1},\"y\":{y1},\"w\":{w},\"h\":{h}");
sb.Append($",\"cx\":{cx},\"cy\":{cy}");
if (!string.IsNullOrEmpty(label)) sb.Append($",\"label\":{Js(label)}");
if (!string.IsNullOrEmpty(shortId)) sb.Append($",\"rid\":{Js(shortId)}");
if (!string.IsNullOrEmpty(shortCls)) sb.Append($",\"cls\":{Js(shortCls)}");
if (!string.IsNullOrEmpty(pkg)) sb.Append($",\"pkg\":{Js(pkg)}");
if (clickable) sb.Append(",\"clickable\":true");
if (checkable) sb.Append(",\"checkable\":true");
if (checked_) sb.Append(",\"checked\":true");
if (selected) sb.Append(",\"selected\":true");
if (scrollable) sb.Append(",\"scrollable\":true");
if (longClick) sb.Append(",\"longClick\":true");
if (focusable) sb.Append(",\"focusable\":true");
if (focused) sb.Append(",\"focused\":true");
if (!enabled) sb.Append(",\"disabled\":true");
sb.Append("}");
written++;
}
sb.Append($"],\"stats\":{{\"totalRaw\":{elements.Count},\"totalFiltered\":{written}}}}}");
string aiJson = sb.ToString();
project.Variables["AiDroidForAI"].Value = aiJson;
project.SendInfoToLog(
$"✅ AI Mapper v3 готов! {elements.Count} тегов → {written} с координатами | " +
$"JSON: {aiJson.Length} символов | App: {currentApp}", true
);
return aiJson;
}
catch (Exception ex)
{
project.SendErrorToLog($"❌ AiMapper v3 ОШИБКА: {ex.Message}", true);
return null;
}
// ==============================================================================
// ПАРСЕР: находит ВСЕ открывающие теги с атрибутом bounds
// ==============================================================================
void ParseAllTags(
string xml,
System.Collections.Generic.List<System.Collections.Generic.Dictionary<string,string>> result)
{
int pos = 0;
int len = xml.Length;
while (pos < len)
{
// Ищем начало тега
int lt = xml.IndexOf('<', pos);
if (lt < 0) break;
// Пропускаем закрывающие теги и декларации
if (lt + 1 < len && (xml[lt+1] == '/' || xml[lt+1] == '?' || xml[lt+1] == '!'))
{
pos = lt + 1;
continue;
}
// Читаем имя тега
int nameEnd = lt + 1;
while (nameEnd < len && xml[nameEnd] != ' ' && xml[nameEnd] != '\t'
&& xml[nameEnd] != '\n' && xml[nameEnd] != '\r'
&& xml[nameEnd] != '>' && xml[nameEnd] != '/')
nameEnd++;
string tagName = xml.Substring(lt+1, nameEnd - lt - 1);
if (string.IsNullOrEmpty(tagName)) { pos = lt + 1; continue; }
// Ищем конец тега
int gt = FindTagEnd(xml, nameEnd);
if (gt < 0) { pos = nameEnd; continue; }
string tagBody = xml.Substring(lt, gt - lt + 1);
pos = gt + 1;
// Пропускаем теги без bounds (они не содержат элемент с координатами)
if (!tagBody.Contains("bounds=")) continue;
// Парсим атрибуты
var attrs = new System.Collections.Generic.Dictionary<string,string>();
attrs["__tag"] = tagName;
ParseAttrs(tagBody, nameEnd - lt, tagBody.Length, attrs);
result.Add(attrs);
}
}
// Ищет закрывающий > тега, учитывая кавычки внутри атрибутов
int FindTagEnd(string xml, int start)
{
bool inQuote = false;
char qChar = '"';
for (int i = start; i < xml.Length; i++)
{
char c = xml[i];
if (inQuote)
{
if (c == qChar) inQuote = false;
}
else
{
if (c == '"' || c == '\'') { inQuote = true; qChar = c; }
else if (c == '>') return i;
}
}
return -1;
}
// Парсит атрибуты внутри тела тега (offset — позиция после имени тега)
void ParseAttrs(
string tag, int offset, int len,
System.Collections.Generic.Dictionary<string,string> attrs)
{
int pos = offset;
while (pos < len)
{
// Пропускаем пробелы
while (pos < len && (tag[pos]==' '||tag[pos]=='\t'||tag[pos]=='\n'||tag[pos]=='\r'))
pos++;
if (pos >= len || tag[pos] == '>' || tag[pos] == '/') break;
// Читаем имя атрибута до '='
int nameStart = pos;
while (pos < len && tag[pos] != '=' && tag[pos] != '>' && tag[pos] != ' ')
pos++;
if (pos >= len || tag[pos] != '=') break;
string attrName = tag.Substring(nameStart, pos - nameStart).Trim();
pos++; // пропускаем '='
if (pos >= len) break;
// Читаем значение в кавычках
char q = tag[pos];
if (q != '"' && q != '\'') break;
pos++;
int valStart = pos;
while (pos < len && tag[pos] != q) pos++;
if (pos >= len) break;
string attrVal = tag.Substring(valStart, pos - valStart);
pos++; // пропускаем закрывающую кавычку
// XML-decode
attrVal = attrVal
.Replace("&", "&")
.Replace("<", "<")
.Replace(">", ">")
.Replace(""", "\"")
.Replace("'", "'");
if (!string.IsNullOrEmpty(attrName) && !attrs.ContainsKey(attrName))
attrs[attrName] = attrVal;
}
}
// ==============================================================================
// ВСПОМОГАТЕЛЬНЫЕ МЕТОДЫ
// ==============================================================================
// Читает числовой атрибут прямо из сырого XML (для <hierarchy width="..." height="...">)
int ParseAttrInt(string xml, string attrName, int defaultVal)
{
string marker = attrName + "=\"";
int idx = xml.IndexOf(marker);
if (idx < 0) return defaultVal;
int start = idx + marker.Length;
int end = xml.IndexOf('"', start);
if (end < 0) return defaultVal;
int result;
return int.TryParse(xml.Substring(start, end - start), out result) ? result : defaultVal;
}
// Парсит "[x1,y1][x2,y2]" → int[4]
int[] ParseBounds(string s)
{
if (string.IsNullOrEmpty(s)) return null;
try {
s = s.Replace("[","").Replace("]"," ").Trim();
var p = s.Split(new char[]{' ',','}, System.StringSplitOptions.RemoveEmptyEntries);
if (p.Length < 4) return null;
return new int[]{ int.Parse(p[0]), int.Parse(p[1]), int.Parse(p[2]), int.Parse(p[3]) };
} catch { return null; }
}
string V(System.Collections.Generic.Dictionary<string,string> d, string k)
{ string v; return d.TryGetValue(k, out v) ? v : ""; }
bool B(System.Collections.Generic.Dictionary<string,string> d, string k)
{ string v = V(d,k); return v=="true"||v=="1"; }
string FirstNE(params string[] vals)
{ foreach(var v in vals) if(!string.IsNullOrWhiteSpace(v)) return v.Trim(); return null; }
string Js(string s)
{
if (s==null) return "null";
return "\"" + s.Replace("\\","\\\\").Replace("\"","\\\"")
.Replace("\n","\\n").Replace("\r","\\r").Replace("\t","\\t") + "\"";
}
string ClassifyElement(string tagName, string cls, bool clickable, bool checkable, bool scrollable)
{
// Сначала по имени XML-тега (быстрее и точнее)
if (!string.IsNullOrEmpty(tagName))
{
string t = tagName.ToLower();
if (t == "edittext" || t == "autocompletetextview" || t == "multiautocompletetextview")
return "input";
if (t == "button" || t == "imagebutton") return "button";
if (t == "textview") return clickable ? "link" : "text";
if (t == "imageview") return clickable ? "imageButton" : "image";
if (t == "checkbox") return "checkbox";
if (t == "radiobutton")return "radio";
if (t == "switch") return "switch";
if (t == "togglebutton") return "toggle";
if (t == "seekbar" || t == "slider") return "slider";
if (t == "spinner") return "select";
if (t == "listview" || t == "recyclerview" || t == "gridview") return "list";
if (t == "scrollview" || t == "nestedscrollview" || t == "horizontalscrollview")
return "scroll";
if (t == "viewpager" || t == "viewpager2") return "pager";
if (t == "toolbar" || t == "actionbar") return "toolbar";
if (t == "tablayout") return "tabs";
if (t == "bottomnavigationview") return "bottomNav";
if (t == "navigationview") return "navDrawer";
if (t == "progressbar") return "progress";
if (t == "ratingbar") return "rating";
if (t == "webview") return "webview";
if (t == "datepicker" || t == "timepicker") return "picker";
}
// Fallback по полному имени класса
if (!string.IsNullOrEmpty(cls))
{
string c = cls.ToLower();
if (c.Contains("edittext")) return "input";
if (c.Contains("button")) return "button";
if (c.Contains("textview")) return clickable ? "link" : "text";
if (c.Contains("imageview")) return clickable ? "imageButton" : "image";
if (c.Contains("checkbox")) return "checkbox";
if (c.Contains("radiobutton")) return "radio";
if (c.Contains("switch")) return "switch";
if (c.Contains("recycler") || c.Contains("listview")) return "list";
if (c.Contains("scroll") || scrollable) return "scroll";
if (c.Contains("webview")) return "webview";
if (c.Contains("progress")) return "progress";
}
if (checkable) return "checkable";
if (clickable) return "button";
if (scrollable) return "scroll";
return "view";
}
Сложная цель («зайди в аккаунт, открой Настройки, смени пароль, выйди») требует работы на нескольких экранах. Нельзя планировать все шаги сразу — элементы следующего экрана ещё не существуют в DOM.
Task Chain Planner (task_chain_planner.py) решает эту задачу до запуска основного агента. Он берёт высокоуровневую цель и разбивает её на группы по принципу «один экран = одна группа».
Ключевое правило группировки
ПРАВИЛО Действия относятся к ОДНОЙ группе, если происходят в ОДНОМ контексте экрана.
НОВАЯ группа начинается только когда предыдущее действие полностью открыло новый экран.
ПРАВИЛЬНО (2 группы):
Группа 1: CLICK на папку Tools → NAVIGATE com.android.chrome
Группа 2: INPUT в адресной строке Chrome → SEARCH
НЕПРАВИЛЬНО (3 группы):
Группа 1: CLICK папку Tools
Группа 2: NAVIGATE Chrome ← нельзя, это часть группы 1!
Группа 3: INPUT в Chrome
Что генерирует плanner
# Результат в папке task_chain/:
task_01.txt → "Click on the Tools folder on the launcher,
then open Chrome by navigating to com.android.chrome"
task_02.txt → "The app is already on screen.
In Chrome address bar, type 'fresh news' and press search"
task_plan.json → полный план со всеми метаданными
Каждый task_NN.txt — это самодостаточный промпт для Logic Control Module. Оператор просто копирует файлы один за другим в task.txt по мере завершения каждой группы. Это может быть легко автоматизировано.
Два варианта LLM-бэкенда
Planner поставляется в двух версиях — с локальной Ollama (LLaMA 3, полная приватность) и с OpenAI API (GPT-4o-mini, выше качество планирования). Переключение — одна строка в конфиге.
ШАГ 3 Logic Control Module — мозг агента
Logic Control Module (logic_control_module.py) — главный компонент системы. Он принимает JSON-дамп экрана и задачу, отправляет запрос к LLM и получает готовую цепочку шагов.
Система типов действий
class ActionType(str, Enum):
CLICK = "CLICK" # тап по элементу
INPUT = "INPUT" # ввод текста
SCROLL = "SCROLL" # прокрутка
NAVIGATE = "NAVIGATE" # переход в приложение / URL
WAIT = "WAIT" # пауза
DONE = "DONE" # задача выполнена
DOM_RESCAN = "DOM_RESCAN" # элемент не найден, нужен новый скан
ERROR = "ERROR" # критическая ошибка
Структура одного шага
@dataclass
class ChainStep:
step_num: int # номер шага
action: ActionType # тип действия
label: str = "" # что кликаем/вводим
x: int = 0 # координата X (центр элемента)
y: int = 0 # координата Y (центр элемента)
value: str = "" # текст для INPUT / URL для NAVIGATE
scroll_dir: str = "down" # направление скролла
scroll_px: int = 300 # пикселей прокрутки
wait_sec: int = 2 # секунд ожидания
save_key: str = "" # сохранить значение в контекст
save_from: str = "none" # откуда брать значение
submit_enter: str = "false" # нажать Enter после INPUT
Персистентная память: ContextStore
Агент умеет запоминать данные между итерациями — логины, токены, промежуточные результаты. ContextStore сохраняет ключ-значение в context_store.json, и LLM видит этот контекст при следующем вызове.
# ZennoDroid записывает контекст после каждого шага:
python logic_control_module.py --save "user_email" "user@example.com"
python logic_control_module.py --save "otp_code" "847291"
# LLM видит при следующем планировании:
# Context:
# user_email: user@example.com (saved at 2024-01-15 14:23:01)
# otp_code: 847291 (saved at 2024-01-15 14:23:15)
Умные пост-обработки цепочки
После получения цепочки от LLM модуль применяет несколько слоёв постобработки:
- Auto-WAIT вставки: после каждого CLICK автоматически добавляется WAIT(2s) — пауза на обновление DOM.
- Submit-Enter детект: если задача содержит фразы типа «нажми поиск» или «press Enter», шагу INPUT ставится submit_enter=true.
- Zero-coord фикс: если LLM вернул координаты (0,0), модуль ищет элемент по label в DOM и подставляет реальные координаты.
- Rescan-mode guard: в режиме пересканирования запрещает LLM предлагать навигационные действия — только поиск конкретного элемента.
ШАГ 4 Action Executor — физическое взаимодействие с Android
Action Executor — это C#-сниппет ZennoDroid, который читает текущий шаг из next_action.txt и физически выполняет его через native DroidInstance API. Это ядро всей системы с точки зрения взаимодействия с устройством.
Обработка каждого типа действия
C#:
// =====================================================================
// DROID SNIPPET 2 — Action Executor + Chain Advance v1.0
// Адаптация ZennoPoster → ZennoDroid
//
// ЧТО ИЗМЕНЕНО относительно ZP-оригинала (Snippet 2 из документа):
//
// [УБРАНО]
// - OFFSET_X / OFFSET_Y (OffsetClick — нет смысла на Android)
// - instance.ActiveTab.FindElementByTag("html") → DrawToBitmap()
// (скриншот через браузерный DOM)
// - instance.ActiveTab.MainDocument.EvaluateScript(jsFindCoord)
// (JS-поиск координат в браузере)
// - instance.ActiveTab.FullEmulationMouseMove / FullEmulationMouseClick
// - instance.SendText() посимвольно + JS Native setter
// - window.scrollBy() через JS
// - instance.ActiveTab.Navigate()
// - domAfterJs — сбор DOM через document.querySelectorAll()
// - DOM diff через JArray с полями interactive/cx/cy
// - instance.ActiveTab.URL / instance.ActiveTab.Title
// - SAVE_FROM: "url", "page_title" → заменены на "app", "activity"
// - CoordRefreshPrompt: "Current page URL/title" → "Current app/activity"
//
// [ДОБАВЛЕНО / ЗАМЕНЕНО]
// + instance.DroidInstance.Screen.GetScreenshot() → byte[] → файл
// + instance.DroidInstance.Hierarchy.GetLayout() → XML → файл
// + Поиск координат через AppiumDriver.FindElementByXPath(@text=)
// + fallback FindElementByAccessibilityId (content-desc)
// + парсинг bounds="[x1,y1][x2,y2]" → cx/cy
// + CLICK: instance.DroidInstance.Input.Tap(cx, cy)
// + fallback Touch(x1,y1,x2,y2)
// + INPUT: Tap для фокуса → ActiveElement().Clear()/SendText()
// + fallback droidInput.SendText() посимвольно
// + SCROLL: droidInput.Swipe() по центру экрана
// + размер экрана из JSON переменной AiDroidForAI или wm size
// + NAVIGATE: App.OpenUrl() для http / Input.Shell(am start) fallback
// + App.Open(packageName) для app-пакетов
// + DOM diff: сравнение XML по Regex (кол-во bounds + clickable=true)
// вместо JArray-сравнения интерактивных элементов
// + STATUS_FILE: CURRENT_APP + CURRENT_ACTIVITY вместо URL/PageTitle
// + CoordRefreshPrompt: Android-контекст (app, activity)
// =====================================================================
string BOT_DIR = project.Directory + @"\";
string CHAIN_FILE = BOT_DIR + "action_chain.json";
string NEXT_FILE = BOT_DIR + "next_action.txt";
string STATUS_FILE = BOT_DIR + "step_status.txt";
string SCREEN_FILE = BOT_DIR + "screenshot.png";
string SCREEN_BEFORE = BOT_DIR + "screenshot_before.png";
string DOM_SRC_FILE = BOT_DIR + "dom_input.txt"; // текущий JSON от AiMapper
string DOM_BEFORE_F = BOT_DIR + "lcm_ai_module_before.txt";
string DOM_AFTER_F = BOT_DIR + "lcm_ai_module_after.txt"; // XML иерархии ПОСЛЕ действия
// ── Вспомогательная: скриншот → файл ─────────────────────────────────
System.Action<string> saveScreen = (path) => {
try {
string remotePath = "/sdcard/zd_screen_tmp.png";
instance.DroidInstance.Input.Shell("screencap -p " + remotePath);
System.Threading.Thread.Sleep(700);
// Получаем ADB-серийник: сначала пробуем AddressPort (для MEmu = "127.0.0.1:2XXXX")
// Info.Name может вернуть display name "MEmu" — не подходит для adb -s
string deviceName = instance.DroidInstance.Info.AddressPort ?? "";
if (string.IsNullOrEmpty(deviceName))
deviceName = instance.DroidInstance.Info.Name ?? "";
// Если deviceName не содержит ":" и не похож на серийник — это display name,
// пробуем получить реальный серийник через "adb devices"
if (!string.IsNullOrEmpty(deviceName) && !deviceName.Contains(":") &&
!deviceName.Contains(".") && deviceName.Length < 20) {
try {
string adbExeCheck = "adb.exe";
string[] adbPathsCheck = new string[] {
System.IO.Path.Combine(project.Directory, "adb.exe"),
System.IO.Path.Combine(project.Directory, @"..\adb.exe"),
@"C:\Program Files\Microvirt\MEmu\adb.exe",
@"C:\Program Files (x86)\Microvirt\MEmu\adb.exe",
@"C:\MEmu\adb.exe",
};
foreach (var c in adbPathsCheck) {
if (System.IO.File.Exists(c)) { adbExeCheck = c; break; }
}
var piCheck = new System.Diagnostics.ProcessStartInfo(adbExeCheck, "devices");
piCheck.UseShellExecute = false; piCheck.CreateNoWindow = true;
piCheck.RedirectStandardOutput = true;
string devicesOut = "";
using (var pCheck = System.Diagnostics.Process.Start(piCheck)) {
devicesOut = pCheck.StandardOutput.ReadToEnd();
pCheck.WaitForExit(5000);
}
// Ищем строку вида "127.0.0.1:XXXXX device"
var devMatch = System.Text.RegularExpressions.Regex.Match(
devicesOut, @"([\d\.\:a-zA-Z0-9\-]+)\s+device");
if (devMatch.Success) {
string foundSerial = devMatch.Groups[1].Value.Trim();
if (foundSerial != "List" && foundSerial.Length > 3) {
project.SendInfoToLog("saveScreen: resolved serial '" + deviceName +
"' -> '" + foundSerial + "' via adb devices", false);
deviceName = foundSerial;
}
}
} catch { }
}
// Ищем adb.exe: рядом с проектом → рядом с ZennoDroid → системный PATH
string adbExe = "adb.exe";
string[] adbPaths = new string[] {
System.IO.Path.Combine(project.Directory, "adb.exe"),
System.IO.Path.Combine(project.Directory, @"..\adb.exe"),
System.IO.Path.Combine(project.Directory, @"..\..\adb.exe"),
@"C:\Program Files\Microvirt\MEmu\adb.exe",
@"C:\Program Files (x86)\Microvirt\MEmu\adb.exe",
@"C:\MEmu\adb.exe",
@"C:\android\platform-tools\adb.exe",
@"C:\Users\" + System.Environment.UserName + @"\AppData\Local\Android\Sdk\platform-tools\adb.exe",
};
foreach (var c in adbPaths) {
if (System.IO.File.Exists(c)) { adbExe = c; break; }
}
// Формируем аргументы — deviceName может содержать ":" поэтому не нужны кавычки
string adbArgs = string.IsNullOrEmpty(deviceName)
? "pull \"" + remotePath + "\" \"" + path + "\""
: "-s " + deviceName + " pull \"" + remotePath + "\" \"" + path + "\"";
var pi = new System.Diagnostics.ProcessStartInfo(adbExe, adbArgs);
pi.UseShellExecute = false;
pi.CreateNoWindow = true;
pi.RedirectStandardOutput = true;
pi.RedirectStandardError = true;
string stdout = "", stderr = "";
using (var p = System.Diagnostics.Process.Start(pi)) {
stdout = p.StandardOutput.ReadToEnd();
stderr = p.StandardError.ReadToEnd();
p.WaitForExit(10000);
}
if (System.IO.File.Exists(path)) {
project.SendInfoToLog("saveScreen OK | adb=" + adbExe + " device=" + deviceName, false);
} else {
project.SendInfoToLog("saveScreen FAILED | adb=" + adbExe +
" | device='" + deviceName + "'" +
" | args='" + adbArgs + "'" +
" | out='" + stdout.Trim() + "'" +
" | err='" + stderr.Trim() + "'", false);
}
} catch (System.Exception ex) {
project.SendInfoToLog("saveScreen err: " + ex.Message, false);
}
};
// ── Вспомогательная: XML иерархии → файл ─────────────────────────────
System.Action<string> saveLayout = (path) => {
try {
string xml = instance.DroidInstance.Hierarchy.GetLayout();
if (string.IsNullOrEmpty(xml)) return;
// Извлекаем кликабельные элементы с координатами из XML → JSON
// чтобы формат совпадал с lcm_ai_module_before (JSON от AiMapper)
var clickableItems = new System.Collections.Generic.List<string>();
var boundsMatches = System.Text.RegularExpressions.Regex.Matches(
xml,
@"text=""([^""]*)""\s[^>]*?(?:content-desc=""([^""]*)""[^>]*?)?clickable=""true""[^>]*?bounds=""\[(\d+),(\d+)\]\[(\d+),(\d+)\]""");
foreach (System.Text.RegularExpressions.Match bm in boundsMatches) {
string lbl = bm.Groups[1].Value.Trim();
string desc = bm.Groups[2].Value.Trim();
if (string.IsNullOrEmpty(lbl)) lbl = desc;
int x1 = int.Parse(bm.Groups[3].Value), y1 = int.Parse(bm.Groups[4].Value);
int x2 = int.Parse(bm.Groups[5].Value), y2 = int.Parse(bm.Groups[6].Value);
int cx = x1 + (x2 - x1) / 2, cy = y1 + (y2 - y1) / 2;
string safeLabel = lbl.Replace("\"", "\\\"");
clickableItems.Add("{\"label\":\"" + safeLabel + "\",\"cx\":" + cx + ",\"cy\":" + cy + "}");
}
// Также берём элементы без текста но с content-desc
var descMatches = System.Text.RegularExpressions.Regex.Matches(
xml,
@"content-desc=""([^""]+)""[^>]*?clickable=""true""[^>]*?bounds=""\[(\d+),(\d+)\]\[(\d+),(\d+)\]""");
var seenLabels = new System.Collections.Generic.HashSet<string>();
foreach (System.Text.RegularExpressions.Match bm in boundsMatches) {
seenLabels.Add(bm.Groups[1].Value.Trim());
}
foreach (System.Text.RegularExpressions.Match dm in descMatches) {
string desc = dm.Groups[1].Value.Trim();
if (!string.IsNullOrEmpty(desc) && !seenLabels.Contains(desc)) {
int x1 = int.Parse(dm.Groups[2].Value), y1 = int.Parse(dm.Groups[3].Value);
int x2 = int.Parse(dm.Groups[4].Value), y2 = int.Parse(dm.Groups[5].Value);
int cx = x1 + (x2-x1)/2, cy = y1 + (y2-y1)/2;
string safeDesc = desc.Replace("\"", "\\\"");
clickableItems.Add("{\"label\":\"" + safeDesc + "\",\"cx\":" + cx + ",\"cy\":" + cy + "}");
}
}
string json = "{\"clickable\":[" + string.Join(",", clickableItems.ToArray()) + "]}";
System.IO.File.WriteAllText(path, json, new System.Text.UTF8Encoding(false));
project.SendInfoToLog("saveLayout: " + clickableItems.Count + " clickable elements → " + path, false);
} catch (System.Exception ex) {
project.SendInfoToLog("saveLayout err: " + ex.Message, false);
}
};
// =====================================================================
if (!System.IO.File.Exists(CHAIN_FILE)) {
project.SendErrorToLog("action_chain.json не найден!", true);
return "ERROR_NO_CHAIN";
}
int currentStepNum = 1;
try { int.TryParse(project.Variables["CurrentStepNum"].Value, out currentStepNum); } catch { }
if (currentStepNum < 1) currentStepNum = 1;
string chainJson = System.IO.File.ReadAllText(CHAIN_FILE, System.Text.Encoding.UTF8);
var jObj = Global.ZennoLab.Json.Linq.JObject.Parse(chainJson);
var steps = jObj["steps"] as Global.ZennoLab.Json.Linq.JArray;
if (steps == null || steps.Count == 0) {
project.SendErrorToLog("action_chain.json: пустой массив шагов!", true);
return "ERROR_EMPTY_STEPS";
}
Global.ZennoLab.Json.Linq.JToken curStep = null;
foreach (var s in steps)
if (s["step_num"] != null && s["step_num"].ToString() == currentStepNum.ToString())
{ curStep = s; break; }
if (curStep == null) {
project.SendInfoToLog("Шаг #" + currentStepNum + " не найден — все шаги выполнены.", true);
project.Variables["ChainDone"].Value = "true";
project.Variables["NeedsVerify"].Value = "false";
System.IO.File.WriteAllText(NEXT_FILE,
"ACTION=DONE\nSTEP_NUM=0\nLABEL=\nX=0\nY=0\nVALUE=\n",
new System.Text.UTF8Encoding(false));
return "SUCCESS_DONE";
}
System.Func<string, string, string> getStr = (key, def) =>
curStep[key] != null ? curStep[key].ToString() : def;
string execAction = getStr("action", "ERROR").ToUpper();
string execLabel = getStr("label", "");
string execVal = getStr("value", "");
string execSaveFrom = getStr("save_from", "none");
string execSaveKey = getStr("save_key", "");
int execX = 0; int.TryParse(getStr("x", "0"), out execX);
int execY = 0; int.TryParse(getStr("y", "0"), out execY);
int execWait = 0; int.TryParse(getStr("wait_sec", "0"), out execWait);
int execScrollPx = 300; int.TryParse(getStr("scroll_px","300"), out execScrollPx);
string execScrollDir = getStr("scroll_dir", "down");
int totalSteps = steps.Count;
bool actionFailed = false;
project.SendInfoToLog(">> Step #" + currentStepNum + "/" + totalSteps +
" [" + execAction + "] label='" + execLabel + "' (" + execX + "," + execY + ")", true);
// =====================================================================
// ДО ДЕЙСТВИЯ: скриншот_before + копия DOM (JSON от AiMapper)
// =====================================================================
saveScreen(SCREEN_BEFORE);
// DOM before: сохраняем текущий AiMapper JSON в том же формате что и after
// Конвертируем elements[] → {"clickable":[{label,cx,cy}]} для корректного сравнения
try {
string aiJsonBefore = project.Variables["AiDroidForAI"].Value ?? "";
if (!string.IsNullOrEmpty(aiJsonBefore)) {
// Парсим JSON объекты поэлементно — ищем { ... } блоки в массиве elements
// и для каждого проверяем наличие "clickable":true независимо от порядка полей
var beforeItems = new System.Collections.Generic.List<string>();
// Находим массив elements: всё между "elements":[ и последним ]
int elemStart = aiJsonBefore.IndexOf("\"elements\":[");
if (elemStart >= 0) {
elemStart = aiJsonBefore.IndexOf('[', elemStart) + 1;
// Парсим каждый объект { } в массиве
int pos2 = elemStart;
while (pos2 < aiJsonBefore.Length) {
int objStart = aiJsonBefore.IndexOf('{', pos2);
if (objStart < 0) break;
// Находим закрывающую } с учётом вложенности
int depth2 = 0, objEnd = -1;
for (int ii = objStart; ii < aiJsonBefore.Length; ii++) {
if (aiJsonBefore[ii] == '{') depth2++;
else if (aiJsonBefore[ii] == '}') { depth2--; if (depth2 == 0) { objEnd = ii; break; } }
}
if (objEnd < 0) break;
string objStr = aiJsonBefore.Substring(objStart, objEnd - objStart + 1);
pos2 = objEnd + 1;
// Проверяем что элемент кликабельный
bool isClickable = objStr.Contains("\"clickable\":true");
if (!isClickable) continue;
// Извлекаем label
var lblM = System.Text.RegularExpressions.Regex.Match(objStr, @"""label""\s*:\s*""([^""]+)""");
if (!lblM.Success) continue;
string lbl = lblM.Groups[1].Value.Trim();
if (string.IsNullOrEmpty(lbl)) continue;
// Извлекаем cx
var cxM = System.Text.RegularExpressions.Regex.Match(objStr, @"""cx""\s*:\s*(\d+)");
if (!cxM.Success) continue;
int cx2 = int.Parse(cxM.Groups[1].Value);
// Извлекаем cy
var cyM = System.Text.RegularExpressions.Regex.Match(objStr, @"""cy""\s*:\s*(\d+)");
if (!cyM.Success) continue;
int cy2 = int.Parse(cyM.Groups[1].Value);
string safeLabel2 = lbl.Replace("\\", "\\\\").Replace("\"", "\\\"");
beforeItems.Add("{\"label\":\"" + safeLabel2 + "\",\"cx\":" + cx2 + ",\"cy\":" + cy2 + "}");
}
}
string beforeJson = "{\"clickable\":[" + string.Join(",", beforeItems.ToArray()) + "]}";
System.IO.File.WriteAllText(DOM_BEFORE_F, beforeJson, new System.Text.UTF8Encoding(false));
project.SendInfoToLog("DOM before: " + beforeItems.Count + " clickable elements saved", false);
} else {
if (System.IO.File.Exists(DOM_SRC_FILE))
System.IO.File.Copy(DOM_SRC_FILE, DOM_BEFORE_F, true);
project.SendInfoToLog("DOM before: fallback to dom_input.txt copy", false);
}
} catch (System.Exception ex) {
project.SendInfoToLog("DOM before err: " + ex.Message, false);
try {
if (System.IO.File.Exists(DOM_SRC_FILE))
System.IO.File.Copy(DOM_SRC_FILE, DOM_BEFORE_F, true);
} catch { }
}
// =====================================================================
// ПОИСК КООРДИНАТ через AppiumDriver
// Заменяет JS-поиск (jsFindCoord) из ZP-версии
// Порядок: 1) FindElementByXPath(@text) → bounds → cx/cy
// 2) FindElementByAccessibilityId (content-desc) → bounds → cx/cy
// =====================================================================
if ((execAction == "CLICK" || execAction == "INPUT") && !string.IsNullOrEmpty(execLabel)) {
bool coordFound = false;
// Стратегия 1: поиск по тексту
try {
string xp = "//*[@text=\"" + execLabel.Replace("\"", "'") + "\"]";
var el = instance.DroidInstance.AppiumDriver.FindElementByXPath(xp);
if (el != null) {
string b = el.GetAttribute("bounds");
var nums = System.Text.RegularExpressions.Regex.Matches(b ?? "", @"\d+");
if (nums.Count >= 4) {
int x1 = int.Parse(nums[0].Value), y1 = int.Parse(nums[1].Value);
int x2 = int.Parse(nums[2].Value), y2 = int.Parse(nums[3].Value);
int foundCx = x1 + (x2-x1)/2, foundCy = y1 + (y2-y1)/2;
project.SendInfoToLog("AppiumCoord [@text]: (" + execX + "," + execY +
") -> (" + foundCx + "," + foundCy + ")", true);
execX = foundCx; execY = foundCy;
coordFound = true;
}
}
} catch (System.Exception ex) {
project.SendInfoToLog("AppiumCoord [@text] miss: " + ex.Message, false);
}
// Стратегия 2: поиск по content-desc (accessibility id)
if (!coordFound) {
try {
var el = instance.DroidInstance.AppiumDriver.FindElementByAccessibilityId(execLabel);
if (el != null) {
string b = el.GetAttribute("bounds");
var nums = System.Text.RegularExpressions.Regex.Matches(b ?? "", @"\d+");
if (nums.Count >= 4) {
int x1 = int.Parse(nums[0].Value), y1 = int.Parse(nums[1].Value);
int x2 = int.Parse(nums[2].Value), y2 = int.Parse(nums[3].Value);
int foundCx = x1 + (x2-x1)/2, foundCy = y1 + (y2-y1)/2;
project.SendInfoToLog("AppiumCoord [a11y]: (" + execX + "," + execY +
") -> (" + foundCx + "," + foundCy + ")", true);
execX = foundCx; execY = foundCy;
}
}
} catch (System.Exception ex) {
project.SendInfoToLog("AppiumCoord [a11y] miss: " + ex.Message, false);
}
}
}
// =====================================================================
// ВЫПОЛНЕНИЕ ДЕЙСТВИЯ
// =====================================================================
var droidInput = instance.DroidInstance.Input;
var droidDriver = instance.DroidInstance.AppiumDriver;
if (execAction == "CLICK") {
// ── Tap — основной метод (заменяет FullEmulationMouseClick) ──────
try {
droidInput.Tap(execX, execY);
project.SendInfoToLog("CLICK (Tap) at (" + execX + "," + execY + ")", true);
} catch (System.Exception ex) {
project.SendInfoToLog("Tap failed: " + ex.Message + " → Touch fallback", false);
try {
// Touch с мини-рандомом внутри точки (аналог FullEmulation с шумом)
var rnd2 = new System.Random();
int dx = rnd2.Next(-4, 5), dy = rnd2.Next(-4, 5);
droidInput.Touch(execX + dx, execY + dy, execX + dx, execY + dy, false, "None");
project.SendInfoToLog("CLICK (Touch fallback) at (" + execX + "," + execY + ")", true);
} catch (System.Exception ex2) {
project.SendErrorToLog("Оба метода клика провалились: " + ex2.Message, true);
actionFailed = true;
}
}
} else if (execAction == "INPUT") {
// ── INPUT: фокус → очистка → ввод ────────────────────────────────
// Заменяет: FullEmulation + JS clearField + instance.SendText посимвольно + JS Native setter
// Шаг 1: тап для фокуса
try {
droidInput.Tap(execX, execY);
System.Threading.Thread.Sleep(400);
project.SendInfoToLog("INPUT focus Tap at (" + execX + "," + execY + ")", true);
} catch (System.Exception ex) {
project.SendInfoToLog("INPUT focus tap err: " + ex.Message, false);
}
// Шаг 2: очистка поля — выделить всё (Ctrl+A = keyevent 277) затем Delete
try {
droidInput.Shell("input keyevent 277"); // KEYCODE_CTRL_A — выделить весь текст
System.Threading.Thread.Sleep(100);
droidInput.Shell("input keyevent 67"); // KEYCODE_DEL — удалить выделенное
System.Threading.Thread.Sleep(100);
droidInput.ClearText(); // дополнительная очистка через ZD API
System.Threading.Thread.Sleep(100);
} catch { }
bool inputOk = false;
// Единственная надёжная стратегия для ZennoDroid: droidInput.SendText()
// activeEl.SendText() работает нестабильно в Chrome адресной строке
try {
droidInput.SendText(execVal);
System.Threading.Thread.Sleep(300);
inputOk = true;
project.SendInfoToLog("INPUT (droidInput.SendText) sent: '" + execVal + "'", true);
} catch (System.Exception ex) {
project.SendInfoToLog("INPUT droidInput.SendText err: " + ex.Message + " → keyevent fallback", false);
}
// Fallback: посимвольный ввод через SendText с задержкой
if (!inputOk) {
try {
foreach (char ch in execVal.ToCharArray()) {
droidInput.SendText(ch.ToString());
System.Threading.Thread.Sleep(30);
}
System.Threading.Thread.Sleep(200);
inputOk = true;
project.SendInfoToLog("INPUT (char-by-char) sent: '" + execVal + "'", true);
} catch (System.Exception ex) {
project.SendErrorToLog("INPUT char-by-char failed: " + ex.Message, true);
actionFailed = true;
}
}
project.SendInfoToLog("INPUT final: ok=" + inputOk +
" | '" + execVal + "' at (" + execX + "," + execY + ")", true);
// ── Скрываем клавиатуру тапом по пустому месту если следующий шаг — CLICK ──
// Клавиатура перекрывает нижние кнопки (Войти, Далее и т.д.)
if (inputOk) {
// Определяем следующий шаг из цепочки
bool nextStepIsClick = false;
int checkNextNum = currentStepNum + 1;
foreach (var s in steps) {
if (s["step_num"] != null && s["step_num"].ToString() == checkNextNum.ToString()) {
string nextAct = s["action"] != null ? s["action"].ToString().ToUpper() : "";
nextStepIsClick = (nextAct == "CLICK");
break;
}
}
if (nextStepIsClick) {
try {
// Получаем размер экрана
int tapSw = 1600, tapSh = 900;
try {
string aiJson2 = project.Variables["AiDroidForAI"].Value;
if (!string.IsNullOrEmpty(aiJson2)) {
var jS2 = Global.ZennoLab.Json.Linq.JObject.Parse(aiJson2);
var pg2 = jS2["page"];
if (pg2 != null && pg2["screen"] != null) {
int.TryParse(pg2["screen"]["w"] != null ? pg2["screen"]["w"].ToString() : "1600", out tapSw);
int.TryParse(pg2["screen"]["h"] != null ? pg2["screen"]["h"].ToString() : "900", out tapSh);
}
}
} catch { }
// Тапаем в верхнюю центральную область — там обычно нет интерактивных элементов
// но область достаточно далеко от поля ввода чтобы снять фокус
int dismissX = tapSw / 2; // центр по горизонтали
int dismissY = (int)(tapSh * 0.18); // ~18% высоты экрана сверху
System.Threading.Thread.Sleep(200);
droidInput.Tap(dismissX, dismissY);
System.Threading.Thread.Sleep(400); // ждём анимацию скрытия клавиатуры
project.SendInfoToLog("INPUT: keyboard dismissed via tap at (" +
dismissX + "," + dismissY + ") before next CLICK", true);
} catch (System.Exception ex) {
project.SendInfoToLog("INPUT: keyboard dismiss tap failed: " + ex.Message, false);
}
}
}
// После ввода — Enter только если цепочка явно требует его через submit_enter=true
// ИЛИ если следующего шага нет (последний шаг цепочки) и поле одиночного ввода
if (inputOk) {
bool sendEnter = false;
string submitEnterFlag = getStr("submit_enter", "false").ToLower().Trim();
if (submitEnterFlag == "true") {
// LLM явно указал что нужен Enter
sendEnter = true;
project.SendInfoToLog("INPUT: submit_enter=true — Enter будет отправлен", false);
} else {
project.SendInfoToLog("INPUT: submit_enter=false — Enter не отправляется", false);
}
if (sendEnter) {
try {
System.Threading.Thread.Sleep(300);
droidInput.Shell("input keyevent 66"); // keyevent 66 = KEYCODE_ENTER
project.SendInfoToLog("INPUT: Enter (keyevent 66) sent", true);
} catch (System.Exception ex) {
project.SendInfoToLog("INPUT: Enter keyevent failed: " + ex.Message, false);
}
}
}
} else if (execAction == "SCROLL") {
// ── Swipe (заменяет window.scrollBy() из ZP) ─────────────────────
// Читаем размер экрана из переменной AiDroidForAI (JSON от AiMapper)
int sw = 1080, sh = 1920;
try {
string aiJson = project.Variables["AiDroidForAI"].Value;
if (!string.IsNullOrEmpty(aiJson)) {
var jScreen = Global.ZennoLab.Json.Linq.JObject.Parse(aiJson);
var pg = jScreen["page"];
if (pg != null) {
if (pg["screen"] != null) {
int.TryParse(pg["screen"]["w"] != null ? pg["screen"]["w"].ToString() : "1080", out sw);
int.TryParse(pg["screen"]["h"] != null ? pg["screen"]["h"].ToString() : "1920", out sh);
}
}
}
} catch { }
// fallback: wm size через ADB
if (sw == 1080 && sh == 1920) {
try {
string wmSize = droidInput.Shell("wm size");
var m = System.Text.RegularExpressions.Regex.Match(wmSize ?? "", @"(\d+)x(\d+)");
if (m.Success) {
int.TryParse(m.Groups[1].Value, out sw);
int.TryParse(m.Groups[2].Value, out sh);
}
} catch { }
}
int cx = sw / 2;
if (execScrollDir == "down") {
droidInput.Swipe(cx, sh * 3/4, cx, sh / 4, 400);
project.SendInfoToLog("SCROLL down (swipe up) " + execScrollPx + "px equiv", true);
} else {
droidInput.Swipe(cx, sh / 4, cx, sh * 3/4, 400);
project.SendInfoToLog("SCROLL up (swipe down)", true);
}
} else if (execAction == "NAVIGATE") {
// ── Открыть URL или приложение (заменяет instance.ActiveTab.Navigate) ──
if (execVal.StartsWith("http://") || execVal.StartsWith("https://")) {
try {
instance.DroidInstance.App.OpenUrl(execVal, "com.android.chrome");
project.SendInfoToLog("NAVIGATE (Chrome) -> " + execVal, true);
} catch {
try {
droidInput.Shell("am start -a android.intent.action.VIEW -d \"" + execVal + "\"");
project.SendInfoToLog("NAVIGATE (am start VIEW) -> " + execVal, true);
} catch (System.Exception ex2) {
project.SendErrorToLog("NAVIGATE failed: " + ex2.Message, true);
actionFailed = true;
}
}
System.Threading.Thread.Sleep(2500);
} else {
// package name — открываем приложение
try {
instance.DroidInstance.App.Open(execVal);
project.SendInfoToLog("NAVIGATE (App.Open) -> " + execVal, true);
} catch (System.Exception ex) {
project.SendErrorToLog("App.Open failed: " + ex.Message, true);
actionFailed = true;
}
System.Threading.Thread.Sleep(2000);
}
} else if (execAction == "WAIT") {
project.SendInfoToLog("WAIT " + execWait + "s", true);
System.Threading.Thread.Sleep(execWait * 1000);
} else {
project.SendErrorToLog("Неизвестное действие: " + execAction, false);
}
// =====================================================================
// ПОСЛЕ ДЕЙСТВИЯ: пауза → скриншот AFTER → XML иерархия AFTER
// =====================================================================
int pauseMs = execAction == "CLICK" ? 1500 :
execAction == "NAVIGATE" ? 2500 : 600;
System.Threading.Thread.Sleep(pauseMs);
saveScreen(SCREEN_FILE);
saveLayout(DOM_AFTER_F); // XML иерархии — заменяет domAfterJs из ZP
// =====================================================================
// DOM DIFF
// Заменяет JArray-сравнение интерактивных элементов из ZP.
// Android-версия: сравниваем XML-файлы по Regex (bounds + clickable=true)
// =====================================================================
int domChangeLevel = 0;
string domChangeSummary = "DOM не изменился";
var movedLabels = new System.Collections.Generic.List<string>();
try {
string jsonBefore = System.IO.File.Exists(DOM_BEFORE_F)
? System.IO.File.ReadAllText(DOM_BEFORE_F, System.Text.Encoding.UTF8) : "";
string jsonAfter = System.IO.File.Exists(DOM_AFTER_F)
? System.IO.File.ReadAllText(DOM_AFTER_F, System.Text.Encoding.UTF8) : "";
if (string.IsNullOrEmpty(jsonBefore) || string.IsNullOrEmpty(jsonAfter)) {
domChangeLevel = 1;
domChangeSummary = "DOM diff: файлы недоступны — level=1";
} else {
// Парсим координаты кликабельных элементов из before (JSON AiMapper) и after (JSON saveLayout)
// Before: массив elements с cx/cy и label
// After: {"clickable":[{"label":..,"cx":..,"cy":..}]}
// Универсальная функция парсинга {"clickable":[{label,cx,cy}]} формата
// Работает для обоих файлов — before и after имеют одинаковый формат
System.Func<string, System.Collections.Generic.Dictionary<string, System.Tuple<int,int>>>
parseClickable = (json) => {
var coords = new System.Collections.Generic.Dictionary<string, System.Tuple<int,int>>();
// Находим массив clickable
int arrStart = json.IndexOf("\"clickable\":[");
if (arrStart < 0) return coords;
arrStart = json.IndexOf('[', arrStart) + 1;
// Парсим каждый объект { } поэлементно
int pos3 = arrStart;
while (pos3 < json.Length) {
int objS = json.IndexOf('{', pos3);
if (objS < 0) break;
int dep3 = 0, objE = -1;
for (int ii = objS; ii < json.Length; ii++) {
if (json[ii] == '{') dep3++;
else if (json[ii] == '}') { dep3--; if (dep3 == 0) { objE = ii; break; } }
}
if (objE < 0) break;
string obj3 = json.Substring(objS, objE - objS + 1);
pos3 = objE + 1;
// label
var lm3 = System.Text.RegularExpressions.Regex.Match(obj3, @"""label""\s*:\s*""([^""]+)""");
if (!lm3.Success) continue;
string lbl3 = lm3.Groups[1].Value.Trim();
if (string.IsNullOrEmpty(lbl3)) continue;
// cx
var cxm3 = System.Text.RegularExpressions.Regex.Match(obj3, @"""cx""\s*:\s*(\d+)");
if (!cxm3.Success) continue;
// cy
var cym3 = System.Text.RegularExpressions.Regex.Match(obj3, @"""cy""\s*:\s*(\d+)");
if (!cym3.Success) continue;
int cx3 = int.Parse(cxm3.Groups[1].Value);
int cy3 = int.Parse(cym3.Groups[1].Value);
// Если label дублируется — берём первое вхождение (стабильнее)
if (!coords.ContainsKey(lbl3))
coords[lbl3] = System.Tuple.Create(cx3, cy3);
}
return coords;
};
var beforeCoords = parseClickable(jsonBefore);
var afterCoords = parseClickable(jsonAfter);
project.SendInfoToLog("DOM diff: before=" + beforeCoords.Count +
" after=" + afterCoords.Count + " clickable elements", false);
// Сравниваем координаты общих элементов
int movedCount = 0, disappearedCount = 0, appearedCount = 0;
int MOVE_THRESHOLD = 15; // пикселей — считаем сдвигом
foreach (var kvp in beforeCoords) {
if (afterCoords.ContainsKey(kvp.Key)) {
var bCoord = kvp.Value;
var aCoord = afterCoords[kvp.Key];
int dx = System.Math.Abs(aCoord.Item1 - bCoord.Item1);
int dy = System.Math.Abs(aCoord.Item2 - bCoord.Item2);
if (dx > MOVE_THRESHOLD || dy > MOVE_THRESHOLD) {
movedCount++;
movedLabels.Add(kvp.Key + "(" + bCoord.Item1 + "," + bCoord.Item2 +
"→" + aCoord.Item1 + "," + aCoord.Item2 + ")");
}
} else {
disappearedCount++;
}
}
foreach (var kvp in afterCoords) {
if (!beforeCoords.ContainsKey(kvp.Key)) appearedCount++;
}
int totalChanges = movedCount + disappearedCount + appearedCount;
string movedInfo = movedLabels.Count > 0
? " | сдвинуты: " + string.Join(", ", movedLabels.ToArray())
: "";
if (movedCount == 0 && disappearedCount == 0 && appearedCount == 0) {
domChangeLevel = 0;
domChangeSummary = string.Format("Координаты идентичны: {0} общих элементов", beforeCoords.Count);
} else if (movedCount > 0) {
// Только реальный сдвиг координат = нужен рефреш
domChangeLevel = 2;
domChangeSummary = string.Format(
"Координаты сдвинулись: сдвинуто={0}, исчезло={1}, появилось={2}{3}",
movedCount, disappearedCount, appearedCount, movedInfo);
} else {
// Исчезли/появились элементы но координаты оставшихся не сдвинулись
// Например: поле заполнено → label изменился, но кнопки на месте
domChangeLevel = 1;
domChangeSummary = string.Format(
"Состав элементов изменился (исчезло={0}, появилось={1}), координаты стабильны",
disappearedCount, appearedCount);
}
}
project.SendInfoToLog("DOM diff [" + execAction + "]: level=" +
domChangeLevel + " | " + domChangeSummary, true);
} catch (System.Exception exDiff) {
domChangeLevel = 1;
domChangeSummary = "DOM diff исключение: " + exDiff.Message;
project.SendInfoToLog("DOM diff EXCEPTION (level=1): " + exDiff.Message, false);
}
try { project.Variables["DomChangeLevel"].Value = domChangeLevel.ToString(); } catch { }
try { project.Variables["DomChangeSummary"].Value = domChangeSummary; } catch { }
// =====================================================================
// Сбор данных для step_status.txt
// Заменяет: instance.ActiveTab.URL / instance.ActiveTab.Title
// =====================================================================
string curApp = "";
try { curApp = instance.DroidInstance.App.Top ?? ""; } catch { }
string curActivity = "";
try {
curActivity = droidInput.Shell(
"dumpsys activity activities | grep mResumedActivity") ?? "";
curActivity = curActivity.Replace("\r","").Replace("\n","").Trim();
if (curActivity.Length > 200) curActivity = curActivity.Substring(0, 200);
} catch { }
// SAVE_FROM: "app" и "activity" вместо "url" и "page_title"
string cap = "";
if (execSaveFrom == "app") cap = curApp;
else if (execSaveFrom == "activity") cap = curActivity;
else if (execSaveFrom == "input_value") cap = execVal;
else if (execSaveFrom == "url") cap = curApp; // legacy fallback
else if (execSaveFrom == "page_title") cap = curActivity; // legacy fallback
if (!string.IsNullOrEmpty(execSaveKey) && !string.IsNullOrEmpty(cap))
try { project.Variables[execSaveKey].Value = cap; } catch { }
System.IO.File.WriteAllText(STATUS_FILE,
"STEP_NUM=" + currentStepNum + "\n" +
"ACTION=" + execAction + "\n" +
"STATUS=done\n" +
"CAPTURED_VALUE=" + cap + "\n" +
"CURRENT_APP=" + curApp + "\n" + // ← DROID (было CURRENT_URL)
"CURRENT_ACTIVITY=" + curActivity + "\n" + // ← DROID (было PAGE_TITLE)
"SCREENSHOT=" + SCREEN_FILE + "\n" +
"DOM_CHANGE_LEVEL=" + domChangeLevel + "\n",
new System.Text.UTF8Encoding(false));
project.SendInfoToLog("Status | step=" + currentStepNum +
" " + execAction + " app=" + curApp, true);
// =====================================================================
// Верификация нужна только для CLICK (идентично ZP)
// =====================================================================
// INPUT тоже верифицируем — нужно убедиться что текст введён и поиск запустился
bool needsVerify = (execAction == "CLICK" || execAction == "INPUT");
// =====================================================================
// Следующий шаг (идентично ZP)
// =====================================================================
int nextStepNum = currentStepNum + 1;
Global.ZennoLab.Json.Linq.JToken nextStep = null;
foreach (var s in steps)
if (s["step_num"] != null && s["step_num"].ToString() == nextStepNum.ToString())
{ nextStep = s; break; }
if (nextStep == null) {
project.SendInfoToLog("Шаг #" + currentStepNum + " — последний.", true);
System.IO.File.WriteAllText(NEXT_FILE,
"ACTION=DONE\nSTEP_NUM=0\nLABEL=\nX=0\nY=0\nVALUE=\n" +
"SCROLL_DIR=down\nSCROLL_PX=300\nWAIT_SEC=0\n" +
"SAVE_KEY=\nSAVE_FROM=none\nSAVE_HINT=\n",
new System.Text.UTF8Encoding(false));
if (needsVerify) {
project.Variables["NeedsVerify"].Value = "true";
project.Variables["ChainNeedVerify"].Value = "true";
project.Variables["ChainDone"].Value = "false";
project.SendInfoToLog("Последний шаг CLICK — ожидаем верификации.", true);
} else {
project.Variables["NeedsVerify"].Value = "false";
project.Variables["ChainNeedVerify"].Value = "false";
project.Variables["ChainDone"].Value = "true";
project.Variables["ChainResult"].Value = "DONE";
project.SendInfoToLog("Цепочка завершена (" + execAction + ").", true);
}
return "SUCCESS_LAST_STEP";
}
// Записываем следующий шаг
System.Func<string, string, string> getNextStr = (key, def) =>
nextStep[key] != null ? nextStep[key].ToString() : def;
var nextLines = new System.Collections.Generic.List<string> {
"ACTION=" + getNextStr("action", "ERROR").ToUpper(),
"STEP_NUM=" + getNextStr("step_num", nextStepNum.ToString()),
"LABEL=" + getNextStr("label", ""),
"X=" + getNextStr("x", "0"),
"Y=" + getNextStr("y", "0"),
"VALUE=" + getNextStr("value", ""),
"SCROLL_DIR=" + getNextStr("scroll_dir", "down"),
"SCROLL_PX=" + getNextStr("scroll_px", "300"),
"WAIT_SEC=" + getNextStr("wait_sec", "0"),
"SAVE_KEY=" + getNextStr("save_key", ""),
"SAVE_FROM=" + getNextStr("save_from", "none"),
"SAVE_HINT=" + getNextStr("save_hint", ""),
};
System.IO.File.WriteAllLines(NEXT_FILE, nextLines.ToArray(),
new System.Text.UTF8Encoding(false));
project.Variables["CurrentStepNum"].Value = nextStepNum.ToString();
project.Variables["CurrentAction"].Value = getNextStr("action", "ERROR").ToUpper();
project.Variables["CurrentLabel"].Value = getNextStr("label", "");
project.Variables["CurrentValue"].Value = getNextStr("value", "");
project.Variables["CurrentX"].Value = getNextStr("x", "0");
project.Variables["CurrentY"].Value = getNextStr("y", "0");
project.Variables["CurrentWaitSec"].Value = getNextStr("wait_sec","0");
project.Variables["CurrentSaveFrom"].Value = getNextStr("save_from","none");
project.Variables["CurrentSaveKey"].Value = getNextStr("save_key", "");
project.Variables["NeedsVerify"].Value = needsVerify ? "true" : "false";
project.Variables["ChainDone"].Value = "false";
// ПОСЛЕ (заменить на):
// =====================================================================
// CoordRefresh — расширенная логика (actionFailed + coords 0,0 + dom)
// =====================================================================
string nextAction = getNextStr("action", "").ToUpper();
bool nextNeedsCoords = (nextAction == "CLICK" || nextAction == "INPUT");
bool coordRefreshNeeded = false;
string coordRefreshReason = "";
int nextX = 0; int.TryParse(getNextStr("x", "0"), out nextX);
int nextY = 0; int.TryParse(getNextStr("y", "0"), out nextY);
bool nextCoordsZero = (nextX == 0 && nextY == 0);
if (actionFailed) {
coordRefreshNeeded = true;
coordRefreshReason = "текущее действие [" + execAction + "] завершилось с ошибкой";
} else if (!nextNeedsCoords) {
coordRefreshReason = "следующий шаг [" + nextAction + "] не требует координат";
} else if (nextCoordsZero) {
coordRefreshNeeded = true;
coordRefreshReason = "координаты следующего шага равны 0,0 — нужен поиск";
} else if (domChangeLevel == 0) {
coordRefreshReason = "DOM идентичен до/после — координаты актуальны";
} else if (domChangeLevel == 1) {
// Состав изменился но координаты стабильны — рефреш не нужен
coordRefreshReason = "DOM: состав элементов изменился но координаты стабильны — рефреш не нужен";
} else if (domChangeLevel == 2) {
// Реальный сдвиг координат — проверяем затронут ли следующий шаг
// Ищем label следующего шага в списке сдвинувшихся элементов
string nextLabel = getNextStr("label", "").Trim().ToLower();
bool nextLabelMoved = false;
if (!string.IsNullOrEmpty(nextLabel)) {
foreach (var ml in movedLabels) {
if (ml.ToLower().StartsWith(nextLabel)) { nextLabelMoved = true; break; }
}
}
// Если label следующего шага сдвинулся — рефреш нужен
// Если label не найден в сдвинувшихся — рефреш не нужен (элемент на месте)
if (nextLabelMoved || string.IsNullOrEmpty(nextLabel)) {
coordRefreshNeeded = true;
coordRefreshReason = "Координаты сдвинулись (level=2): " + domChangeSummary;
} else {
coordRefreshReason = "Координаты изменились но следующий элемент '" + getNextStr("label","") + "' на месте";
}
} else {
coordRefreshReason = "DOM без изменений";
}
project.Variables["CoordRefreshNeeded"].Value = coordRefreshNeeded ? "true" : "false";
project.Variables["ActionFailed"].Value = actionFailed ? "true" : "false";
project.SendInfoToLog("CoordRefresh: " + (coordRefreshNeeded ? "НУЖЕН" : "не нужен") +
" | " + coordRefreshReason, true);
if (coordRefreshNeeded) {
int rebuildFromStep = actionFailed ? currentStepNum : nextStepNum;
var remainingSteps = new System.Text.StringBuilder();
int idx = 0;
foreach (var s in steps) {
int sNum = 0;
int.TryParse(s["step_num"] != null ? s["step_num"].ToString() : "0", out sNum);
if (sNum < rebuildFromStep) continue;
idx++;
System.Func<string, string, string> gs = (k, d) => s[k] != null ? s[k].ToString() : d;
string sAct = gs("action","?").ToUpper(), sLbl = gs("label",""),
sVal = gs("value",""), sX = gs("x","0"), sY = gs("y","0");
string line = idx + ". " + sAct;
if (!string.IsNullOrEmpty(sLbl)) line += " on: " + sLbl;
if (!string.IsNullOrEmpty(sVal)) line += " (value: '" + sVal + "')";
if (sAct == "NAVIGATE") {
if (!string.IsNullOrEmpty(sVal) && (sVal.StartsWith("http://") || sVal.StartsWith("https://")))
line += " → C# will call: App.OpenUrl(\"" + sVal + "\", \"com.android.chrome\")";
else if (!string.IsNullOrEmpty(sVal))
line += " → C# will call: App.Open(\"" + sVal + "\") — value MUST be exact package name like com.android.chrome";
else
line += " [NAVIGATE value MISSING — set value to exact Android package name, e.g. \"com.android.chrome\" for Chrome. NEVER use \"chrome://\"]";
}
if ((sAct == "CLICK" || sAct == "INPUT") && (sX != "0" || sY != "0"))
line += " [current coords: " + sX + "," + sY + "]";
if ((sAct == "CLICK" || sAct == "INPUT") && sX == "0" && sY == "0")
line += " [coords MISSING — must find]";
remainingSteps.AppendLine(line);
}
string failNote = actionFailed
? "FAILED STEP: step " + currentStepNum + " [" + execAction + "] FAILED — value was invalid.\n" +
"FIX this step: if action=NAVIGATE, correct the 'value' to a valid Android package name (e.g. \"com.android.chrome\").\n" +
"NOTE: Steps after NAVIGATE depend on the app that NAVIGATE opens — do NOT look for their elements on the current screen.\n"
: "";
project.Variables["CoordRefreshPrompt"].Value =
"COORD-REFRESH MODE. DO NOT create a new plan. DO NOT add steps.\n" +
"Your ONLY task: find each element listed below in the current Android UI and return their FRESH x/y coordinates.\n\n" +
failNote +
"Current app: " + curApp + "\n" +
"Current activity: " + curActivity + "\n\n" +
"Remaining steps (starting from step " + rebuildFromStep + "). " +
"Coordinates in brackets are STALE and must be recalculated.\n" +
"Return a JSON with the same steps, same action/label/value, but FRESH x/y from current DOM:\n\n" +
remainingSteps.ToString();
project.SendInfoToLog("CoordRefresh: от шага #" + rebuildFromStep +
" [" + (actionFailed ? execAction : nextAction) + "]" +
(actionFailed ? " (actionFailed)" : " '" + getNextStr("label","") + "'") +
" требует актуальных координат.", true);
} else {
project.Variables["CoordRefreshPrompt"].Value = "";
}
project.SendInfoToLog(
"Next: #" + nextStepNum + " [" + getNextStr("action","?") + "] '" +
getNextStr("label","") + "'" +
" | NeedsVerify=" + (needsVerify ? "true" : "false") +
" | CoordRefresh=" + (coordRefreshNeeded ? "true" : "false") +
" | ActionFailed=" + (actionFailed ? "true" : "false") +
" | DomLevel=" + domChangeLevel, true);
return "ok";
После выполнения шага executor читает action_chain.json и ищет следующий шаг по номеру. Новый шаг записывается в next_action.txt. Счётчик CurrentStepNum обновляется в переменных проекта ZennoDroid — это позволяет механизму COORD-REFRESH знать, на каком шаге произошёл сбой.
ШАГ 5 Step Verifier — система самопроверки с LLaVA
Мобильные интерфейсы коварны. Клик мог физически пройти, но приложение зависло. Или выскочило окно «Оцените нас» и поглотило тап. Или реклама перекрыла нужный элемент. Step Verifier (step_verifier.py) решает эту проблему трёхуровневой системой верификации.
Уровень 1: Pixel Diff — быстрая проверка
Сравниваются скриншоты ДО и ПОСЛЕ действия. Вычисляется процент изменившихся пикселей. Если экран не изменился совсем — это явный RETRY без обращения к LLM.
# Настройки чувствительности (env vars):
MIN_DIFF_PCT = 0.3 # минимум для CLICK
MIN_DIFF_INPUT_PCT = 0.05 # минимум для INPUT (курсор мигнул = OK)
WARN_DIFF_PCT = 1.5 # порог для предупреждения
# Работает БЕЗ Pillow (fallback на ручной PNG-парсер через zlib)
def pixel_diff(before, after) -> float: # возвращает % изменений
Уровень 2: DOM Score — умная проверка структуры
Иногда пиксельный diff равен нулю, но DOM реально изменился — например, элемент получил фокус или раскрылся dropdown. DOM Score сравнивает JSON-структуры до и после и возвращает числовой балл: сколько элементов появилось, исчезло или изменило координаты.
# Если pixel_diff < MIN_DIFF_PCT, но DOM_score >= порога:
# → считаем OK без обращения к LLaVA (экономим время)
#
# Если pixel_diff < MIN_DIFF_PCT И DOM_score = 0:
# → FAST RETRY (экран не изменился ни визуально, ни структурно)
Уровень 3: LLaVA Vision — финальный арбитр
Если первые два уровня не дали однозначного ответа, подключается LLaVA. Алгоритм намеренно разделён на три прохода, чтобы не нагружать модель двумя изображениями сразу (что снижает точность).
- Pass-1A: отправляем скриншот ДО → LLaVA описывает состояние экрана текстом
- Pass-1B: отправляем скриншот ПОСЛЕ → LLaVA описывает новое состояние
- Pass-2: отправляем ОБА текстовых описания + DOM diff → LLM выносит вердикт
# Вердикты Step Verifier:
OK → действие успешно, переходим к следующему шагу
WAIT → экран загружается, подождать N секунд и проверить снова
RETRY → действие не сработало, повторить шаг
ERROR → критическая ошибка, запустить COORD-REFRESH
Защита от галлюцинаций LLM
Если LLM сказал «OK», но объективные данные (pixel diff близок к нулю + DOM score = 0) говорят обратное — вердикт автоматически переопределяется на RETRY. Это защищает от случаев, когда модель «галлюцинирует» успех.
# Override: LLM сказал OK, но данные говорят нет
Python:
if verdict == "OK" and diff < WARN_DIFF_PCT:
if "NO CHANGES" in dom_summary and dom_score == 0:
verdict = "RETRY"
confidence = "high"
reason = "LLM сказал OK, но pixel diff=0, DOM score=0"
COORD-REFRESH — самовосстановление без перезапускаЭто, пожалуй, самая элегантная часть всей системы. Представьте: агент выполнил шаги 1-3, на шаге 4 что-то пошло не так — появился баннер, элемент сдвинулся, приложение немного обновилось. Классический подход — остановка и ручной рестарт.
Наш агент делает иначе. Когда Step Verifier возвращает RETRY или ERROR, активируется режим COORD-REFRESH.
Как работает COORD-REFRESH
- Step Verifier детектирует сбой на шаге N
- Читается текущее состояние action_chain.json
- Строится специальный промпт: список ВСЕХ оставшихся шагов начиная с N, с пометкой что старые координаты устарели
- Промпт записывается в task_refresh.txt
- Запускается logic_control_module_refresh.py — он делает новый GetLayout(), получает свежий DOM и пересчитывает только координаты (не перепланирует действия)
- action_chain.json обновляется с новыми координатами
- Выполнение продолжается с шага N — теперь с актуальными координатами
# Пример COORD-REFRESH промпта (автогенерация):
COORD-REFRESH MODE. DO NOT create a new plan.
Your ONLY task: find each element listed below in the
current Android UI and return their FRESH x/y coordinates.
VERIFY FAILED: step 4 did not verify: pixel diff=0.1%
Current app: com.android.chrome
Current activity: com.google.android.apps.chrome.Main
Remaining steps (starting from step 4):
1. INPUT on: Search or type URL [current coords: 540,165]
2. CLICK on: Go button [coords MISSING — must find]
3. DONE
ЗАЧЕМ ЭТО ВАЖНО COORD-REFRESH — это разница между «скрипт упал» и «скрипт адаптировался».
Агент не начинает с нуля. Он не перепланирует задачу.
Он просто находит те же элементы на чуть изменившемся экране.
В реальных условиях это сокращает количество падений на 70-80%.
6. Полный поток выполнения
Ниже — полная последовательность работы агента от цели до результата. Именно такой поток реализован в блок-схеме ZennoDroid-проекта.
START
│
▼
[1] Инициализация: читаем tasks_all.txt → task_chain/task_01.txt
│
▼
[2] AI Element Mapper → dom_input.txt (JSON экрана)
│
▼
[3] Logic Control Module → action_chain.json (план шагов)
│
▼ ◄─────────────────────────────────────────────────────────┐
[4] ВЫПОЛНЕНИЕ: читаем next_action.txt, тапаем/вводим/скроллим │
│ │
▼ │
[5] Step Verifier: Pixel Diff + DOM Score + LLaVA │
│ │
├─ VERIFY=OK → следующий шаг ────────────────────────────► │
│ │
├─ VERIFY=WAIT → ждём N секунд → повтор верификации ──────── │
│ │
└─ VERIFY=RETRY/ERROR: │
│ │
[7] COORD-REFRESH: новый GetLayout() → пересчёт координат ───┘
│
▼
[ChainDone=true] → сохранить результат → переход к task_02.txt
7. Практический пример: агент открывает Chrome и ищет новости
Пройдём по полному циклу на конкретном примере: цель — «Нажми на папку Tools, открой Chrome, найди свежие новости».
Шаг 1: Task Chain Planner разбивает цель
python task_chain_planner.py "Click on Tools, open Chrome, search for news"
# Результат:
task_chain/task_01.txt:
Click on the Tools folder on the launcher,
then open Chrome by navigating to com.android.chrome
task_chain/task_02.txt:
The app is already on screen.
In Chrome address bar, type 'fresh news' and press search
Шаг 2: AI Mapper читает лаунчер
JSON:
# DOM лаунчера (упрощённо):
{ "elements": [
{ "id": 1, "tag": "button", "label": "Tools",
"cx": 810, "cy": 1450, "clickable": true },
{ "id": 2, "tag": "button", "label": "Phone",
"cx": 270, "cy": 1450, "clickable": true },
...
]
}
JSON:
{
"task_summary": "Open Tools folder then navigate to Chrome",
"confidence": "high",
"steps": [
{ "step_num": 1, "action": "CLICK",
"label": "Tools", "x": 810, "y": 1450 },
{ "step_num": 2, "action": "WAIT", "wait_sec": 2 },
{ "step_num": 3, "action": "NAVIGATE",
"value": "com.android.chrome", "x": 0, "y": 0 },
{ "step_num": 4, "action": "DONE" }
]
}
| Pixel Diff после CLICK на Tools | 12.4% — экран изменился ![]() |
| DOM Score после открытия папки | +8 элементов появилось ![]() |
| LLaVA вердикт | OK — папка открылась, Chrome виден ![]() |
Второй группой task_02.txt копируется в task.txt, и агент повторяет цикл уже внутри Chrome — находит адресную строку, вводит запрос, нажимает Enter.
8. Производительность и настройка под ваши нужды
Выбор LLM-модели
| Компонент | Технология | Роль |
| LLaMA 3 (локально) | Ollama | Полная приватность, ~2-4с планирование, GPU 8GB |
| Mistral 7B (локально) | Ollama | Чуть быстрее LLaMA, хорошо для коротких задач |
| Qwen 2.5:14B (локально) | Ollama | Лучшее качество локально, GPU 16GB+ |
| GPT-4o-mini (API) | OpenAI | Высокое качество, 1-2с, платно, не локально |
| LLaVA (локально) | Ollama | Только для Step Verifier, можно заменить на CogVLM |
# logic_control_module.py
MODEL_NAME = "llama3" # или "qwen2.5:14b" для лучшего качества
CLEAR_CONTEXT = False # True = сбросить память между запусками
# step_verifier.py (через env vars)
MIN_DIFF_PCT = 0.3 # % изменения для CLICK
MIN_DIFF_INPUT_PCT = 0.05 # % изменения для INPUT
VISION_MODEL = "llava" # или "cogvlm2" / "bakllava"
TEXT_MODEL = "llama3" # LLM для Pass-2 вердикта
Многопоточность
Система изначально разработана под многопоточную среду ZennoDroid. Каждый поток работает со своей папкой проекта (свой dom_input.txt, свой action_chain.json и т.д.), поэтому конкуренции за файлы нет. Python-скрипты запускаются как отдельные процессы и завершаются по таймауту (45 секунд для верификатора).
9. Установка и быстрый старт
Требования
- ZennoDroid (любая актуальная версия)
- Python 3.10+ с пакетами: ollama, Pillow
- Ollama с моделями: ollama pull llama3 / ollama pull llava
- ADB в PATH (обычно уже есть при установке ZennoDroid)
project_folder/
├── logic_control_module.py ← мозг агента
├── logic_control_module_refresh.py ← пересчёт координат
├── step_verifier.py ← система верификации
├── task_chain_planner.py ← планировщик задач
├── goal.txt ← ваша цель
├── task.txt ← текущий промпт для LCM
├── task_chain/ ← сгенерированные группы
│ ├── task_01.txt
│ └── task_02.txt
└── [runtime files]
├── dom_input.txt ← пишет ZennoDroid
├── action_chain.json ← пишет LCM
├── verify_result.txt ← пишет Step Verifier
└── context_store.json ← персистентная память
Порядок запуска
- Установить Ollama, скачать модели: llama3, llava
- Скопировать все .py файлы в папку ZennoDroid-проекта
- Создать переменные проекта: AiDroidTree, AiDroidForAI, ChainDone, CurrentStepNum, NeedsVerify, VerifyOK, CoordRefreshNeeded, CoordRefreshPrompt, ChainNeedVerify, ChainResult, VerifyMsg
- Написать цель в goal.txt → запустить task_chain_planner.py
- Скопировать task_chain/task_01.txt → task.txt
- Запустить ZennoDroid-проект — агент начнёт работать
Добавление нового типа действия
Система легко расширяется. Чтобы добавить новый тип (например, LONG_PRESS — долгое нажатие), нужно:
- Добавить константу в ActionType enum (logic_control_module.py)
- Добавить обработку в system prompt LLM
- Добавить case в Action Executor (C#)
- Добавить AUT
K_ACTIONS правило в step_verifier.py при необходимости
LCM использует ollama.Client() — это работает с любой моделью, поддерживаемой Ollama. Для внешних API (OpenAI, Anthropic, Groq) достаточно заменить клиент в методе plan() — всё остальное останется без изменений.
Возможные улучшения
- Распознавание капчи: добавить специальный ActionType.CAPTCHA с вызовом внешнего решателя
- Автоматическое переключение task-файлов: ZennoDroid может сам читать task_plan.json и переходить к следующей группе
- Облачный LLM для сложных экранов: fallback на GPT-4o при низкой confidence локальной модели
- Кэширование DOM-решений: сохранять маппинг элемент→координаты для повторяющихся экранов
- Расширенный ContextStore: хранить историю успешных цепочек для схожих задач (few-shot learning)
То, что мы построили — это не просто «умный кликер». Это полноценный автономный агент, который понимает задачу, читает экран как человек, планирует последовательность действий и самостоятельно исправляет ошибки.
Давайте ещё раз пройдёмся по ключевым возможностям:
- Нет хардкода координат. LLM находит элементы по семантике в реальном времени.
- Нет поломок при обновлениях APK. Изменился интерфейс? COORD-REFRESH пересчитает координаты без вашего участия.
- Самопроверка на каждом шаге. Три уровня верификации (Pixel Diff, DOM Score, LLaVA) исключают «тихие» ошибки.
- Многоэкранные задачи. Task Chain Planner разбивает любую цель на управляемые группы.
- Персистентная память. Агент запоминает данные между итерациями через ContextStore.
- Полная приватность. Все модели работают локально через Ollama — данные не покидают ваш сервер.
ИСХОДНЫЙ КОД Все файлы приложены к статье:
• logic_control_module.py — основной модуль планирования
• logic_control_module_refresh.py — COORD-REFRESH модуль
• step_verifier.py — верификация шагов
• task_chain_planner.py — планировщик задач по экранам
• C# сниппеты для ZennoDroid: Mapper, Initializer, Executor, Verifier
Задавайте вопросы в комментариях — отвечу на всё!
Если статья была полезна — не забудьте поставить плюс
Если статья была полезна — не забудьте поставить плюс
Вложения
-
229,2 КБ Просмотры: 16
-
216,4 КБ Просмотры: 2
Последнее редактирование:



