Как известно, под новый год случаются чудеса, и этот год не стал исключением. Мне удалось прикрутить LLM в визуальный язык программирования Scratch, чем и обрадовал ребенка. А началось всё в один прекрасный день, когда мой сын - школьник осваивал n8n и ваял телеграм бота. Разговорившись, мы вспомнили, что его увлечение программированием началось со Scratch. И его фраза, что было бы здорово, если бы в scratch была бы встроена иишечка, можно столько прикольных игр сделать, стала отправной точкой для данного проекта. Рассказываю и показываю, как мы реализовали эту безумную идею.

Типичная нейротян из Scratch
Типичная нейротян из Scratch

После апгрейда компьютера Scratch не был установлен в системе. Поэтому первым делом я скачал его и... почти сразу же удалил. Дело в том что он не умеет в HTTP-запросы. А без них общение с внешними API невозможны. Казалось бы можно сворачивать проект и не страдать больше фигнёй, но тут на сцену вышел Turbowarp. По сути, это тот же самый Scratch, только на максималках. Его ключевая фишка - поддержка пользовательских расширений (extension). То есть мы можем написать собственный extensions и добавить недостающую функциональность.

Интерфейс Turbowarp. Найдите 10 отличий со Scratch
Интерфейс Turbowarp. Найдите 10 отличий со Scratch

В качестве мозга был выбран Gigachat. Во-первых он бесплатный. Во-вторых не надо заморачиваться с VPN, а в третьих он работает без vpn и бесплатен.

Для подключения к API GigaChat необходимо:
  1. Зарегистрироваться в Studio по ссылке https://developers.sber.ru/studio/workspaces/

  2. Создать проект, в инструментах выбрать GigaChat API и заполнить все необходимые поля.

  3. Зайти в созданный проект и выбрать: "Настроить API"

  4. Получить и сохранить ключ.

Подготовительная часть окончена, теперь создадим прокси сервер. Он необходим из-за политики CORS, которая блокирует запросы к внешним API (позже я узнал, что в desktop версии можно отключить CORS, но было уже поздно. Для web версии сервер все равно необходим). Сервер будет:

  1. Принимать запросы от Turbowarp.

  2. Перенаправлять их в Gigachat.

  3. Возвращать ответ обратно в Scratch-проект.

Так как проект чисто для себя (и сына) то сервер будет локальный. Мой выбор в качестве фреймворка пал на FastAPI.

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], 
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

Как отмечал выше: проект локальный, поэтому разрешаем CORS со всех источников без лишних угрызений совести.

Настраиваем GIGACHAT.

GIGACHAT_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
GIGACHAT_CHAT_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"

AUTH_KEY = os.getenv("GIGACHAT_AUTH_KEY") 
SCOPE = "GIGACHAT_API_PERS"
MODEL_NAME = "GigaChat"

access_token = None
token_expires_at = 0

Так как токен живет 30 минут реализуем функцию, которая будет возвращать новый токен, после истечении отведенного времени

async def ensure_token():
    global access_token, token_expires_at

    now = time.time()
    if access_token and now < token_expires_at - 60:
        return access_token

    headers = {
        "Authorization": f"Basic {AUTH_KEY}",
        "Content-Type": "application/x-www-form-urlencoded",
        "Accept": "application/json",
        "RqUID": str(uuid.uuid4()),
    }

    data = f"scope={SCOPE}"

    async with httpx.AsyncClient(verify=False, timeout=30) as client:
        response = await client.post(
            GIGACHAT_AUTH_URL,
            headers=headers,
            content=data
        )

    response.raise_for_status()
    token_data = response.json()

    access_token = token_data["access_token"]
    token_expires_at = now + token_data.get("expires_in", 1800)


    return access_token

API будет максимально простым и без всяких наворотов. Реализованы всего три метода:

  • Отправляем сообщение

  • Получаем ответ

  • Очищаем чат

Полный код сервера
import time
import uuid
from fastapi import FastAPI, BackgroundTasks
from fastapi.middleware.cors import CORSMiddleware
from pydantic import BaseModel
import httpx
import os
# =====================
# APP
# =====================

