Skip to main content

Архитектура AI Orchestrator


@lowcode/ai-orchestrator

@lowcode/ai-orchestrator — это библиотека‑оркестратор AI‑запросов для Lowcode Platform. Она находится «между» редактором и конкретными AI‑провайдерами и решает задачи:

  • формирование промпта на основе DSL (AppSchema), контекста редактора и текста запроса пользователя;
  • нормализация вызова моделей в единый формат (AiModelRequest / AiModelResponse);
  • разбор ответа модели (JSON → набор типизированных операций над DSL AiOperation[]);
  • аккуратное применение этих операций к AppSchema и выдача нового DSL + текстового объяснения.

Промпт учитывает актуальные глобальные контексты выражений, включая route.* (path + params + query) и event.* (внутри обработчиков), а также поддерживает navigate с params и query.

Библиотека опирается на:

  • @lowcode/dsl — описание схемы приложения (AppSchema, ComponentNode и т.п.);
  • @lowcode/shared-types — общий контракт AI (AiAssistRequest, AiAssistResponse, AiModelRequest, AiModelResponse, AiOperation, подсказки контекста и т.д.).

Хост (обычно backend @lowcode/api) предоставляет только:

  • реализацию callModel (как сходить в OpenAI / AI Tunnel / локальную модель);
  • выбор провайдера (provider) и модели (model);
  • логгирование, авторизацию и остальные инфраструктурные вещи.

1. Роль ai-orchestrator в общей архитектуре

Высокоуровневый сценарий работы AI‑ассистента:

builder-web (UI, чат пользователя)
→ HTTP POST /ai/assist (apps/api)
→ AiService (NestJS) собирает AiAssistRequest
→ @lowcode/ai-orchestrator.assist(input, callModel)
→ buildAssistModelRequest(...) # формирование промпта
→ callModel(modelRequest) # вызов AI-провайдера
→ parseAssistModelResponse(modelResponse) # разбор JSON-ответа
→ applyAiOperations(appSchema, operations) # операции над DSL
→ ответ AiAssistResponse
→ builder-web обновляет DSL и показывает текстовое объяснение

Ключевые идеи:

  • Чистота: ai-orchestrator ничего не знает ни про HTTP, ни про SDK конкретных моделей. Вся сеть — во внешнем callModel.
  • DSL‑центричность: библиотека работает с DSL и над DSL — промпт строится вокруг AppSchema, результатом являются операции над AppSchema.
  • Унификация: независимо от того, используем ли мы прямой OpenAI‑API, AI Tunnel или локальную модель, на входе/выходе у ai-orchestrator всегда один и тот же контракт.

2. Структура пакета

packages/ai-orchestrator/
├─ src/
│ ├─ index.ts # Публичный API: re-export assist()
│ └─ assist/
│ ├─ assist.ts # Главный use-case: функция assist(...)
│ ├─ buildPrompt.ts # Формирование AiModelRequest (messages[]) из AiAssistRequest
│ ├─ parseResponse.ts # Разбор AiModelResponse → AiOperation[] + explanation
│ └─ applyOperations.ts # Применение AiOperation[] к AppSchema
├─ tests/
│ └─ assist/
│ ├─ assist.test.ts # Интеграционный тест пайплайна assist()
│ ├─ buildPrompt.test.ts # Юнит-тест формирования промпта
│ ├─ parseResponse.test.ts # Юнит-тест разбора ответа
│ └─ applyOperations.test.ts # Юнит-тест применения операций к DSL
├─ tsconfig.json # Основной TS-конфиг пакета
├─ tsconfig.spec.json # TS-конфиг для Jest-тестов
├─ tsconfig.docs.json # TS-конфиг для Typedoc
├─ typedoc.json # Конфиг Typedoc (entryPoints + out)
├─ jest.config.cjs # Конфигурация Jest
└─ package.json # Метаданные пакета и скрипты

