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

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

В качестве мозга был выбран Gigachat. Во-первых он бесплатный. Во-вторых не надо заморачиваться с VPN, а в третьих он работает без vpn и бесплатен.
Для подключения к API GigaChat необходимо:
Зарегистрироваться в Studio по ссылке https://developers.sber.ru/studio/workspaces/
Создать проект, в инструментах выбрать GigaChat API и заполнить все необходимые поля.
Зайти в созданный проект и выбрать: "Настроить API"
Получить и сохранить ключ.
Подготовительная часть окончена, теперь создадим прокси сервер. Он необходим из-за политики CORS, которая блокирует запросы к внешним API (позже я узнал, что в desktop версии можно отключить CORS, но было уже поздно. Для web версии сервер все равно необходим). Сервер будет:
Принимать запросы от Turbowarp.
Перенаправлять их в Gigachat.
Возвращать ответ обратно в 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_tokenAPI будет максимально простым и без всяких наворотов. Реализованы всего три метода:
Отправляем сообщение
Получаем ответ
Очищаем чат
Полный код сервера
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. Итак нам нужно всего четыре элемента:
Подключиться к серверу
Отправить запрос
Принять ответ
Очистить чат
Код расширения
(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. И работает не так гладко, как хотелось, но сын доволен, а это главный критерий успеха.
