Новогодний подарок: Как я прикрутил LLM к scratch и порадовал ребёнка. ai.. ai. fastapi.. ai. fastapi. gigachat.. ai. fastapi. gigachat. llm.. ai. fastapi. gigachat. llm. python.. ai. fastapi. gigachat. llm. python. scratch.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp. визуальное программирование.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp. визуальное программирование. искусственный интеллект.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp. визуальное программирование. искусственный интеллект. Ненормальное программирование.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp. визуальное программирование. искусственный интеллект. Ненормальное программирование. Разработка игр.. ai. fastapi. gigachat. llm. python. scratch. Turbowarp. визуальное программирование. искусственный интеллект. Ненормальное программирование. Разработка игр. сезон ии в разработке.

Как известно, под новый год случаются чудеса, и этот год не стал исключением. Мне удалось прикрутить 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. И работает не так гладко, как хотелось, но сын доволен, а это главный критерий успеха.

Автор: LeXaNe

Источник

Rambler's Top100