Основные слои:

  • assist‑слой (assist.ts) — высокоуровневый use‑case: запрос → model → операции → новый DSL;
  • prompt‑слой (buildPrompt.ts) — сборка system/user‑сообщений для модели в OpenAI‑подобном формате;
  • parser‑слой (parseResponse.ts) — разбор текстового ответа модели в структурированный формат;
  • applier‑слой (applyOperations.ts) — применение операций к AppSchema без знания о конкретном UI.

3. Ключевые типы и сущности

Типы находятся в @lowcode/shared-types/src/ai.ts. Ниже — концептуальный обзор.

3.1. AiAssistRequest / AiAssistResponse

Высокоуровневый контракт между builder-web и backend (а внутри backend’а — между API и ai-orchestrator):

interface AiAssistRequest {
/** Текст запроса пользователя (чат-ввод) */
prompt: string;

/** Текущее приложение целиком — основной источник истины для AI */
appSchema: AppSchema;

/** Дополнительный контекст проекта/страницы */
projectId?: string;
pageId?: string;

/** Подсказки о selection и состоянии редактора */
hints?: AiEditorContextHints;

/** Выбранный провайдер и модель (UI в builder-web) */
provider: 'direct' | 'aiTunnel' | 'local';
model: string;

/** Опциональные идентификаторы для логгирования и трейсинга */
requestId?: string;
}

interface AiAssistResponse {
/** Новый AppSchema после применения операций (если они были) */
updatedAppSchema?: AppSchema;

/** Сырые операции, предложенные моделью */
operations?: AiOperation[];

/** Текстовое объяснение, что было сделано / почему */
explanation?: string;

/** Информация об ошибке, если что-то пошло не так */
error?: AiError;
}

AiAssistRequest собирается на стороне API из данных, пришедших от builder-web. AiAssistResponse возвращается обратно в редактор, где уже принимается решение — обновлять DSL, показывать diff, проигрывать изменения пошагово и т.п.

3.2. AiModelRequest / AiModelResponse

Низкоуровневый контракт между ai-orchestrator и конкретным провайдером модели. Формат максимально близок к OpenAI‑подобному chat‑API:

interface AiModelRequest {
provider: AiProviderType; // 'direct' | 'aiTunnel' | 'local'
model: string;
messages: AiChatMessage[]; // system + user (и далее, если нужно)
maxTokens?: number;
temperature?: number;
requestId?: string;
traceId?: string;
tools?: AiToolDefinition[]; // зарезервировано под function calling
toolChoice?: AiToolChoice;
}

interface AiModelResponse {
messages: AiChatMessage[]; // обычно одно assistant-сообщение
usage?: AiUsage; // опциональная статистика токенов
toolCalls?: AiToolCall[]; // под function calling / tools
}

Эти типы используются внутри assist() и buildAssistModelRequest(). Реальная работа с сетью происходит в переданном извне callModel(req).

3.3. AiOperation — операции над DSL

AiOperation описывает элементарное изменение в DSL. Типы операций ориентированы на реальные сценарии в редакторе:

type AiOperationKind =
| 'explainSelection'
| 'addComponent'
| 'moveComponent'
| 'updateComponentProps'
| 'updateLayout'
| 'deleteComponent'
| 'addPage'
| 'updatePage'
| 'updateAppShell'
| 'deletePage'
| 'addDataSource'
| 'updateDataSource'
| 'deleteDataSource'
| 'addEventHandler'
| 'updateEventHandler'
| 'deleteEventHandler'
| 'addAppStateVariable'
| 'updateAppStateVariable'
| 'deleteAppStateVariable'
| 'addPageStateVariable'
| 'updatePageStateVariable'
| 'deletePageStateVariable'
| 'fixValidationErrors'
| 'other';

Примеры конкретных форм (упрощённо):

interface AiAddComponentOperation {
kind: 'addComponent';
pageId: string;
parentId: string;
component: ComponentNode; // готовый DSL-компонент
}

