«Свой» уходит в опенсорс: вайб-темы, RAG поверх шифра и объектив, который знает контекст. byok.. byok. Go.. byok. Go. llm.. byok. Go. llm. ollama.. byok. Go. llm. ollama. Open source.. byok. Go. llm. ollama. Open source. rag.. byok. Go. llm. ollama. Open source. rag. SearXNG.. byok. Go. llm. ollama. Open source. rag. SearXNG. sqlite-vec.. byok. Go. llm. ollama. Open source. rag. SearXNG. sqlite-vec. vision.. byok. Go. llm. ollama. Open source. rag. SearXNG. sqlite-vec. vision. ии-ассистент.. byok. Go. llm. ollama. Open source. rag. SearXNG. sqlite-vec. vision. ии-ассистент. искусственный интеллект.. byok. Go. llm. ollama. Open source. rag. SearXNG. sqlite-vec. vision. ии-ассистент. искусственный интеллект. Криптография.

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

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

Под капотом это не «положим поверх пару цветов», а полная палитра из шестнадцати полей. Но фокус в том, что 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
Группы помощников

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

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

Автор: Zazza

Источник