35 файлов и одна кнопка, или как вытащить проект из веб-чата нейросети

Некоторые веб-чаты вроде DeepSeek и Qwen не умеют отдавать бинарные файлы для скачивания. Я проверил это на практике и написал промпт, который генерирует единый HTML со встроенным ZIP-генератором на чистом JavaScript. Пользователь открывает файл в браузере офлайн, нажимает кнопку - и получает zip-архив со всей структурой директорий и файлов. Дальше его можно поместить под git, развернуть на машине или сделать что угодно ещё. Для нетерпеливых - ссылка на промпт.
Зачем это понадобилось
Идея не всегда приходит в удобный момент. Часто - на ходу: в очереди, на прогулке, пока ждёшь жену в машине или ребёнка с тренировки. Я привык проработку подобных идей начинать на смартфоне в DeepSeek или Qwen с помощью мобильного приложения - быстро изложить верхнеуровневую концепцию, задать ряд вопросов, сформулировать гипотезы, пока мысль не успела раствориться. Если идея вырастает во что-то большее - появляется проект, который переезжает на отдельную виртуальную машину с opencode, где уже существует свой отдельный воркфлоу. Иногда, пока opencode пережёвывает основные проекты, я возвращаюсь к DeepSeek и Qwen, чтобы параллельно обсудить гипотезы и получить обратную связь. И это опять может сгенерировать очередной набор артефактов.
Недавно занимался оптимизацией промптов и конфигурации для opencode. Структура - агенты, скиллы, темплейты, директивы - всё это вылилось в 35 файлов. Принимать участие в чемпионате по спортивной копипасте не хотелось, и тогда созрело решение.
Как это устроено
Промпт генерирует единый HTML-файл с кратким описанием проекта и кнопкой «Download ZIP». Внутри - объект FILES, где ключ - путь внутри архива, значение - полное содержимое файла. Пустые директории передаются через .gitkeep. ZIP собирается на чистом JS: local file headers, central directory, end of central directory record, method store, CRC32 через lookup-таблицу. Никаких JSZip и прочих библиотек. UTF-8, general purpose bit 11 = 1 - чтобы имена читались корректно в 7-Zip, WinRAR, macOS Archive Utility и busybox unzip.