interface AiMoveComponentOperation {
kind: 'moveComponent';
pageId: string;
componentId: string;
parentId?: string;
insertPosition?: 'auto' | 'start' | 'end' | { beforeComponentId?: string; afterComponentId?: string };
}

interface AiUpdateComponentPropsOperation {
kind: 'updateComponentProps';
pageId: string;
componentId: string;
propsPatch?: Record<string, PropValue>;
layoutPatch?: Record<string, PropValue>;
stylePatch?: Record<string, PropValue>;
}

interface AiUpdateLayoutOperation {
kind: 'updateLayout';
pageId: string;
componentId: string;
layoutPatch: Record<string, PropValue>;
}

interface AiUpdateAppShellOperation {
kind: 'updateAppShell';
shell: ComponentNode | null;
}

interface AiAddDataSourceOperation {
kind: 'addDataSource';
dataSource: DataSource;
}

interface AiAddEventHandlerOperation {
kind: 'addEventHandler';
handler: EventHandler;
}

Полный набор операций описан и типизирован в @lowcode/shared-types.

3.4. Нормализация style/layout патчей

AI не всегда аккуратно разводит визуальные и layout‑поля, поэтому applyOperations.ts перед применением операций выполняет нормализацию:

  • все известные визуальные ключи (backgroundColor, borderRadius, fontSize, textAlign, className и т.п.) из propsPatch перекладываются в stylePatch;
  • layout‑ключи (width, height, gap, padding, alignItems, justifyContent, flexDirection, ...) перекладываются в layoutPatch;
  • если среди layout‑ключей встречаются flex‑настройки (flexDirection, alignItems, justifyContent, flexGrow и т.д.), а display не указан, автоматически подставляется display: "flex". Это отражает поведение Quick‑меню PropertiesPanel, где есть явный Flex toggle: без display: flex остальные flex‑свойства не работают.

Те же правила явно проговариваются в системном промпте (buildPrompt.ts), чтобы модель по возможности сразу возвращала корректные stylePatch и layoutPatch. Нормализация служит «страховкой», если ответ не соответствует требованиям.

Дополнительные нормализации в applyOperations.ts:

  • для DataSource приводится type → kind и config.value → config.data (для kind="static");
  • для встроенных компонентов отбрасываются неизвестные props (исключение — BlockInstance, где пропсы определяет схема блока).

3.5. AiEditorContextHints — подсказки контекста редактора

Чтобы модель могла корректно интерпретировать фразы вроде «эта кнопка», «этот текст», ai-orchestrator принимает подсказки из редактора:

interface AiSelectionHint {
kind: 'component' | 'page' | 'app' | 'expression' | 'dataSource' | 'state';
pageId?: string;
componentId?: string;
propName?: string;
propSection?: 'props' | 'layout' | 'style' | 'state' | 'dataSource' | 'event';
dslPath?: string; // запасной вариант: путь внутри AppSchema
}

interface AiEditorContextHints {
primarySelection?: AiSelectionHint;
}

Редактор сам решает, что считать primarySelection (например, последнюю отмеченную ноду в Canvas или текущий фокус в PropertiesPanel). Эта информация сериализуется в user‑payload и попадает в промпт.

3.6. CallModelFn — адаптер провайдера моделей

ai-orchestrator не ходит в сеть сам. Вместо этого в assist() ему передают функцию:

export type CallModelFn = (req: AiModelRequest) => Promise<AiModelResponse>;

На уровне API её можно реализовать через:

  • официальный SDK OpenAI;
  • совместимый SDK, работающий через AI Tunnel (изменяется только baseURL и apiKey);
  • локальный HTTP-сервис, который принимает AiModelRequest и отвечает AiModelResponse;
  • заглушку для тестов.

4. Главный use-case: функция assist()

Входная точка пакета — функция assist():

