ИИ берет под контроль смартфон: Создание автономных Android-агентов в ZennoDroid

SAT

Client
Регистрация
24.12.2024
Сообщения
56
Благодарностей
117
Баллы
33
КОНКУРСНЫЙ КЕЙС

ZennoDroid + LLM

ИИ берёт под контроль смартфон
Создание автономных Android-агентов в ZennoDroid


Полная архитектура · Исходные коды · Живые результаты
140539

О ЧЁМ СТАТЬЯ Вы устали переписывать шаблоны каждый раз, когда разработчики приложения меняют интерфейс?
В этом кейсе — готовая архитектура автономного мобильного агента, который сам «читает» экран,
сам принимает решения и сам себя проверяет. Никакого хардкода координат. Никаких 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 VerifierLLaVA + Pixel DiffАвтоматическая верификация каждого шага
При сбое координатОстановка скриптаCOORD-REFRESH: пересчёт на лету

ГЛАВНЫЙ ПРИНЦИП Скрипт не ищет тупо координату или XPath.
Агент сам разглядывает экран, анализирует иерархию элементов,
принимает осознанные решения о тапах и свайпах,
а затем сам себя проверяет — успешно ли прошло действие.

3. Архитектура: два слоя и семь компонентов
Система разделена на два чётких слоя — Frontend (ZennoDroid, работает с устройством) и Backend (Python + LLM, работает с данными). Взаимодействие идёт через файловый протокол: JSON и TXT-файлы в папке проекта.

Стек технологий

КомпонентТехнологияРоль
ZennoDroidC# сниппетыFrontend: эмулятор, тапы, скриншоты, ADB
AI Element MapperC# + UIAutomatorПарсинг XML экрана → компактный JSON
Logic Control ModulePython + LLaMA 3Анализ DOM → цепочка действий
Task Chain PlannerPython + LLMРазбивка цели на группы по экранам
Action ExecutorC# + DroidInstanceФизическое выполнение: тап, ввод, скролл
Step VerifierPython + LLaVAВерификация: Pixel Diff + DOM diff + ИИ-зрение
COORD-REFRESHPython + 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");
Этап 1.2: Парсинг ВСЕХ тегов с coordinates
Стандартный 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);
}
Этап 1.3: Классификация и компактный JSON
Каждый элемент классифицируется по семантическому типу: 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 }
}
284 сырых тега превращаются в 31 значимый элемент — именно столько нужно LLM для принятия решений.
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("&amp;",  "&")
            .Replace("&lt;",   "<")
            .Replace("&gt;",   ">")
            .Replace("&quot;", "\"")
            .Replace("&apos;", "'");

        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";
}
ШАГ 2 Task Chain Planner — разбиваем цель на экраны

Сложная цель («зайди в аккаунт, открой Настройки, смени пароль, выйди») требует работы на нескольких экранах. Нельзя планировать все шаги сразу — элементы следующего экрана ещё не существуют в 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 },
    ...
  ]

}
Шаг 3: LLM генерирует action_chain.json
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" }
  ]
}
Шаг 4: Step Verifier проверяет каждый шаг
Pixel Diff после CLICK на Tools12.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-проект — агент начнёт работать
10. Точки расширения системы
Добавление нового типа действия
Система легко расширяется. Чтобы добавить новый тип (например, LONG_PRESS — долгое нажатие), нужно:

  • Добавить константу в ActionType enum (logic_control_module.py)
  • Добавить обработку в system prompt LLM
  • Добавить case в Action Executor (C#)
  • Добавить AUTo_OK_ACTIONS правило в step_verifier.py при необходимости
Интеграция с другими LLM
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)
11. Заключение: добро пожаловать в эру автономной автоматизации
То, что мы построили — это не просто «умный кликер». Это полноценный автономный агент, который понимает задачу, читает экран как человек, планирует последовательность действий и самостоятельно исправляет ошибки.

Давайте ещё раз пройдёмся по ключевым возможностям:

  • Нет хардкода координат. LLM находит элементы по семантике в реальном времени.
  • Нет поломок при обновлениях APK. Изменился интерфейс? COORD-REFRESH пересчитает координаты без вашего участия.
  • Самопроверка на каждом шаге. Три уровня верификации (Pixel Diff, DOM Score, LLaVA) исключают «тихие» ошибки.
  • Многоэкранные задачи. Task Chain Planner разбивает любую цель на управляемые группы.
  • Персистентная память. Агент запоминает данные между итерациями через ContextStore.
  • Полная приватность. Все модели работают локально через Ollama — данные не покидают ваш сервер.
Вы больше не пишете сотни экшенов под каждое приложение. Вы просто формулируете цель в goal.txt — например, «открой Chrome и найди свежие новости» — а агент сам прокладывает маршрут по UI.

ИСХОДНЫЙ КОД Все файлы приложены к статье:
• logic_control_module.py — основной модуль планирования
• logic_control_module_refresh.py — COORD-REFRESH модуль
• step_verifier.py — верификация шагов
• task_chain_planner.py — планировщик задач по экранам
• C# сниппеты для ZennoDroid: Mapper, Initializer, Executor, Verifier


Задавайте вопросы в комментариях — отвечу на всё!

Если статья была полезна — не забудьте поставить плюс
 

Вложения

Последнее редактирование:
  • Спасибо
Реакции: brun0 и deskuznetsov

Кто просматривает тему: (Всего: 1, Пользователи: 1, Гости: 0)