Прошлая статья про «Свой» заканчивалась вопросом: что вообще делать с этим проектом — добивать и выкладывать в опенсорс, тянуть в «серую зону» на зарубежных моделях, или забыть. В итоге: я разобрался с 152-ФЗ, зарегистрировал все необходимые документы на обработку ПДн и трансграничную передачу и выложил проект в OpenSource.
— основное приложение: https://github.com/Zazza/svoi-oss— внешний RAG (про него ниже — отдельная история): https://github.com/Zazza/svoi-oss-ragЛицензия — AGPL-3.0.
Пересказывать прошлую статью не буду (кто не читал — там история, как из браузерной игры вырос ассистент с долговременной памятью). Скажу только главное для контекста: всё, что там описано, работает и через облачные агрегаторы (BYOK), и на локальной Ollama — стриминговый чат по SSE, факты с pgvector, extraction и забывание, tool calling, планировщик, мультиагентский пайплайн, vision.
Дальше — только про новое.
Оглавление:
-
Архитектура
-
Вайб-темы: тема интерфейса, которую генерит LLM
-
External RAG «Мой ПК»: бек и большие модели не видят ваших данных
-
Веб-поиск
-
Объектив и тематические помощники
-
Что в планах
-
Что с этим делать
—
Архитектура
Стек прежний, только монорепа: бэкенд на Go (стандартный net/http, ручной SQL, pgx + sqlx), фронт на Vue 3, в базе PostgreSQL 17 с pgvector. Деплой — один docker-compose: postgres + searxng + backend + nginx, всё слушает 127.0.0.1, наружу торчит только nginx.
Важное архитектурное решение, которое многое объясняет дальше: все LLM-вызовы идут через единый OpenAI-совместимый клиент, а провайдеры лежат в таблице с приоритетами. Это значит две вещи. Во-первых, нет привязки к конкретному вендору — модель чата, модель extraction, vision-модель и embed-модель — это просто четыре поля в конфиге. Во-вторых, поверх них стоит «circuit breaker»: если primary падает или отдаёт 5xx, запрос уходит на fallback. Для self-host это спасает и при сбоях агрегатора, и когда локальная Ollama задыхается на тяжёлом запросе.
И ещё: весь SQL — ручной, без GORM и sqlc. pgvector используется не через библиотеку, а как raw SQL — векторы пишутся и читаются напрямую, а при старте код сам выравнивает размерность колонок под текущую embed-модель. Поэтому модель эмбеддингов можно поменять без миграций.
—
Вайб-темы: тема интерфейса, которую генерит LLM
Обычно тема интерфейса — это light / dark. У меня добавился третий режим: vibe. Идея простая — не выбирать тему из списка, а сгенерировать её под себя. Заходишь в профиль, пишешь подсказку («панк, красный, закат с переливами»), и на выходе получаешь целую тему: фон, поверхности, акцент, «материал» (стекло / рубленые ретро-рамки / плоский) и текстуру фона. Потом правишь прямо из чата: @тема сделай темнее, @тема больше красного, @тема сделай градиент или @тема поменяй черно-зеленый фон на черно-синий.