export async function assist(
input: AiAssistRequest,
callModel: CallModelFn,
): Promise<AiAssistResponse> {
const modelRequest = buildAssistModelRequest(input);
const modelResponse = await callModel(modelRequest);

const { operations, explanation } = parseAssistModelResponse(modelResponse);
const updatedAppSchema = applyAiOperations(input.appSchema, operations);

return {
updatedAppSchema,
operations,
explanation,
};
}

Пайплайн:

  1. Формирование промптаbuildAssistModelRequest(input):

    • собирает system‑сообщение: роль ассистента, формат ответа, правила;
    • собирает user‑сообщение: JSON‑payload с полями prompt, appSchema, projectId, pageId, hints;
    • возвращает AiModelRequest с заполненными messages, provider, model, requestId.
  2. Вызов моделиcallModel(modelRequest):

    • реализуется на стороне API (см. раздел про интеграцию ниже);
    • на этом шаге выбирается, использовать ли прямой OpenAI, AI Tunnel или локальную модель.
  3. Разбор ответаparseAssistModelResponse(modelResponse):

    • ищет первое assistant‑сообщение в messages;
    • пытается распарсить content как JSON с полями { operations, explanation };
    • если JSON не парсится — воспринимает content как plain‑текст explanation и возвращает пустой список операций;
    • добавляет защиту от null/отсутствия сообщений.
  4. Применение операций к DSLapplyAiOperations(appSchema, operations):

    • создаёт копию исходного AppSchema (immutability);
    • последовательно применяет операции AiOperation (patch‑логика);
    • возвращает новый AppSchema, который можно сразу отрисовывать в превью.

Результирующий AiAssistResponse уже удобно отдавать в builder-web, где можно либо прямо заменить DSL, либо показать diff.


5. Формирование промпта: buildPrompt.ts

Файл assist/buildPrompt.ts отвечает за создание AiModelRequest.

Основные принципы:

  • в system‑сообщении задаются правила работы ассистента:

    • он работает в контексте low-code‑редактора;
    • оперирует строго структурированным DSL (AppSchema);
    • обязан возвращать JSON с полями operations и explanation;
    • не имеет права самовольно придумывать несуществующие типы компонент или пропсы;
    • знает про order в ComponentNode и использует moveComponent для перестановки детей;
    • должен учитывать hints.primarySelection как «то, про что говорит пользователь» по умолчанию.
  • в user‑сообщении содержится один JSON‑объект со структурой примерно вида:

    {
    "prompt": "Сделай эту кнопку более круглой",
    "projectId": "...",
    "pageId": "page-1",
    "appSchema": {
    /* текущее DSL-приложение целиком */
    },
    "hints": {
    "primarySelection": {
    "kind": "component",
    "pageId": "page-1",
    "componentId": "btn-1",
    "propSection": "props",
    },
    },
    }

На уровне AiModelRequest никакой бизнес‑логики больше не происходит. Всё, что связано с выбором модели и провайдера, приходит из AiAssistRequest.


6. Разбор ответа: parseResponse.ts

Файл assist/parseResponse.ts концентрируется на безопасном разборе ответа модели.

Алгоритм:

  1. Берётся первое сообщение с role: 'assistant' из AiModelResponse.messages.
  2. Если сообщение отсутствует — возвращается { operations: [], explanation: undefined }.
  3. Пытается распарсить message.content как JSON.
    • При успехе:

      • ожидаются поля operations (массив) и explanation (строка);
      • при отсутствии operations подставляется пустой массив.
    • При ошибке парсинга:

      • содержимое поля content воспринимается как plain‑текст explanation;
      • операции считаются пустыми.

Такой дизайн позволяет:

  • на ранних этапах (когда промпт ещё сырой) всё равно выдавать пользователю полезный текстовый ответ;
  • постепенно ужесточать контракт и схему JSON без ломания API.

7. Применение операций к DSL: applyOperations.ts