app = FastAPI()

app.add_middleware(
    CORSMiddleware,
    allow_origins=["*"], 
    allow_credentials=True,
    allow_methods=["*"],
    allow_headers=["*"],
)

# =====================
# STATE
# =====================

chat_history = []
last_answer = ""
state = "idle" # idle | processing | ready

# =====================
# GIGACHAT CONFIG
# =====================

GIGACHAT_AUTH_URL = "https://ngw.devices.sberbank.ru:9443/api/v2/oauth"
GIGACHAT_CHAT_URL = "https://gigachat.devices.sberbank.ru/api/v1/chat/completions"

AUTH_KEY = os.getenv("GIGACHAT_AUTH_KEY") 
SCOPE = "GIGACHAT_API_PERS"
MODEL_NAME = "GigaChat"

access_token = None
token_expires_at = 0

# =====================
# MODELS
# =====================

class SendRequest(BaseModel):
    message: str

# =====================
# TOKEN
# =====================

async def ensure_token():
    global access_token, token_expires_at

    now = time.time()
    if access_token and now < token_expires_at - 60:
        return access_token

    headers = {
        "Authorization": f"Basic {AUTH_KEY}",
        "Content-Type": "application/x-www-form-urlencoded",
        "Accept": "application/json",
        "RqUID": str(uuid.uuid4()),
    }

    data = f"scope={SCOPE}"

    async with httpx.AsyncClient(verify=False, timeout=30) as client:
        response = await client.post(
            GIGACHAT_AUTH_URL,
            headers=headers,
            content=data
        )

    response.raise_for_status()
    token_data = response.json()

    access_token = token_data["access_token"]
    token_expires_at = now + token_data.get("expires_in", 1800)


    return access_token

# =====================
# GIGACHAT REQUEST
# =====================

async def request_gigachat():
    global chat_history, last_answer, state

    try:
        token = await ensure_token()

        headers = {
            "Authorization": f"Bearer {token}",
            "Content-Type": "application/json"
        }

        payload = {
            "model": MODEL_NAME,
            "messages": chat_history
        }

        async with httpx.AsyncClient(verify=False, timeout=60) as client:
            response = await client.post(
                GIGACHAT_CHAT_URL,
                headers=headers,
                json=payload
            )

        response.raise_for_status()
        data = response.json()

        answer = data["choices"][0]["message"]["content"]

        chat_history.append({
            "role": "assistant",
            "content": answer
        })

        last_answer = answer
        state = "ready"



    except Exception as e:
        print("error: ", repr(e))
        last_answer = ""
        state = "idle"

# =====================
# API
# =====================

@app.post("/clear")
async def clear_chat():
    global chat_history, last_answer, state

    chat_history = []
    last_answer = ""
    state = "idle"

    print("Chat cleared")
    return {"status": "cleared"}


@app.post("/send")
async def send_message(req: SendRequest, background_tasks: BackgroundTasks):
    global chat_history, last_answer, state

    if state == "processing":
        return {"status": "busy"}

    chat_history.append({
        "role": "user",
        "content": req.message
    })

    last_answer = ""
    state = "processing"

    print("Message received, querying GigaChat")
    background_tasks.add_task(request_gigachat)

    return {"status": "accepted"}


@app.get("/get")
async def get_answer():
    return {
        "answer": last_answer if state == "ready" else ""
    }
Запускаем сервер через терминал
uvicorn server:api --host 127.0.0.1 --port 8000

Или в режиме разработки, для автоматической перезагрузки при изменении кода:

uvicorn server:app --host 127.0.0.1 --port 8000 --reload

Когда сервер написан и запущен, переходим к заключительному этапу: пишем расширение для turbowarp. Итак нам нужно всего четыре элемента:

  1. Подключиться к серверу

  2. Отправить запрос

  3. Принять ответ

  4. Очистить чат

