«Свой» уходит в опенсорс: вайб-темы, RAG поверх шифра и объектив, который знает контекст

от автора

Прошлая статья про «Свой» заканчивалась вопросом: что вообще делать с этим проектом — добивать и выкладывать в опенсорс, тянуть в «серую зону» на зарубежных моделях, или забыть. В итоге: я разобрался с 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, а с телефона вы просто задаёте вопросы — и поиск идёт по вашему компьютеру. Но главное — бекенд «Свой» и модель в чате при этом не видят содержимого ваших файлов. Совсем.

Кто какие роли играет

Три стороны:

  1. Телефон (фронт, Chromium WebView) — генерирует свою ключевую пару, шифрует запрос, расшифровывает ответ.

  2. Бекенд «Свой»слепой релей. Хранит и пересылает только шифртекст, ключей расшифровки не держит.

  3. Ваш ПК (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}

Поток данных

Поток данных

Важный момент: «загрузить файл» здесь не значит «загрузить в облако». Файл лежит в папке на вашем ПК и индексируется локально — в облако не уходит ничего.

  1. Индексация (только на ПК, офлайн от бека). Агент обходит разрешённые папки, достаёт текст, режет на чанки (~800 символов, overlap 100), эмбеддит через OpenAI-совместимый провайдер и кладёт в локальный sqlite-vec. fsnotify-watcher держит индекс актуальным.

  2. Вопрос с телефона. Телефон собирает payload ({query, history}), считает общий секрет и шифрует его локально через WebCrypto.

  3. В бекенд уходит только шифртекст: POST /api/chats/{id}/external-rag с opaque-телом.

  4. Бек релеит вслепую. Он не знает ключа — просто кладёт команду в SSE-канал устройства. Пользовательское сообщение при этом сохраняется в БД с Content = ciphertext. В базе «Свой» лежит только шифр.

  5. ПК расшифровывает локально и ищет. Hybrid retrieval (cosine + keyword + BM25, опционально rerank + MMR) по локальному индексу, ответ генерируется стримом.

  6. Ответ шифруется на ПК и летит обратно как шифртекст.

  7. Бек снова слепой — форвардит чанки в телефон.

  8. Телефон расшифровывает локально.

Кто что видит — в одной таблице:

Объект

Что видит бекенд «Свой»

Где в открытом виде

Запрос пользователя

только шифртекст

телефон до шифрования, ПК после расшифровки

Чанки ответа

только шифртекст

ПК при генерации, телефон при расшифровке

Содержимое файлов ПК

никогда

только на ПК

Эмбеддинги / индекс

никогда

только на ПК

В ходе «Мой ПК» облачная модель «Свой» не вызывается вообще. Эти сообщения помечаются 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. Можно поднять свой инстанс для себя.

Понятия не имею, нужен ли кому-то ещё такой проект, но мне — нужен. Поэтому код лежит открытый.

PWA

PWA
Группы помощников

Группы помощников
Группы помощников "Здоровье и спорт"

Группы помощников «Здоровье и спорт»

ссылка на оригинал статьи https://habr.com/ru/articles/1063052/