Файл assist/applyOperations.ts отвечает за применение AiOperation[] к AppSchema.

Особенности реализации:

  • входящий AppSchema не мутируется — всегда создаётся новая структура;
  • операции применяются по очереди, в одном проходе;
  • при невозможности применить конкретную операцию (например, компонент не найден) — операция пропускается, остальные продолжают выполняться.

Поддерживаются, в частности, следующие операции:

  • addComponent

  • ищется PageSchema по pageId;

  • ищется родительский ComponentNode по parentId в дереве страницы;

  • в children родителя добавляется новый ComponentNode.

  • moveComponent

  • ищется компонент и его родитель по componentId;

  • компонент удаляется из текущего родителя и вставляется в parentId (если задан) или в текущего родителя;

  • позиция вставки контролируется insertPosition (start/end/before/after/auto).

  • если в детях уже используется order, он нормализуется под новый порядок.

  • updateComponentProps

    • ищется компонент по pageId + componentId;
    • props, layout и style патчатся shallow‑merge’ом (новые значения заменяют старые).
  • updateLayout

    • аналогично, но патчится layout.
  • addDataSource

    • новый DataSource добавляется в appSchema.dataSources;
    • при отсутствии массива dataSources создаётся новый.
  • updateDataSource

    • ищется существующий источник по dataSourceId;
    • поверх него применяется shallow‑patch из patch.
  • addEventHandler

    • EventHandler добавляется к appSchema.eventHandlers (для компонентов или страниц);
    • создаётся массив, если его не было.

Логика deliberately простая — основная ответственность за корректность операций лежит на промпте и модели. При необходимости в будущем сюда можно добавить строгую валидацию / отчёт о неудачных операциях.


8. Интеграция с backend API (@lowcode/api)

Типичный сценарий использования ai-orchestrator в NestJS‑сервисе:

@Injectable()
export class AiService {
constructor(/* зависимости для реальных SDK провайдеров */) {}

private async callModel(req: AiModelRequest): Promise<AiModelResponse> {
// Здесь реализуется адаптер: OpenAI, AI Tunnel или локальный HTTP-сервис.
// Пример для AI Tunnel (псевдокод):
//
// const client = new OpenAI({
// apiKey: process.env.AI_TUNNEL_API_KEY,
// baseURL: 'https://api.aitunnel.ru/v1/',
// });
//
// const result = await client.chat.completions.create({
// model: req.model,
// messages: req.messages,
// max_tokens: req.maxTokens,
// temperature: req.temperature,
// });
//
// return {
// messages: [
// {
// role: result.choices[0].message.role,
// content: result.choices[0].message.content,
// },
// ],
// usage: {
// promptTokens: result.usage?.prompt_tokens,
// completionTokens: result.usage?.completion_tokens,
// totalTokens: result.usage?.total_tokens,
// },
// };

throw new Error('callModel не реализован');
}

async assist(request: AiAssistRequest): Promise<AiAssistResponse> {
return assist(request, (modelReq) => this.callModel(modelReq));
}
}

Дальше поверх этого сервиса строится контроллер AiController с эндпоинтом вроде:

POST /ai/assist

который:

  • принимает DTO (близкий к AiAssistRequest);
  • достаёт AppSchema (из тела запроса или из БД по projectId/version);
  • вызывает aiService.assist();
  • возвращает AiAssistResponse редактору.

9. Интеграция с builder-web

На стороне builder-web ai-orchestrator напрямую не используется — редактор разговаривает с backend API.