Под капотом это не «положим поверх пару цветов», а полная палитра из шестнадцати полей. Но фокус в том, что LLM не отвечает за все шестнадцать — иначе тема разваливается на несочетаемые цвета. Модель играет роль арт-директора и отдаёт только три цвета (фон / текст / акцент), плюс «материал» и текстуру:
type vibeInput struct { Name string json:"name" Description string json:"description" Bg string json:"bg" // фон Text string json:"text" // текст Accent string json:"accent" // акцент Background VibeBg json:"background" Style string json:"style" // flat | glass | retro}
А вся остальная палитра выводится детерминированно — смешиванием фона и текста:
func deriveVibe(in vibeInput) Vibe { bg, text, accent := in.Bg, in.Text, in.Accent return &Vibe{ Surface2: blend(bg, text, 0.16), Surface3: blend(bg, text, 0.22), Border: blend(bg, text, 0.28), TextDim: blend(text, bg, 0.45), AccentHover: blend(accent, bg, 0.18), AccentBg: deriveAccentBg(accent), // ... }}
Это даёт согласованность при любом, даже самом «странном» выборе модели. Поверх этого работает нормализация: bg и accent обязаны быть валидным #RRGGBB (иначе — retry), а контраст текст/фон проверяется по WCAG. Если модель выдала светлый текст на светлом фоне — текст автоматически заменяется на читаемый.
Применяется тема не классами, а inline CSS-переменными на document.documentElement — поэтому они по специфичности перекрывают и :root, и [data-theme="light"], то есть vibe реально «выключает» обычные темы, а не накладывается поверх:
const VIBE_VARS = [ ['--bg', 'bg'], ['--surface', 'surface'], ['--text', 'text'], ['--accent', 'accent'], ['--accent-hover', 'accentHover'], ['--nav-bg', 'navBg'], ['--header-bg', 'headerBg'], // ... ещё 8 штук]// setVars пишет их инлайн на <html>
«Материал» — это отдельно, не цвет, а способ отрисовки: атрибут data-vibe-style на <html>. glass делает поверхности полупрозрачными с backdrop-filter: blur(20px), retro — рубит скругления в ноль и ставит жёсткие рамки 2px со смещённой тенью 4px 4px 0 (привет, Windows 95), flat — снимает всё. И отдельный слой .vibe-bg под контентом рисует три radial-gradient из цветов — фон «переливается».LLM здесь — лишь инструмент, который один раз генерирует палитру. Не больше.
Мелочь, которая порадовала: в промпте я явно задал арт-директору узнаваемые эстетики — macOS dark должен дать системный синий
#0a84ffи стильglass, Windows 95 — бирюзу#008080иretro, GitHub dark —#0d1117иflat.
External RAG «Мой ПК»: бек и большие модели не видят ваших данных
Это, пожалуй, самая интересная штука, и она живёт в отдельном репозитории (svoi-oss-rag). Обычный RAG я описывал в прошлой статье: документ режется на чанки, эмбеддится, лежит в pgvector. Это работает, но у него есть предел — он индексирует то, что вы явно загрузили в базу. А у людей обычно гигабайты документов лежат на домашнем ПК: PDF, docx, выгрузки, конспекты. Хочется, чтобы ассистент умел искать по ним, не заставляя пользователя тащить всё в облако.
External RAG решает это так: на ваш ПК ставится десктоп-агент svoi-rag (Go-бинарник, GUI на Windows/macOS, TUI на Linux), он индексирует выбранные папки в локальный sqlite-vec, а с телефона вы просто задаёте вопросы — и поиск идёт по вашему компьютеру. Но главное — бекенд «Свой» и модель в чате при этом не видят содержимого ваших файлов. Совсем.
Кто какие роли играет
Три стороны:
-
Телефон (фронт, Chromium WebView) — генерирует свою ключевую пару, шифрует запрос, расшифровывает ответ.
-
Бекенд «Свой» — слепой релей. Хранит и пересылает только шифртекст, ключей расшифровки не держит.
-
Ваш ПК (svoi-rag) — индексирует файлы, расшифровывает запрос, делает поиск и вызывает LLM, шифрует ответ.
Криптография
Алгоритм: P-256 ECDH → HKDF-SHA256 → AES-256-GCM. Реализован дважды, зеркально — на Go (ПК) и на WebCrypto (телефон).При сопряжении стороны обмениваются только публичными ключами (через бекенд), а общий секрет каждая считает локально — бекенд в этом обмене не участвует и приватных ключей не имеет:
func (c *Crypto) SetPeerPubkey(deviceID, peerB64 string) error { peerPub, _ := ecdh.P256().NewPublicKey(peerBytes) shared, _ := c.priv.ECDH(peerPub) // сырой ECDH-секрет info := bindHKDFInfo(deviceID, c.PubkeyBase64(), peerB64) key := make([]byte, 32) r := hkdf.New(sha256.New, shared, nil, []byte(info)) io.ReadFull(r, key) c.secret = key // симметричный ключ AES-256}
Два усиления поверх базового ECDH, и они важны для честности конструкции:
-
Привязка к каналу. В HKDF-info подмешаны device_id и оба публичных ключа (в каноническом порядке). Если кто-то попытается подменить идентичность канала — производный ключ сойдётся другой, и расшифровка упадёт по GCM-тэгу.
-
Разделение ролей. В AES-GCM как additionalData идёт «роль» сообщения: request (телефон→ПК), response (ПК→телефон), file (вложение). Шифртекст одной роли нельзя подсунуть в слот другой — тэг не сойдётся.
func (c *Crypto) seal(plaintext, role []byte) (nonce, ct []byte, err error) { gcm, _ := c.gcm() nonce = make([]byte, gcm.NonceSize()) io.ReadFull(rand.Reader, nonce) // крипто-случайный nonce ct = gcm.Seal(nil, nonce, plaintext, role) return}
Поток данных
Поток данных
Важный момент: «загрузить файл» здесь не значит «загрузить в облако». Файл лежит в папке на вашем ПК и индексируется локально — в облако не уходит ничего.
-
Индексация (только на ПК, офлайн от бека). Агент обходит разрешённые папки, достаёт текст, режет на чанки (~800 символов, overlap 100), эмбеддит через OpenAI-совместимый провайдер и кладёт в локальный
sqlite-vec.fsnotify-watcherдержит индекс актуальным. -
Вопрос с телефона. Телефон собирает payload (
{query, history}), считает общий секрет и шифрует его локально через WebCrypto. -
В бекенд уходит только шифртекст:
POST /api/chats/{id}/external-ragс opaque-телом. -
Бек релеит вслепую. Он не знает ключа — просто кладёт команду в SSE-канал устройства. Пользовательское сообщение при этом сохраняется в БД с
Content = ciphertext. В базе «Свой» лежит только шифр. -
ПК расшифровывает локально и ищет. Hybrid retrieval (cosine + keyword + BM25, опционально rerank + MMR) по локальному индексу, ответ генерируется стримом.
-
Ответ шифруется на ПК и летит обратно как шифртекст.
-
Бек снова слепой — форвардит чанки в телефон.
-
Телефон расшифровывает локально.
Кто что видит — в одной таблице:
|
Объект |
Что видит бекенд «Свой» |
Где в открытом виде |
|
Запрос пользователя |
только шифртекст |
телефон до шифрования, ПК после расшифровки |
|
Чанки ответа |
только шифртекст |
ПК при генерации, телефон при расшифровке |
|
Содержимое файлов ПК |
никогда |
только на ПК |
|
Эмбеддинги / индекс |
никогда |
только на ПК |
В ходе «Мой ПК» облачная модель «Свой» не вызывается вообще. Эти сообщения помечаются source = “externalrag” и явно исключаются из контекста сервисной модели. То есть даже если оператор развернул «Свой» на cloud-агрегаторе — в этом пути ваши файлы до его моделей не доходят.
Сопряжение
«Сопряжение» — это обмен публичными ключами. Телефон создаёт 6-значный код и регистрирует свой публичный ключ. На ПК вы вводите код в визарде, агент гасит код, отдаёт свой публичный ключ и получает публичный ключ телефона. Бекенд крест-накрест раздаёт ключи — и после этого стороны могут считать общий секрет, а бек остаётся ни при чём. Авторизация device-канала — отдельный bearer-токен (не JWT), причём в БД хранится только его SHA-256. Ну и per-IP rate-limit на ввод кода, чтобы не брутили.
Какие форматы работают
Всё из списка парсится: txt, docx, xlsx, pdf, pptx, odt, epub, csv (плюс md, код, json/yaml). Офисные (OOXML/ODF) — это ZIP+XML через стандартную библиотеку: word/document.xml, content.xml, xl/sharedStrings.xml, ppt/slides/*. PDF — внешний pdftotext (poppler), на Windows бандлится рядом с бинарником (потому что иначе надо руками поставить и прописать PATH). EPUB — ZIP + XHTML. Для текста есть авто-детекция кодировки (UTF-8/UTF-16/cp1251) — без него половина старых документов превратилась бы в «кракозябры».
Но здесь добавлю: штука в активной разработке. Что есть и что в планах — позже.—
Веб-поиск
Веб-поиск сделан на SearXNG — self-hosted метапоисковике, который сам роутит на Google/Bing/DuckDuckGo. Никакой привязки к конкретному поисковику.
Главное — это агентный tool calling, а не «вставим результаты в промпт». Модели отдаётся инструмент web_search, и она сама решает, когда искать. В системный промпт при этом вшито жёсткое правило: если нужен поиск — вызывай web_search в том же ответе, одним tool_call, и не пиши «сейчас поищу» без вызова. Без этого промпта, модель обманывала — писала “сейчас поищу” и ничего не делала.
Механика — multi-turn. Первое обращение к модели вызывает tool_call web_search → бек гоняет SearXNG → форматирует сниппеты → второй вызов модели стримит финальный ответ, уже с результатами в контексте через сообщение с role: “tool”:
secondMessages := append(append([]LLMMessage{}, messages...), LLMMessage{Role: "assistant", Content: preamble, ToolCalls: []ToolCall{*wsTC}}, LLMMessage{Role: "tool", ToolCallID: wsTC.ID, Content: toolContent},)
Параллельно крутится image-поиск — до шести картинок по теме, они не идут в LLM, а собираются в галерею под ответом. Там же — источники-цитаты («Прочитано: …» с ссылками), чтобы не было галлюцинаций ссылок. Всё это переживает перезагрузку страницы, потому что сохраняется в meta сообщения.
Во время поиска бек шлёт SSE-статус «ищет: {запрос}», чтобы интерфейс не висел застывшим «Сейчас поищу…».
Объектив и тематические помощники
В прошлой статье я рассказывал про vision, который знает контекст: если в промпт положить факты про пользователя, модель не просто «видит людей», а «узнаёт» их. С тех пор появилась конкретная фича — «Объектив»: системный чат-анализатор фото, аналог Google Lens. Он создаётся автоматически у каждого пользователя, в нём можно прислать фото и попросить разобрать — что на нём, прочитать текст с этикетки/меню, опознать растение, найти цену через веб-поиск.
Но интереснее не сам объектив, а то, как он связан с тематическими помощниками. Помощники в «Свой» — это чаты с готовым системным промптом: Кулинар, Автомеханик, Грибник-лесник, Фитнес, и так далее. И часть из них умеет анализировать фото (помечены значком 📷). Фишка в том, что одно и то же фото анализируется через «оптику» выбранного помощника: скиньте фото двигателя в «Автомеханик» — получите разбор по делу, скиньте фото блюда в «Кулинар» — получите рецепт.
Технически это двухслойная сборка промпта. При наличии фото в любой чат доклеивается базовый фото-промпт vision_lens (триггеры: описать объект, OCR, опознать, вызвать веб-поиск). А поверх него, у тематического помощника, доклеивается его собственный фото-акцент:
if len(images) > 0 { systemPrompt += promptstore.Get("chat.vision_lens") // база if effective != nil && effective.VisionPrompt != "" { systemPrompt += effective.VisionPrompt // per-template акцент }}
То есть объектив — это не отдельная vision-модель и не изолированный сервис, а свойство любого чата: фото-инструкция клеится автоматически, а тематический помощник добавляет к ней свою специализацию. И можно прямо в чате «Объектив» явно пригласить профильного спеца через @Кулинар [фото пиццы] — ответ придёт от Кулинара в его персоне.
Что в планах
External RAG — сейчас допиливается. Пока он не очень хорошо работает с большим количеством PDF. У меня огромная база журналов, хочу, чтобы он корректно искал по ним.
Но тут главное основа, шифрование и использование локального ПК. Для меня это приложение, которое должно избавить от рутины, поэтому, хочу добавить:
-
работу с почтой, чтобы он проверял письма и говорил, что важно, с возможностью отвечать (да, я ленивый) и разбирал спам, которого в почте больше, чем полезного;
-
мониторинг и разбор логов различных сервисов. Тут ИИ очень помогает, не нужно больше лазить по логам и искать инциденты, LLM это делает быстрее;
-
очень хочу сделать что-то вроде Claude, отдельный чат, где я напишу, что надо изменить в проекте и он это сделает, закомитит и запушит;
-
генерация изображений с Stable Diffusion на GPU. Потому что бесплатно.
Что с этим делать
Коротко: проект технически готов, открыт под AGPL, и поднимается одним make up. Работает и на локальной Ollama (бесплатно, всё у вас), и через любой OpenAI-совместимый cloud (BYOK). Бекенд можно использовать как есть, внешний RAG — отдельно.
Для тех, кому не хочется возиться с сервером, есть и hosted-версия на svoi-ai.ru — по сути тот же движок. Оплата там максимально простая, без подписок: закинул сумму на баланс и пользуешься, сколько наговорил — столько и списалось. Почему платно: за LLM-агрегаторы и инфру нужно платить, и бесплатно я это тянуть не могу. Сейчас этим сервисом пользуюсь я, жена и несколько знакомых.
В hosted ещё приятнее первый вход: вместо обычной регистрации — визард, где ИИ знакомится с пользователем, подбирает помощников и сразу генерирует вайб-тему (ту самую, из начала статьи). Готовых помощников там тоже больше, с иконками, которые я нагенерил на своей GPU. Но своих помощников можно завести и в опенсорсе — там есть простой LLM-онбординг, который соберёт персонажа с нуля за пару шагов.
Я не маркетолог и не умею продвигать продукты — поэтому единственное, что я могу сделать, чтобы разработка не осталась невидимой, это выложить её и рассказать про неё. Если кому-то архитектура, криптография или просто сам подход кажутся интересными — welcome. Можно поднять свой инстанс для себя.
Понятия не имею, нужен ли кому-то ещё такой проект, но мне — нужен. Поэтому код лежит открытый.
ссылка на оригинал статьи https://habr.com/ru/articles/1063052/