Код расширения
(function (Scratch) {
    "use strict";

    let serverUrl = "";
    let lastAnswer = "";

    class GigaChatProxy {

        getInfo() {
            return {
                id: "gigachatproxy",
                name: "GigaChat Proxy",
                blocks: [
                    {
                        opcode: "connect",
                        blockType: Scratch.BlockType.COMMAND,
                        text: "подключиться к серверу [URL]",
                        arguments: {
                            URL: {
                                type: Scratch.ArgumentType.STRING,
                                defaultValue: "http://127.0.0.1:8000"
                            }
                        }
                    },
                    {
                        opcode: "clearChat",
                        blockType: Scratch.BlockType.COMMAND,
                        text: "очистить чат"
                    },
                    {
                        opcode: "sendMessage",
                        blockType: Scratch.BlockType.COMMAND,
                        text: "отправить в чат [TEXT]",
                        arguments: {
                            TEXT: {
                                type: Scratch.ArgumentType.STRING,
                                defaultValue: "Привет"
                            }
                        }
                    },
                    {
                        opcode: "getAnswer",
                        blockType: Scratch.BlockType.REPORTER,
                        text: "получить ответ"
                    }
                ]
            };
        }

        connect({ URL }) {
            serverUrl = URL;
            lastAnswer = "";
        }

        async clearChat() {
            if (!serverUrl) return;

            try {
                await fetch(serverUrl + "/clear", {
                    method: "POST"
                });
            } catch (e) {
                // ошибки игнорируем
            }

            lastAnswer = "";
        }

        async sendMessage({ TEXT }) {
            if (!serverUrl) return;

            lastAnswer = "";

            try {
                await fetch(serverUrl + "/send", {
                    method: "POST",
                    headers: {
                        "Content-Type": "application/json"
                    },
                    body: JSON.stringify({ message: TEXT })
                });
            } catch (e) {
                // ошибки игнорируем
            }
        }

        async getAnswer() {
            if (!serverUrl) return "";

            try {
                const response = await fetch(serverUrl + "/get");
                const data = await response.json();

                if (data.answer && data.answer !== "") {
                    lastAnswer = data.answer;
                }
            } catch (e) {
                // игнор
            }

            return lastAnswer;
        }
    }

    Scratch.extensions.register(new GigaChatProxy());

})(Scratch);
Теперь можно в бой!
Теперь можно в бой!

После подключения расширения появляются новые блоки, которые можно использовать в проектах.

По традиции пишем "Hello, world!". Меняем спрайт на радующее глаз изображение и общаемся с моделью.

Hello, World!
И что ты мне на это ответишь?
И что ты мне на это ответишь?
Ты что такая дерзкая, а?
Ты что такая дерзкая, а?

С чувством глубокого удовлетворения я отправился заниматься своими делами (спать), а юного скретчера попросил подумать над играми, которые он так мечтал реализовать. Он за словом в карман не полез и назначил ценник в 1000 рублей за игру.

Камень, ножницы, бумага
Камень, ножницы, бумага

На следующий день я увидел игру "Камень, ножницы, бумага". По сути, это стрельба из пушки по воробьям. Для такой игры можно вообще обойтись без LLM. Единственный плюс: победитель объявляется каждый раз по разному.

Разработчик пояснил, что это всего лишь пристрелка.

Следующим он показал проект, который пока еще в разработке. Мы играем за детектива и, опрашивая подозреваемых, должны вычислить преступника.

Убийца - садовник
Что вы делали вечером, накануне, преступления?
Что вы делали вечером, накануне, преступления?
Минздрав предупреждает!!!
Минздрав предупреждает!!!

Вот здесь уже стало действительно интересно. Но сразу всплыли ограничения: В облачко ответа персонажа действует лимит 300 символов. Завязка истории представляет длинный текст и возникает такая же проблема с его выводом. Если вывести на экран переменную с этим текстом, то она все перекроет. В ней нет скроллинга и если текст не влез, то ты его просто не увидишь. В итоге пришлось делить текст на части и выводить их поэтапно.

В целом это был интересный эксперимент, пусть и с костылями, но затащили в scratch LLM. И работает не так гладко, как хотелось, но сын доволен, а это главный критерий успеха.