Типичный UX‑флоу для AI‑панели в редакторе:

  1. Пользователь выделяет компонент/страницу и нажимает на кнопку AI‑ассистента.

  2. Открывается чат‑панель, пользователь вводит запрос (prompt).

  3. Редактор собирает:

    • текущий AppSchema (или берёт последнюю сохранённую версию);
    • projectId / pageId;
    • hints.primarySelection (тип selection, компонент, секция и т.п.);
    • выбранного провайдера и модель (UI‑селекторы: direct / aiTunnel + конкретная модель).
  4. Всё это отправляется в API в формате AiAssistRequest.

  5. API через @lowcode/ai-orchestrator возвращает AiAssistResponse.

  6. Редактор:

    • подставляет updatedAppSchema в превью;
    • при желании показывает diff (список операций);
    • отображает explanation для пользователя.

На уровне ai-orchestrator вся эта логика уже абстрагирована: библиотека видит только один чистый объект AiAssistRequest.


10. Тестирование

Пакет @lowcode/ai-orchestrator покрыт Jest‑тестами, которые повторяют структуру src/assist:

  • assist.test.ts

    • интеграционный тест для функции assist():
      • мокает callModel и возвращает заранее подготовленный AiModelResponse;
      • проверяет, что на выходе получаем корректный updatedAppSchema, список операций и explanation;
      • убеждается, что callModel вызывается ровно один раз.
  • buildPrompt.test.ts

    • проверяет, что buildAssistModelRequest():
      • формирует 2 сообщения (system + user);
      • корректно сериализует AiAssistRequest в JSON‑payload user‑сообщения;
      • прокидывает provider, model, requestId в AiModelRequest.
  • parseResponse.test.ts

    • проверяет разбор JSON‑ответа и fallback в plain‑текст:
      • корректный JSON → извлекаются operations и explanation;
      • невалидный JSON → operations = [], explanation = content.
  • applyOperations.test.ts

    • тестирует применение основных операций AiOperation к простому AppSchema:
      • добавление компонента;
      • обновление props/layout/style;
      • добавление и обновление dataSources;
      • добавление eventHandlers;
      • проверка immutability (исходный DSL не модифицируется).

Запуск тестов:

pnpm --filter @lowcode/ai-orchestrator test

11. План развития

Ориентировочный roadmap для @lowcode/ai-orchestrator:

  1. Расширение набора AiOperation

    • более точные операции для appState / pageState;
    • отдельные операции для ExpressionValue (правки выражений);
    • операции по работе с eventHandlers (привязка действий к событиям, генерация обработчиков на основе текста).
  2. Поддержка function calling / tools

    • формальное описание операций как tools в промпте;
    • разбор toolCalls из AiModelResponse и маппинг их в AiOperation[];
    • более строгий контракт между моделью и библиотекой.
  3. Более строгая валидация ответов

    • схемы (JSON Schema / Zod) для проверки структур AiOperation;
    • отчёт о пропущенных/исправленных операциях для devtools.
  4. Расширенное логгирование и трейсинг

    • requestId/traceId во всех вызовах;
    • хуки для логгирования промпта, ответа и результата применения операций;
    • интеграция с общей системой логов платформы.
  5. Настройка промпта и профили

    • разные системные промпты для режимов: generate / explain / refactor / fix;
    • локализация подсказок под язык пользователя;
    • возможность конфигурировать стиль ответов (подробность, формальность).

12. Резюме

@lowcode/ai-orchestrator — это единый исполнительный слой для AI‑ассистента, который:

  • принимает на вход структурированный запрос AiAssistRequest (prompt + DSL + контекст);
  • формирует OpenAI‑подобный AiModelRequest для избранного провайдера;
  • разбирает AiModelResponse в список операций AiOperation[] и текстовое объяснение;
  • аккуратно применяет операции к AppSchema и возвращает новый DSL.

Благодаря этому:

  • логика работы с AI сосредоточена в одном месте и не дублируется между builder-web и API;
  • подключение новых провайдеров (OpenAI, AI Tunnel, локальные модели) сводится к реализации callModel;
  • развитие AI‑функций (генерация компонентов, рефакторинг, автоконфиг источников данных и т.п.) происходит без изменения контрактов DSL и без ломки редактора.