А почему не сгенерировать zip сразу?
Я отправил в Qwen, DeepSeek и Perplexity один и тот же промпт с просьбой сгенерировать zip с двумя небольшими текстовыми файлами. Результаты:
Модель | Ответ | Примечание |
|---|---|---|
DeepSeek | «I can’t generate actual downloadable files or ZIP archives» | Предложил текст для копирования вручную |
Qwen | Нет технической возможности отдать бинарный файл | Нет доступа к файловому хранилищу |
Perplexity | Успех: | Сработал Perplexity Labs с песочницей и код-исполнением |
Результат от модели к модели может быть разным: одна отказывается, другая уже умеет. Но есть нюанс. По моим наблюдениям, у DeepSeek глубина проработки больше, чем у Perplexity. Для генерации архива Perplexity подходит, а для вдумчивого обсуждения идеи мне больше подходит DeepSeek. Поэтому HTML-трюк остаётся полезным: он позволяет получать артефакты именно от той модели, с которой комфортно взаимодействовать.
Про консистентность
Протестировал пока только в DeepSeek. Четыре раза запустил промпт, получил четыре HTML, распаковал каждый zip в новую директорию и сравнил через diff -r. Вся структура и содержимое совпадают от директории к директории. Разница в объёме html-файлов связана с изменениями, внесёнными мной в промпт и, как следствие, в интерфейс получаемого html-файла (добавилось дерево проекта, описание файлов и их размеры).
$ ls -lah
итого 564K
drwxrwxr-x 6 evgeniy evgeniy 4,0K окт 26 13:02 .
drwxrwxr-x 22 evgeniy evgeniy 4,0K окт 26 10:59 ..
-rw-rw-r-- 1 evgeniy evgeniy 83K окт 26 10:59 deepseek_html_0.html
-rw-rw-r-- 1 evgeniy evgeniy 83K окт 26 12:20 deepseek_html_1.html
-rw-rw-r-- 1 evgeniy evgeniy 91K окт 26 12:31 deepseek_html_2.html
-rw-rw-r-- 1 evgeniy evgeniy 91K окт 26 12:56 deepseek_html_3.html
drwxrwxr-x 5 evgeniy evgeniy 4,0K окт 26 11:36 opencode-config-0
-rw-rw-r-- 1 evgeniy evgeniy 42K окт 26 10:59 opencode-config-0.2.0_0.zip
-rw-rw-r-- 1 evgeniy evgeniy 42K окт 26 12:21 opencode-config-0.2.0_1.zip
-rw-rw-r-- 1 evgeniy evgeniy 42K окт 26 12:34 opencode-config-0.2.0_2.zip
-rw-rw-r-- 1 evgeniy evgeniy 42K окт 26 12:56 opencode-config-0.2.0_3.zip
drwxrwxr-x 5 evgeniy evgeniy 4,0K окт 26 12:21 opencode-config-1
drwxrwxr-x 5 evgeniy evgeniy 4,0K окт 26 12:34 opencode-config-2
drwxrwxr-x 5 evgeniy evgeniy 4,0K окт 26 12:57 opencode-config-3
-rw-rw-r-- 1 evgeniy evgeniy 2,7K окт 26 12:23 prompt-1.md
-rw-rw-r-- 1 evgeniy evgeniy 2,4K окт 26 12:26 prompt-2.md
-rw-rw-r-- 1 evgeniy evgeniy 1,7K окт 26 12:49 prompt-3.md
$ diff -r opencode-config-0 opencode-config-1
$ diff -r opencode-config-0 opencode-config-2
$ diff -r opencode-config-0 opencode-config-3
$
Предстоит ещё проверить в Qwen, Kimi и других, но текущий результат обнадёживает.
Бонусы, о которых не думал сразу
Имя zip-архива не всегда достаточно, чтобы понять, что находится внутри, особенно если имя содержит название проекта. Чтобы разобраться, нужно открыть архив, заглянуть как минимум в README.md, иногда и в код. На телефоне это ещё менее удобно, чем на компьютере. В то же время html-файл откроется практически любым браузером на любом устройстве и сразу будет содержать описание проекта, извлечённое из контекста общения с нейросетью.
Почему HTML остаётся лучшим выбором
Мой сценарий — «пользователь работает в веб-чате, модель уже знает структуру, нужно отдать всё одним архивом». В этом сценарии html-файл с встроенным zip:
работает там, где модель не умеет генерировать бинарные файлы (DeepSeek и Qwen на момент проверки через web-чат);
не заставляет переключаться на другую модель только ради архива (Perplexity может сгенерировать zip-архив, но работать с ним мне нравится меньше);
не требует установки расширений или доступа к файловой системе;
даёт пользователю полный контроль над структурой: иерархия, пустые директории, метаданные.
Про ограничения
DeepSeek, например, позволяет по кнопке продолжать генерацию, что позволяет собрать единый большой html-файл в несколько итераций. Но не факт, что все аналогичные решения дадут такую возможность. В противном случае единый файл, доступный для скачивания, может не собраться. Впрочем, сомнительно, что таким образом кто-то рассчитывает генерировать мегабайты контента. В крайнем случае можно попросить разбить на несколько архивов. Россыпь небольших файлов — наиболее вероятный сценарий, который мы как раз и закрываем текущим решением. Если же проект разрастётся до чего-то тяжёлого, с ним в любом случае нужно работать с помощью предпочитаемого вами harness.
Промпт
Ниже привожу промпт, который получился в результате нескольких итераций доработок. Предложения по доработке и конструктивная критика приветствуются.
Содержимое промпта
Generate a single self-contained HTML file (opencode-config-bundle.html)
that lets the user download the opencode-config archive from a phone.
Layout, top to bottom:
1. Sticky header:
- left: title "opencode-config bundle" (single line, truncates on
narrow screens)
- right: download button, compact, same line as title
- below the title, small muted line: "v0.2.0 - 35 files"
- status line under the button ("Preparing..." / "Done."), only
visible after click, small font
2. Section "About": what/why/who, 2 short paragraphs.
3. Section "How to use": 4 numbered steps, short.
4. Section "Directory tree": full tree with ASCII branches, one line
per file: name, short description, size (computed from FILES at
load). Directories show a role, not a size. Wrapped in a
horizontally scrollable <pre>.
5. Section "Prior art": two links with one-line notes.
6. Section "Disclaimer": same text as README.md.
7. Section "Authorship": Evgeniy Shumilov + DeepSeek, contacts.
8. <details> with plain file list.
9. <script src="jszip"> + handler. On click: build zip, trigger
download, show status. If JSZip is undefined, show a clear error.
CSS:
- Header is position: sticky; top: 0; background: #fff; border-bottom:
1px solid #ddd; padding: 0.6rem 1rem; z-index: 10.
- Header flex: title on the left, button on the right, gap 0.5rem.
- Button: compact on desktop (padding 0.5rem 0.9rem, font 0.9rem),
full-width on very narrow screens via media query below 420px.
- Body has padding-top so sticky header does not overlap content.
- No emojis, ASCII only in the tree.
- Sizes in the tree are computed from FILES, single source of truth.Как это встроено в рабочий процесс

На работе и дома я использую i3wm, а в качестве лаунчера — rofi. Для небольших промптов, которые нужны регулярно, у меня есть обвязка: каждый промпт — это отдельный shell-скрипт в ~/.config/rofi/scripts/, содержащий имя, описание и текст. Например, pr-step-by-step:
#!/bin/sh
#icon diodon
#name Step by step prompt
text='Guide me step by step. One command or snippet with a set of commands from you - one feedback from me. Do not use Russian comments, UTF icons, or emojis in snippets.'
nohup sh -c "printf '%s' '$text' | /usr/bin/xclip -selection clipboard" >/dev/null 2>&1 &Вызывается rofi, вводится пара символов, например prom..., выбирается нужный промпт, enter — текст уходит в буфер обмена, дальше можно вставлять в чат. Бывает полезно и для opencode, например, когда несмотря на все проверки и базовые промпты локальная модель пытается выйти за пределы рабочей директории.
Скрытый текст
В конфиге rofi добавлен кастомный мод scripts:
configuration {
modi: "drun,calc:qalc,scripts:~/.config/rofi/rofiselector,ssh,window,run";
width: 35;
show-icons: true;
icon-theme: "papirus";
lines: 10;
fake-transparency: true;
sidebar-mode: true;
}
@import "/usr/share/rofi/themes/mytheme.rasi"Сам ~/.config/rofi/rofiselector — скрипт, который формирует список промптов и обрабатывает выбор.
#!/bin/bash
ROOT_DIR="$HOME/.config/rofi/scripts"
STATE_FILE="/tmp/rofi_scripts_current_dir"
save_current_dir() {
echo "$1" > "$STATE_FILE"
}
load_current_dir() {
if [ -f "$STATE_FILE" ]; then
local saved_dir=$(cat "$STATE_FILE")
if [[ "$saved_dir" == "$ROOT_DIR"* ]]; then
echo "$saved_dir"
return
fi
fi
echo "$ROOT_DIR"
}
get_metadata() {
local file_path="$1"
local metadata_type="$2"
local line=$(grep -m 1 "^[[:space:]]*#$metadata_type" "$file_path" 2>/dev/null)
if [ -n "$line" ]; then
echo "$line" | sed "s/^[[:space:]]*#$metadata_type[[:space:]]*//" | xargs
else
echo ""
fi
}
get_dir_icon() {
local dir_path="$1"
local icon_file="$dir_path/.icon"
if [ -f "$icon_file" ]; then
local icon=$(cat "$icon_file" | head -1 | xargs)
if [ -n "$icon" ]; then
echo "$icon"
return
fi
fi
echo "folder"
}
print_items() {
local current_dir="$1"
if [ "$current_dir" != "$ROOT_DIR" ]; then
echo -en "..\0icon\x1fgo-up\n"
fi
for item in "$current_dir"/*; do
[ -e "$item" ] || continue
local name=$(basename "$item")
if [ -d "$item" ]; then
local icon=$(get_dir_icon "$item")
echo -en "$name\0icon\x1f$icon\n"
fi
done
for item in "$current_dir"/*; do
[ -e "$item" ] || continue
local name=$(basename "$item")
if [ -x "$item" ] && [ -f "$item" ]; then
local custom_name=$(get_metadata "$item" "name")
local icon=$(get_metadata "$item" "icon")
if [ -n "$custom_name" ]; then
local display_name="$custom_name"
else
display_name="${name%.*}"
fi
if [ -n "$icon" ]; then
echo -en "$display_name\0icon\x1f$icon\n"
else
echo -en "$display_name\0icon\x1fterminal\n"
fi
fi
done
}
run_item() {
local current_dir="$1"
local selected="$2"
if [ "$selected" = ".." ]; then
local parent_dir=$(dirname "$current_dir")
save_current_dir "$parent_dir"
print_items "$parent_dir"
return
fi
for item in "$current_dir"/*; do
[ -e "$item" ] || continue
local name=$(basename "$item")
if [ -d "$item" ] && [ "$name" = "$selected" ]; then
local target_dir="$current_dir/$name"
if [ -d "$target_dir" ]; then
save_current_dir "$target_dir"
print_items "$target_dir"
fi
return
fi
done
for script in "$current_dir"/*; do
if [ -x "$script" ] && [ -f "$script" ]; then
local script_name=$(basename "$script")
local custom_name=$(get_metadata "$script" "name")
local file_base="${script_name%.*}"
if [ "$selected" = "$custom_name" ] || [ "$selected" = "$file_base" ]; then
rm -f "$STATE_FILE"
exec "$script"
exit 0
fi
fi
done
}
if [ -z "$1" ]; then
CURRENT_DIR=$(load_current_dir)
print_items "$CURRENT_DIR"
else
CURRENT_DIR=$(load_current_dir)
run_item "$CURRENT_DIR" "$1"
fiИтог
Вставил промпт → скачал HTML → открыл → нажал кнопку → получил zip → хоть заноси под git, хоть отправляй в бэкапы. Множество файлов, одна кнопка, ноль копипасты.
KioskNews shows a cleaned-up reading view extracted from the publisher’s page — the original always lives on their site, not ours.