feat(ai): replace the Vojo AI widget with a native, isolated ChatGPT-style chat surface (threads, history, typing)

This commit is contained in:
heaven 2026-06-02 01:49:31 +03:00
parent 5d959311f2
commit 185d0a60a7
41 changed files with 1607 additions and 3104 deletions

2
.vscode/tasks.json vendored
View file

@ -16,7 +16,7 @@
{ {
"label": "Deploy widgets", "label": "Deploy widgets",
"type": "shell", "type": "shell",
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL", "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
"group": "none", "group": "none",
"presentation": { "presentation": {
"reveal": "always", "reveal": "always",

View file

@ -53,12 +53,26 @@ type Bot struct {
// off-lock, so several goroutines touch this shared state at once. mu is held only // off-lock, so several goroutines touch this shared state at once. mu is held only
// for short map operations and is NEVER held across a network or xAI call — that // for short map operations and is NEVER held across a network or xAI call — that
// head-of-line hold was the root cause of the multi-minute silence. // head-of-line hold was the root cause of the multi-minute silence.
mu sync.Mutex mu sync.Mutex
seen *lruSet // event ids already handled (dedup within a session; self-locking) seen *lruSet // event ids already handled (dedup within a session; self-locking)
botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking) botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking)
meta map[string]*roomMeta meta map[string]*roomMeta
buf map[string][]bufferedMsg // buf and inflight are keyed by roomID THEN by thread root ("" = main timeline), so
inflight map[string]bool // roomID currently generating a reply (per-room single-flight) // each conversation (a thread) keeps an isolated context window and an independent
// single-flight claim. Two messages in different threads of one room no longer block
// each other or pollute each other's history. roomMeta stays room-level (membership
// /encryption are room facts). forgetRoom drops a room's whole subtree in one delete.
buf map[string]map[string]*convBuf
inflight map[string]map[string]bool
// bufSeq is a monotonic counter stamped onto a convBuf on every append (guarded by mu);
// it orders conversations by last activity so appendBuf can LRU-evict the coldest one
// when a room exceeds maxConvBuffersPerRoom.
bufSeq uint64
// typingRefs counts in-flight generations per ROOM that are currently showing the
// "typing…" indicator. Matrix typing notifications are room-scoped (no thread_id in
// the CS-API), so the indicator can't be split per thread: it goes on when the first
// generation in a room starts and off only when the last one finishes (refcount).
typingRefs map[string]int
} }
func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) { func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) {
@ -80,8 +94,9 @@ func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error)
seen: newLRUSet(5000), seen: newLRUSet(5000),
botSent: newLRUSet(5000), botSent: newLRUSet(5000),
meta: make(map[string]*roomMeta), meta: make(map[string]*roomMeta),
buf: make(map[string][]bufferedMsg), buf: make(map[string]map[string]*convBuf),
inflight: make(map[string]bool), inflight: make(map[string]map[string]bool),
typingRefs: make(map[string]int),
} }
// Build the cascade backends only for enabled layers (config already fail-fast // Build the cascade backends only for enabled layers (config already fail-fast
@ -330,19 +345,28 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
// the language-free "I'm busy" signal. The claim is taken here, synchronously and // the language-free "I'm busy" signal. The claim is taken here, synchronously and
// in transaction order, so the FIRST message for a room wins and later ones are // in transaction order, so the FIRST message for a room wins and later ones are
// dropped until release — never the reverse. // dropped until release — never the reverse.
if !b.tryClaim(roomID) { // Resolve which conversation this turn belongs to: an existing thread (continue it),
b.log.Debug("drop: room busy generating", "room", roomID, "sender", ev.Sender) // a freshly rooted DM thread (auto-thread, DM-only), or the main timeline ("").
// Groups never auto-thread; see resolveThreadRoot.
threadRoot := b.resolveThreadRoot(isDM, ev, mc)
// Per-(room,thread) single-flight: a slow answer in one conversation no longer blocks
// another thread in the same room, and two messages in the SAME conversation still see
// exactly one winner. The claim is taken synchronously in transaction order.
if !b.tryClaim(roomID, threadRoot) {
b.log.Debug("drop: conversation busy generating", "room", roomID, "thread", threadRoot, "sender", ev.Sender)
return return
} }
// Snapshot the room history (excludes this trigger) under the claim, then run the // Snapshot THIS conversation's history (excludes this trigger) under the claim, then
// slow generation in its own goroutine so this transaction's remaining events and // run the slow generation in its own goroutine so this transaction's remaining events
// other rooms are not blocked by the xAI call. respond appends the trigger+answer // and other rooms/threads are not blocked by the xAI call. respond appends the
// to the buffer itself, only on success (see sendReply), and releases the claim. // trigger+answer to the same per-thread buffer on success (see sendReply) and releases
history := b.snapshotBuf(roomID) // the claim.
history := b.snapshotBuf(roomID, threadRoot)
b.safego("respond", func() { b.safego("respond", func() {
defer b.release(roomID) defer b.release(roomID, threadRoot)
b.respond(ctx, roomID, isDM, ev, mc, history) b.respond(ctx, roomID, threadRoot, isDM, ev, mc, history)
}) })
} }
@ -350,7 +374,7 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
// never trip the per-user gate, while the global DAILY_USD_CEILING still applies. // never trip the per-user gate, while the global DAILY_USD_CEILING still applies.
const unlimitedCap = 1 << 30 const unlimitedCap = 1 << 30
func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) { func (b *Bot) respond(ctx context.Context, roomID, threadRoot string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) {
started := time.Now() started := time.Now()
// One telemetry row per request, populated as the flow decides its outcome and // One telemetry row per request, populated as the flow decides its outcome and
// emitted once via defer — so every exit (deny, error, empty, paid silence, success) // emitted once via defer — so every exit (deny, error, empty, paid silence, success)
@ -438,7 +462,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
defer cancel() defer cancel()
msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens) msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens)
res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID)) res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID, threadRoot))
// Record what the routing + generation actually did, whatever the outcome. // Record what the routing + generation actually did, whatever the outcome.
rl.Route = res.route rl.Route = res.route
@ -503,7 +527,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
} }
b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route, b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route,
"usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens) "usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens)
if err := b.sendReply(ctx, roomID, ev, mc, text); err != nil { if err := b.sendReply(ctx, roomID, threadRoot, ev, mc, text); err != nil {
// Paid silence (§8.1): the spend is real (USD is kept — refunding it would // Paid silence (§8.1): the spend is real (USD is kept — refunding it would
// under-count the ceiling), but the reply never landed. Refund the request SLOT // under-count the ceiling), but the reply never landed. Refund the request SLOT
// so the user can retry, and react ⚠️ so the failure isn't silent. // so the user can retry, and react ⚠️ so the failure isn't silent.
@ -535,15 +559,21 @@ func (b *Bot) estimateUSD(model string) float64 {
// convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when // convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when
// GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header // GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header
// only pins a conversation to the same backend to raise the hit rate (docs.x.ai), so // only pins a conversation to the same backend to raise the hit rate (docs.x.ai). The
// a stable per-room id is the right unit — every turn in a room shares the system // right unit is the (room,thread) pair, not the room: once conversations are threaded,
// prompt and history prefix. It carries no PII (the room id is opaque) and is hashed // each thread has its OWN system+history prefix, so pinning all of a room's threads to
// to keep it compact and non-identifying. // one id would thrash the cache between divergent prefixes. The main timeline keys on
func (b *Bot) convID(roomID string) string { // roomID alone (threadRoot ""), preserving the previous value for the flat case. Carries
// no PII (ids are opaque) and is hashed to stay compact and non-identifying.
func (b *Bot) convID(roomID, threadRoot string) string {
if !b.cfg.GrokPromptCache { if !b.cfg.GrokPromptCache {
return "" return ""
} }
return fmt.Sprintf("vojo-%08x", hashString(roomID)) key := roomID
if threadRoot != "" {
key = roomID + "|" + threadRoot
}
return fmt.Sprintf("vojo-%08x", hashString(key))
} }
// computeUSD prices a call from the API-returned token usage (authoritative // computeUSD prices a call from the API-returned token usage (authoritative
@ -606,23 +636,24 @@ func (b *Bot) reactEncryptedOnce(ctx context.Context, roomID, eventID string) bo
// conversation buffer so the next turn has context. It RETURNS the send error so the // conversation buffer so the next turn has context. It RETURNS the send error so the
// caller can handle paid silence (§8.1): a billed answer that failed to deliver must // caller can handle paid silence (§8.1): a billed answer that failed to deliver must
// refund the slot and react, not vanish. // refund the slot and react, not vanish.
func (b *Bot) sendReply(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error { func (b *Bot) sendReply(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
if err := b.sendMessage(ctx, roomID, trigger, triggerMC, body); err != nil { if err := b.sendMessage(ctx, roomID, threadRoot, trigger, triggerMC, body); err != nil {
return err return err
} }
// Record the user trigger AND the assistant answer together, only AFTER the answer // Record the user trigger AND the assistant answer together, only AFTER the answer
// was sent, so a failed or empty generation never leaves a dangling user turn (a // was sent, so a failed or empty generation never leaves a dangling user turn (a
// question with no reply) in the buffer — which would skew later completions. // question with no reply) in the buffer — which would skew later completions. Both go
// Single-flight guarantees no other turn for this room interleaves between the two. // to THIS conversation's buffer (roomID,threadRoot). Per-(room,thread) single-flight
b.appendBuf(roomID, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false}) // guarantees no other turn for this conversation interleaves between the two.
b.appendBuf(roomID, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true}) b.appendBuf(roomID, threadRoot, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
b.appendBuf(roomID, threadRoot, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
return nil return nil
} }
// sendMessage builds and sends an m.notice reply and tracks our own event id. Returns // sendMessage builds and sends an m.notice reply and tracks our own event id. Returns
// the send error (nil on success) so the caller can detect a failed delivery. // the send error (nil on success) so the caller can detect a failed delivery.
func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error { func (b *Bot) sendMessage(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
content := buildNoticeContent(trigger.EventID, trigger.Sender, triggerMC.RelatesTo, body) content := buildNoticeContent(trigger.EventID, trigger.Sender, threadRoot, body)
id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content) id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content)
if err != nil { if err != nil {
b.log.Error("send failed", "room", roomID, "err", err) b.log.Error("send failed", "room", roomID, "err", err)
@ -633,11 +664,16 @@ func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, tr
return nil return nil
} }
// startTypingKeepalive starts the typing indicator and keeps it alive for the whole // startTypingKeepalive shows the room-level "typing…" indicator for the whole generation
// generation (the CS-API server-side typing notification expires after the 30s we // and keeps it alive (the CS-API notification expires after the 30s we pass, so we refresh
// pass, so we refresh every 20s). The returned stop clears the indicator and is safe // every 20s). The returned stop is safe to call once via defer. Typing is ROOM-scoped in
// to call once via defer. Typing is best-effort UX — failures are non-fatal. // Matrix — there is no per-thread typing — so with per-(room,thread) concurrency several
// generations can run for one room at once. A per-room refcount keeps the indicator on
// until the LAST of them finishes, rather than letting whichever finishes first clear it
// out from under the others. Best-effort UX — failures are non-fatal.
func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() { func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
b.typingAcquire(roomID)
b.setTyping(ctx, roomID, true) b.setTyping(ctx, roomID, true)
done := make(chan struct{}) done := make(chan struct{})
go func() { go func() {
@ -658,11 +694,40 @@ func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
return func() { return func() {
once.Do(func() { once.Do(func() {
close(done) close(done)
b.setTyping(ctx, roomID, false) // Only the last in-flight generation for the room clears the indicator; the
// others merely stop their own keepalive loop (a forgetRoom mid-flight can drop
// the counter to <=0, which the guard treats as "last" — still correct).
if b.typingRelease(roomID) {
b.setTyping(ctx, roomID, false)
}
}) })
} }
} }
// typingAcquire registers one in-flight generation against the room-level typing indicator.
// The increment is the only state change; the caller (re)asserts the indicator on regardless,
// since re-sending typing=true is idempotent and refreshes the server-side 30s timeout.
func (b *Bot) typingAcquire(roomID string) {
b.mu.Lock()
b.typingRefs[roomID]++
b.mu.Unlock()
}
// typingRelease drops one in-flight generation and reports whether it was the LAST one for
// the room (refcount fell to <=0), so the caller clears the indicator only then. A forgetRoom
// that already deleted the key leaves a missing entry: the decrement reads 0 and lands at -1
// (<=0), so the guard still treats it as "last" and the negative entry is deleted — no leak.
func (b *Bot) typingRelease(roomID string) (last bool) {
b.mu.Lock()
defer b.mu.Unlock()
b.typingRefs[roomID]--
last = b.typingRefs[roomID] <= 0
if last {
delete(b.typingRefs, roomID)
}
return last
}
// setTyping sets/clears the bot's typing indicator (best-effort UX; failures are // setTyping sets/clears the bot's typing indicator (best-effort UX; failures are
// non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive. // non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive.
func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) { func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
@ -672,13 +737,17 @@ func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
} }
// buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop // buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop
// skip catches our own output. Thread-aware (F27): a trigger from a thread gets a // skip catches our own output. threadRoot decides where the answer lands: when non-empty
// thread relation so the answer lands in the thread, not the main timeline. // the answer carries an m.thread relation rooted there (F27) — either replying inside an
func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body string) map[string]any { // existing thread or auto-rooting a NEW DM conversation on the trigger. The caller's
// resolveThreadRoot makes that choice and is DM-gated, so a group answer never gets a
// thread relation it didn't already have. When empty the answer is a plain top-level
// reply (groups, and DMs with conversations off).
func buildNoticeContent(replyTo, sender, threadRoot, body string) map[string]any {
relates := map[string]any{} relates := map[string]any{}
if triggerRelates != nil && triggerRelates.RelType == "m.thread" && triggerRelates.EventID != "" { if threadRoot != "" {
relates["rel_type"] = "m.thread" relates["rel_type"] = "m.thread"
relates["event_id"] = triggerRelates.EventID relates["event_id"] = threadRoot
relates["is_falling_back"] = true relates["is_falling_back"] = true
relates["m.in_reply_to"] = map[string]any{"event_id": replyTo} relates["m.in_reply_to"] = map[string]any{"event_id": replyTo}
} else { } else {
@ -701,24 +770,55 @@ func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body
return content return content
} }
// --- per-room single-flight ---------------------------------------------------- // --- per-(room,thread) single-flight ---------------------------------------------
// tryClaim marks a room as generating and returns true if the caller won the claim // resolveThreadRoot decides which conversation a trigger belongs to, returning the thread
// (no generation was already in flight). The loser must drop its message. // root event id, or "" for the main timeline. Order: (1) a trigger already inside a thread
func (b *Bot) tryClaim(roomID string) bool { // continues that thread; (2) in a 1:1 DM, a top-level message roots a NEW conversation on
// itself (ChatGPT-style "new chat"); (3) otherwise — EVERY group message — the main timeline
// (""). This is the single place auto-threading is decided and it is hard-gated on isDM, so
// a group is NEVER auto-threaded (the group gate is structural, not a flag). buildNoticeContent
// emits the matching m.thread relation for the same value, so the conversation we serialize on
// is always the one the answer lands in.
func (b *Bot) resolveThreadRoot(isDM bool, ev *Event, mc *MessageContent) string {
if mc.RelatesTo != nil && mc.RelatesTo.RelType == "m.thread" && mc.RelatesTo.EventID != "" {
return mc.RelatesTo.EventID
}
if isDM {
return ev.EventID
}
return ""
}
// tryClaim marks a (room,thread) conversation as generating and returns true if the caller
// won the claim (nothing was already in flight for that exact conversation). The loser
// drops its message. Different threads of one room claim independently — a slow answer in
// one conversation never blocks another. The check-and-set is atomic under b.mu (one map,
// no per-thread mutex), so there is no lazy-lock TOCTOU.
func (b *Bot) tryClaim(roomID, threadRoot string) bool {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
if b.inflight[roomID] { m := b.inflight[roomID]
if m == nil {
m = make(map[string]bool)
b.inflight[roomID] = m
}
if m[threadRoot] {
return false return false
} }
b.inflight[roomID] = true m[threadRoot] = true
return true return true
} }
func (b *Bot) release(roomID string) { func (b *Bot) release(roomID, threadRoot string) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
delete(b.inflight, roomID) if m := b.inflight[roomID]; m != nil {
delete(m, threadRoot)
if len(m) == 0 {
delete(b.inflight, roomID)
}
}
} }
// --- per-room metadata helpers (all guarded by b.mu; probes run outside it) ----- // --- per-room metadata helpers (all guarded by b.mu; probes run outside it) -----
@ -752,8 +852,9 @@ func (b *Bot) forgetRoom(roomID string) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
delete(b.meta, roomID) delete(b.meta, roomID)
delete(b.buf, roomID) delete(b.buf, roomID) // drops the room's whole per-thread subtree in one delete
delete(b.inflight, roomID) delete(b.inflight, roomID) // (nested maps keyed by roomID keep forgetRoom O(1))
delete(b.typingRefs, roomID)
} }
// ensureEncryption returns whether the room is encrypted, probing the CS-API once // ensureEncryption returns whether the room is encrypted, probing the CS-API once
@ -824,28 +925,69 @@ func (b *Bot) ensureCounts(ctx context.Context, roomID string) (countsKnown, isD
return false, false, false return false, false, false
} }
func (b *Bot) snapshotBuf(roomID string) []bufferedMsg { // convBuf is one conversation's (one thread's) rolling context window plus a last-touch
// stamp used for LRU eviction. Without eviction, a long-lived control DM that spawns many
// ChatGPT-style conversations would accumulate one buffer per conversation for the whole
// process lifetime (forgetRoom only frees them on room leave/ban).
type convBuf struct {
msgs []bufferedMsg
touched uint64 // b.bufSeq at last append; higher = more recent
}
// maxConvBuffersPerRoom bounds how many conversation buffers a single room retains. A
// human DM rarely keeps this many live conversations at once; an evicted cold conversation
// just rebuilds its window from scratch on its next turn (same as after a restart), so the
// only cost of eviction is a one-turn cold start, never a wrong answer.
const maxConvBuffersPerRoom = 64
func (b *Bot) snapshotBuf(roomID, threadRoot string) []bufferedMsg {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
src := b.buf[roomID] // A never-seen conversation has no buffer (nil inner map / nil convBuf) → nil history,
if len(src) == 0 { // exactly what a fresh "new chat" wants (context = system + trigger).
cb := b.buf[roomID][threadRoot]
if cb == nil || len(cb.msgs) == 0 {
return nil return nil
} }
out := make([]bufferedMsg, len(src)) out := make([]bufferedMsg, len(cb.msgs))
copy(out, src) copy(out, cb.msgs)
return out return out
} }
func (b *Bot) appendBuf(roomID string, msg bufferedMsg) { func (b *Bot) appendBuf(roomID, threadRoot string, msg bufferedMsg) {
b.mu.Lock() b.mu.Lock()
defer b.mu.Unlock() defer b.mu.Unlock()
limit := b.cfg.MaxCtxEvent * 2 limit := b.cfg.MaxCtxEvent * 2
if limit < 8 { if limit < 8 {
limit = 8 limit = 8
} }
buf := append(b.buf[roomID], msg) m := b.buf[roomID]
if len(buf) > limit { if m == nil {
buf = buf[len(buf)-limit:] m = make(map[string]*convBuf)
b.buf[roomID] = m
}
cb := m[threadRoot]
if cb == nil {
cb = &convBuf{}
m[threadRoot] = cb
}
cb.msgs = append(cb.msgs, msg)
if len(cb.msgs) > limit {
cb.msgs = cb.msgs[len(cb.msgs)-limit:]
}
b.bufSeq++
cb.touched = b.bufSeq
// LRU-evict the least-recently-touched conversation once the room exceeds the cap. The
// just-touched conversation has the highest `touched`, so it is never the victim.
if len(m) > maxConvBuffersPerRoom {
var victim string
var oldest uint64
for k, v := range m {
if victim == "" || v.touched < oldest {
victim, oldest = k, v.touched
}
}
delete(m, victim)
} }
b.buf[roomID] = buf
} }

158
apps/ai-bot/buffers_test.go Normal file
View file

@ -0,0 +1,158 @@
package main
import (
"fmt"
"testing"
)
// TestConvIDFlatCaseInvariant pins the load-bearing prompt-cache invariant of the
// per-(room,thread) refactor: the MAIN-timeline conv id (threadRoot "") must stay
// byte-identical to the pre-threading per-room value, so rooms that existed before
// threading keep their warm Grok prompt-cache routing; and a thread must get a DISTINCT
// id so divergent thread prefixes don't thrash one shared cache slot. Also asserts the
// flag-off path returns "" (no header) for both cases.
func TestConvIDFlatCaseInvariant(t *testing.T) {
b := &Bot{cfg: &Config{GrokPromptCache: true}}
flat := b.convID("!room:vojo.chat", "")
legacy := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat"))
if flat != legacy {
t.Fatalf("flat convID = %q, want legacy per-room value %q (prompt-cache continuity)", flat, legacy)
}
threaded := b.convID("!room:vojo.chat", "$root")
if threaded == flat {
t.Fatalf("threaded convID must differ from the flat/main-timeline id, both = %q", flat)
}
if want := fmt.Sprintf("vojo-%08x", hashString("!room:vojo.chat|$root")); threaded != want {
t.Fatalf("threaded convID = %q, want %q", threaded, want)
}
off := &Bot{cfg: &Config{GrokPromptCache: false}}
if got := off.convID("!room:vojo.chat", ""); got != "" {
t.Fatalf("convID with GrokPromptCache off must be empty, got %q", got)
}
if got := off.convID("!room:vojo.chat", "$root"); got != "" {
t.Fatalf("threaded convID with GrokPromptCache off must be empty, got %q", got)
}
}
// TestSnapshotAppendBufNilPaths exercises the nested-map nil reads the per-(room,thread)
// keying introduced: a never-seen room, a known room but unknown thread, and the round-trip
// of a single append. A nil inner map / nil convBuf must read back as nil history (exactly
// what a fresh "new chat" wants), never panic.
func TestSnapshotAppendBufNilPaths(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
if got := b.snapshotBuf("!a", ""); got != nil {
t.Fatalf("snapshot of a never-seen room must be nil, got %v", got)
}
b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: "hi", isBot: false})
got := b.snapshotBuf("!a", "")
if len(got) != 1 || got[0].body != "hi" {
t.Fatalf("snapshot after one append = %v, want one message 'hi'", got)
}
// Same room, a DIFFERENT thread that was never appended to → nil (inner map exists, key absent).
if got := b.snapshotBuf("!a", "$other"); got != nil {
t.Fatalf("snapshot of an unknown thread in a known room must be nil, got %v", got)
}
// A different room entirely → nil (outer key absent).
if got := b.snapshotBuf("!b", ""); got != nil {
t.Fatalf("snapshot of a different room must be nil, got %v", got)
}
}
// TestAppendBufTrimsToLimit asserts a single conversation's buffer is bounded to
// MaxCtxEvent*2 (min 8) and keeps the most recent messages (FIFO drop of the oldest).
func TestAppendBufTrimsToLimit(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 10}, buf: make(map[string]map[string]*convBuf)}
const limit = 20 // MaxCtxEvent*2
for i := 0; i < limit+5; i++ {
b.appendBuf("!a", "", bufferedMsg{sender: "@u:vojo.chat", body: fmt.Sprintf("m%d", i)})
}
got := b.snapshotBuf("!a", "")
if len(got) != limit {
t.Fatalf("buffer length = %d, want capped at %d", len(got), limit)
}
// Oldest 5 dropped; the window starts at m5 and ends at m24.
if got[0].body != "m5" || got[len(got)-1].body != "m24" {
t.Fatalf("buffer window = [%s..%s], want [m5..m24]", got[0].body, got[len(got)-1].body)
}
}
// TestAppendBufLRUEviction proves the per-room conversation-buffer cap: once a room exceeds
// maxConvBuffersPerRoom, the LEAST-recently-touched conversation is evicted, and the
// just-touched conversation is never the victim.
func TestAppendBufLRUEviction(t *testing.T) {
b := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
// Touch exactly maxConvBuffersPerRoom+1 distinct threads, oldest-first.
for i := 0; i <= maxConvBuffersPerRoom; i++ {
b.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
}
if n := len(b.buf["!a"]); n != maxConvBuffersPerRoom {
t.Fatalf("room buffer count = %d, want capped at %d", n, maxConvBuffersPerRoom)
}
if got := b.snapshotBuf("!a", "$t0"); got != nil {
t.Fatalf("the coldest conversation ($t0) must have been evicted, got %v", got)
}
if got := b.snapshotBuf("!a", fmt.Sprintf("$t%d", maxConvBuffersPerRoom)); got == nil {
t.Fatal("the just-appended conversation must never be the eviction victim")
}
// Re-touch protection: fill to the cap, re-touch the OLDEST, then overflow by one.
// The re-touched conversation must survive; the new second-oldest becomes the victim.
b2 := &Bot{cfg: &Config{MaxCtxEvent: 4}, buf: make(map[string]map[string]*convBuf)}
for i := 0; i < maxConvBuffersPerRoom; i++ {
b2.appendBuf("!a", fmt.Sprintf("$t%d", i), bufferedMsg{body: "x"})
}
b2.appendBuf("!a", "$t0", bufferedMsg{body: "x"}) // re-touch the oldest → now newest
b2.appendBuf("!a", "$overflow", bufferedMsg{body: "x"}) // overflow → evict the coldest
if got := b2.snapshotBuf("!a", "$t0"); got == nil {
t.Fatal("a re-touched conversation ($t0) must NOT be evicted")
}
if got := b2.snapshotBuf("!a", "$t1"); got != nil {
t.Fatalf("the new coldest conversation ($t1) must be the victim, got %v", got)
}
}
// TestTypingRefcount covers the per-room typing refcount the per-(room,thread) concurrency
// relies on (Matrix typing is room-scoped, so several thread generations share one
// indicator). Only the LAST release reports "last" (clears the indicator), and a release
// after forgetRoom dropped the key must self-heal without leaving a leaked negative entry.
func TestTypingRefcount(t *testing.T) {
b := &Bot{typingRefs: make(map[string]int)}
b.typingAcquire("!a")
b.typingAcquire("!a")
if b.typingRefs["!a"] != 2 {
t.Fatalf("typingRefs after two acquires = %d, want 2", b.typingRefs["!a"])
}
if last := b.typingRelease("!a"); last {
t.Fatal("first release of two must NOT be the last")
}
if last := b.typingRelease("!a"); !last {
t.Fatal("second release of two must be the last")
}
if _, ok := b.typingRefs["!a"]; ok {
t.Fatalf("the key must be deleted once the refcount hits 0, still present = %d", b.typingRefs["!a"])
}
// forgetRoom mid-flight: two acquires, then the room is forgotten (key deleted), then the
// in-flight generations release against a missing key. Each lands at -1 (<=0 → "last") and
// deletes the entry, so no persistent negative count leaks.
b.typingAcquire("!b")
b.typingAcquire("!b")
b.forgetRoom("!b")
if last := b.typingRelease("!b"); !last {
t.Fatal("a release after forgetRoom must report last (counter <= 0)")
}
if last := b.typingRelease("!b"); !last {
t.Fatal("a second stale release must also report last")
}
if _, ok := b.typingRefs["!b"]; ok {
t.Fatalf("no negative entry must leak after forgetRoom + stale releases, value = %d", b.typingRefs["!b"])
}
}

View file

@ -5,52 +5,89 @@ import (
"testing" "testing"
) )
// TestSingleFlightClaim documents the per-room single-flight invariant the async // TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
// refactor relies on: at most one generation per room at a time, the claim is // async refactor relies on: at most one generation per conversation at a time, the claim
// independent per room, and a release re-arms the room. handleEvent takes this claim // is independent per (room,thread), and a release re-arms only that conversation.
// synchronously in transaction order, so the FIRST message for a room wins and later // handleEvent takes this claim synchronously in transaction order, so the FIRST message
// ones are dropped until release (never the reverse). // for a conversation wins and later ones are dropped until release (never the reverse).
func TestSingleFlightClaim(t *testing.T) { func TestSingleFlightClaim(t *testing.T) {
b := &Bot{inflight: make(map[string]bool)} b := &Bot{inflight: make(map[string]map[string]bool)}
if !b.tryClaim("!a") { if !b.tryClaim("!a", "") {
t.Fatal("first claim on !a should win") t.Fatal("first claim on (!a, main) should win")
} }
if b.tryClaim("!a") { if b.tryClaim("!a", "") {
t.Fatal("second claim on !a must fail while in flight") t.Fatal("second claim on (!a, main) must fail while in flight")
} }
if !b.tryClaim("!b") { // A DIFFERENT thread in the SAME room must claim independently — the whole point of
// per-(room,thread) single-flight: a slow answer in one conversation cannot block
// another conversation in the same room.
if !b.tryClaim("!a", "$root1") {
t.Fatal("a different thread in the same room must claim independently")
}
if b.tryClaim("!a", "$root1") {
t.Fatal("second claim on (!a, $root1) must fail while in flight")
}
if !b.tryClaim("!b", "") {
t.Fatal("a different room must claim independently") t.Fatal("a different room must claim independently")
} }
b.release("!a") b.release("!a", "")
if !b.tryClaim("!a") { if !b.tryClaim("!a", "") {
t.Fatal("after release !a must be claimable again") t.Fatal("after release (!a, main) must be claimable again")
}
// Releasing the main timeline must NOT free the thread's claim.
if b.tryClaim("!a", "$root1") {
t.Fatal("releasing (!a, main) must not free (!a, $root1)")
} }
} }
// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same // TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
// room and asserts EXACTLY ONE wins the claim — the property that prevents two // conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
// concurrent generations (double xAI spend) for one room. Run under -race. // generations (double xAI spend) for one conversation. It also races two DIFFERENT
// threads of one room together and asserts each has its own single winner, proving the
// claim is independent per (room,thread), not per room. Run under -race.
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) { func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
b := &Bot{inflight: make(map[string]bool)} b := &Bot{inflight: make(map[string]map[string]bool)}
const n = 64 const n = 64
var wins int64 var sameWins, threadAWins, threadBWins int64
var mu sync.Mutex var mu sync.Mutex
var wg sync.WaitGroup var wg sync.WaitGroup
wg.Add(n) wg.Add(n * 3)
for i := 0; i < n; i++ { for i := 0; i < n; i++ {
go func() { go func() {
defer wg.Done() defer wg.Done()
if b.tryClaim("!room") { if b.tryClaim("!room", "$same") {
mu.Lock() mu.Lock()
wins++ sameWins++
mu.Unlock()
}
}()
go func() {
defer wg.Done()
if b.tryClaim("!room", "$a") {
mu.Lock()
threadAWins++
mu.Unlock()
}
}()
go func() {
defer wg.Done()
if b.tryClaim("!room", "$b") {
mu.Lock()
threadBWins++
mu.Unlock() mu.Unlock()
} }
}() }()
} }
wg.Wait() wg.Wait()
if wins != 1 { if sameWins != 1 {
t.Fatalf("exactly one goroutine must win the claim, got %d", wins) t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins)
}
if threadAWins != 1 {
t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins)
}
if threadBWins != 1 {
t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins)
} }
} }

View file

@ -121,7 +121,7 @@ func TestMarkdownAdversarialNoPanicNoInjection(t *testing.T) {
} }
func TestBuildNoticeContentAttachesFormatted(t *testing.T) { func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "Here is **bold**.") c := buildNoticeContent("$evt", "@u:vojo.chat", "", "Here is **bold**.")
if c["format"] != matrixHTMLFormat { if c["format"] != matrixHTMLFormat {
t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat) t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat)
} }
@ -135,7 +135,7 @@ func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
} }
func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) { func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) {
c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "no markdown here") c := buildNoticeContent("$evt", "@u:vojo.chat", "", "no markdown here")
if _, ok := c["format"]; ok { if _, ok := c["format"]; ok {
t.Fatalf("format must be absent for plain text") t.Fatalf("format must be absent for plain text")
} }

View file

@ -0,0 +1,70 @@
package main
import "testing"
// TestResolveThreadRoot pins the conversation-routing gate — the single place that decides
// whether a trigger continues a thread, roots a NEW conversation, or stays on the main
// timeline. The load-bearing invariant is the LAST case: a group is NEVER auto-threaded, so
// the threading feature can't change group behavior. Auto-threading in 1:1 DMs is always on
// (no flag); the only gate is isDM.
func TestResolveThreadRoot(t *testing.T) {
inThread := &MessageContent{RelatesTo: &RelatesTo{RelType: "m.thread", EventID: "$root"}}
topLevel := &MessageContent{}
reply := &MessageContent{RelatesTo: &RelatesTo{RelType: "", EventID: "", InReplyTo: &InReplyTo{EventID: "$x"}}}
ev := &Event{EventID: "$trigger"}
cases := []struct {
name string
isDM bool
mc *MessageContent
want string
}{
{"existing thread continues (DM)", true, inThread, "$root"},
{"existing thread continues (group)", false, inThread, "$root"},
{"DM top-level roots a new thread on the trigger", true, topLevel, "$trigger"},
{"GROUP top-level never auto-threads", false, topLevel, ""},
{"DM plain reply (no m.thread) roots a new thread", true, reply, "$trigger"},
{"GROUP plain reply never auto-threads", false, reply, ""},
}
for _, c := range cases {
b := &Bot{}
if got := b.resolveThreadRoot(c.isDM, ev, c.mc); got != c.want {
t.Errorf("%s: resolveThreadRoot = %q, want %q", c.name, got, c.want)
}
}
}
// TestBuildNoticeContentThreadRelation asserts the reply lands where resolveThreadRoot
// decided: a non-empty threadRoot emits an m.thread relation (so the answer joins the
// conversation), an empty one emits only m.in_reply_to (a plain top-level reply).
func TestBuildNoticeContentThreadRelation(t *testing.T) {
threaded := buildNoticeContent("$reply", "@u:vojo.chat", "$root", "hi")
rel, ok := threaded["m.relates_to"].(map[string]any)
if !ok {
t.Fatalf("m.relates_to missing or wrong type: %T", threaded["m.relates_to"])
}
if rel["rel_type"] != "m.thread" {
t.Errorf("rel_type = %v, want m.thread", rel["rel_type"])
}
if rel["event_id"] != "$root" {
t.Errorf("event_id = %v, want $root", rel["event_id"])
}
if rel["is_falling_back"] != true {
t.Errorf("is_falling_back = %v, want true", rel["is_falling_back"])
}
if inReply, _ := rel["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
}
topLevel := buildNoticeContent("$reply", "@u:vojo.chat", "", "hi")
rel2, ok := topLevel["m.relates_to"].(map[string]any)
if !ok {
t.Fatalf("m.relates_to missing or wrong type: %T", topLevel["m.relates_to"])
}
if _, hasThread := rel2["rel_type"]; hasThread {
t.Errorf("a top-level reply must not carry rel_type, got %v", rel2["rel_type"])
}
if inReply, _ := rel2["m.in_reply_to"].(map[string]any); inReply["event_id"] != "$reply" {
t.Errorf("m.in_reply_to.event_id = %v, want $reply", inReply["event_id"])
}
}

View file

@ -1,4 +0,0 @@
node_modules/
dist/
.vite/
*.local

View file

@ -1,12 +0,0 @@
<!doctype html>
<html lang="ru">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<title>Vojo AI</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load diff

View file

@ -1,20 +0,0 @@
{
"name": "@vojo/widget-vojo-ai",
"version": "0.0.1",
"private": true,
"description": "Vojo AI bot widget — policy notice + «Add to chat», mounts inside /bots/vojo-ai",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc --noEmit && vite build",
"preview": "vite preview"
},
"dependencies": {
"preact": "10.22.1"
},
"devDependencies": {
"@preact/preset-vite": "2.9.0",
"typescript": "5.4.5",
"vite": "5.4.19"
}
}

View file

@ -1,143 +0,0 @@
import { useEffect, useState } from 'preact/hooks';
import type { WidgetBootstrap } from './bootstrap';
import type { WidgetApi } from './widget-api';
import { createT, type T } from './i18n';
// Must match the host's capability string (catalog.ts BOT_CAP_ADD_TO_CHAT).
const ADD_TO_CHAT_CAP = 'vojo.add_to_chat';
// Lead glyph for the «Добавить в чат» card — a speech bubble with a «+».
// Stroke-only so it picks up `currentColor` and matches the bridge widgets'
// icon language (viewBox 20×20, stroke-width 1.6).
const AddChatIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M3 5.5A2 2 0 0 1 5 3.5h10a2 2 0 0 1 2 2v6a2 2 0 0 1-2 2H7.5L4 16.5v-3A2 2 0 0 1 3 11.5z"
stroke-linejoin="round"
/>
<line x1="10" y1="6.2" x2="10" y2="10.8" stroke-linecap="round" />
<line x1="7.7" y1="8.5" x2="12.3" y2="8.5" stroke-linecap="round" />
</svg>
);
// Shield + check — leads the «Конфиденциальность и данные» card (mirrors the
// Telegram widget's info-card-opens-modal pattern).
const ShieldIcon = () => (
<svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" aria-hidden="true">
<path
d="M10 2.5l6 2.2v4.6c0 3.7-2.5 6.4-6 8.2-3.5-1.8-6-4.5-6-8.2V4.7z"
stroke-linejoin="round"
/>
<path d="M7.4 9.8l1.9 1.9 3.3-3.6" stroke-linecap="round" stroke-linejoin="round" />
</svg>
);
// Full privacy notice, behind a card → modal (Telegram «О боте» pattern). This
// is where the Grok / xAI / 30-day / third-party detail lives — NOT on the
// surface. Backdrop click + Escape close; no focus-trap (small surface).
const AboutModal = ({ t, onClose }: { t: T; onClose: () => void }) => {
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onClose();
};
window.addEventListener('keydown', onKey);
return () => window.removeEventListener('keydown', onKey);
}, [onClose]);
return (
<div
class="about-overlay"
role="dialog"
aria-modal="true"
aria-label={t('about.title')}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div class="about-panel">
<header class="about-header">
<h2 class="about-title">{t('about.title')}</h2>
<button
type="button"
class="about-close-x"
onClick={onClose}
aria-label={t('about.aria-close')}
>
×
</button>
</header>
<div class="about-body">
<p>{t('about.body-1')}</p>
<p>{t('about.body-2')}</p>
<p>{t('about.body-3')}</p>
<p>{t('about.body-4')}</p>
<p>{t('about.consent')}</p>
</div>
<div class="about-footer">
<button type="button" class="btn-primary" onClick={onClose}>
{t('about.close')}
</button>
</div>
</div>
</div>
);
};
type AppProps = {
bootstrap: WidgetBootstrap;
api: WidgetApi;
};
export function App({ bootstrap, api }: AppProps) {
const t = createT(bootstrap.clientLanguage);
const [aboutOpen, setAboutOpen] = useState(false);
// Render the action ONLY when the host advertised the capability. UI hint
// only — the host re-checks the capability against the trusted config before
// honouring the verb, so a forced render here cannot escalate anything.
const canAddToChat = bootstrap.capabilities.includes(ADD_TO_CHAT_CAP);
// Follow host theme changes (initial theme applied in main.tsx before paint).
useEffect(() => {
api.on('themeChange', (name) => {
document.documentElement.dataset.theme = name;
});
}, [api]);
return (
<div class="app">
<section class="section">
<div class="command-grid">
{canAddToChat && (
<button class="command-card" type="button" onClick={() => api.addToChat()}>
<span class="command-card-lead-icon" aria-hidden="true">
<AddChatIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.add.name')}</div>
<div class="command-card-desc">{t('card.add.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
)}
<button class="command-card" type="button" onClick={() => setAboutOpen(true)}>
<span class="command-card-lead-icon" aria-hidden="true">
<ShieldIcon />
</span>
<div class="command-card-body">
<div class="command-card-name">{t('card.privacy.name')}</div>
<div class="command-card-desc">{t('card.privacy.desc')}</div>
</div>
<span class="command-card-chevron" aria-hidden="true">
</span>
</button>
</div>
</section>
{aboutOpen && <AboutModal t={t} onClose={() => setAboutOpen(false)} />}
</div>
);
}

View file

@ -1,69 +0,0 @@
// Parse the URL params the bot widget host appends when loading experience.url.
// Source of truth on the host side:
// src/app/features/bots/BotWidgetEmbed.ts (getBotWidgetUrl).
// Keep this in sync if the host adds params.
export type WidgetBootstrap = {
widgetId: string;
parentUrl: string;
parentOrigin: string;
roomId: string;
userId: string;
botId: string;
botMxid: string;
/** Elevated host verbs this bot is allowed to drive, forwarded by the host
* as a CSV render hint (NOT an authorization input the host re-checks the
* capability against the trusted config before acting). Used here only to
* decide whether to draw the «Add to chat» button. Empty CSV `[]` (F19). */
capabilities: string[];
theme: 'light' | 'dark';
clientLanguage: string;
};
export type BootstrapResult =
| { ok: true; bootstrap: WidgetBootstrap }
| { ok: false; missing: string[] };
const REQUIRED = ['widgetId', 'parentUrl', 'roomId', 'userId', 'botMxid'] as const;
export const readBootstrap = (search: string): BootstrapResult => {
const params = new URLSearchParams(search);
const get = (k: string) => params.get(k) ?? '';
const missing = REQUIRED.filter((k) => !params.get(k));
if (missing.length > 0) return { ok: false, missing: [...missing] };
// Origin is what the widget validates against on incoming postMessage — see
// widget-api.ts. Falling back to '*' would defeat the security boundary, so a
// malformed parentUrl bails out as a missing-param error.
let parentOrigin: string;
try {
parentOrigin = new URL(get('parentUrl')).origin;
} catch {
return { ok: false, missing: ['parentUrl'] };
}
// CSV → string[]. Empty string MUST become [] — `''.split(',')` yields `['']`
// which would make `capabilities.includes(...)` subtly wrong downstream (F19).
const capsRaw = get('capabilities');
const capabilities = capsRaw ? capsRaw.split(',').filter(Boolean) : [];
const themeRaw = get('theme');
const theme: 'light' | 'dark' = themeRaw === 'dark' ? 'dark' : 'light';
return {
ok: true,
bootstrap: {
widgetId: get('widgetId'),
parentUrl: get('parentUrl'),
parentOrigin,
roomId: get('roomId'),
userId: get('userId'),
botId: get('botId'),
botMxid: get('botMxid'),
capabilities,
theme,
clientLanguage: get('clientLanguage'),
},
};
};

View file

@ -1,26 +0,0 @@
// English fallback. Mirror the RU key set; `Record<StringKey, string>` enforces
// that every RU key has an EN counterpart at compile time.
import type { StringKey } from './ru';
export const EN: Record<StringKey, string> = {
'card.add.name': 'Add to chat',
'card.add.desc': 'Invite Vojo AI into a room — it will reply to mentions there.',
'card.privacy.name': 'Privacy & data',
'card.privacy.desc': 'What is sent to the AI service and how it is stored',
'about.title': 'Vojo AI privacy',
'about.body-1': 'Vojo AI is an AI-powered virtual assistant. The model is provided by xAI (USA).',
'about.body-2':
'When you mention the robot in a chat or message it directly, the text of messages from that chat is sent to xAI to generate a reply and may be stored there for up to 30 days.',
'about.body-3':
'If the robot is added to a group chat, other participants messages may also reach xAI. Only add the robot where appropriate, and let the other participants know.',
'about.body-4':
'Do not send the robot personal, payment, or other confidential data. Replies are AI-generated and may contain errors.',
'about.consent':
'By adding the robot to a chat, you consent to sending that chats messages to xAI.',
'about.close': 'Close',
'about.aria-close': 'Close “Vojo AI privacy”',
'bootstrap.failed': 'Widget failed to start',
'bootstrap.missing-params': 'Missing required URL params: {names}.',
'bootstrap.embedded-only': 'This page is meant to be embedded by Vojo at {route}.',
};

View file

@ -1,30 +0,0 @@
// Tiny i18n harness. Russian primary, English fallback (BCP-47 prefix match —
// any `en` variant). Bootstrap forwards `clientLanguage` from the host; main.tsx
// can also call `createT()` without args before bootstrap completes (falls back
// to navigator.language, then RU).
import { RU, type StringKey } from './ru';
import { EN } from './en';
const interpolate = (s: string, vars?: Record<string, string>): string => {
if (!vars) return s;
return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`);
};
const pickDict = (clientLanguage: string | undefined): Record<StringKey, string> => {
const lang = (
clientLanguage ||
(typeof navigator !== 'undefined' ? navigator.language : '') ||
'ru'
).toLowerCase();
return lang.startsWith('en') ? EN : RU;
};
export type T = (key: StringKey, vars?: Record<string, string>) => string;
export const createT = (clientLanguage?: string): T => {
const dict = pickDict(clientLanguage);
return (key, vars) => interpolate(dict[key], vars);
};
export type { StringKey };

View file

@ -1,36 +0,0 @@
// Russian primary copy. To add a string:
// 1. add the key + RU value here (this file is the canonical key list — `en.ts`
// and the `StringKey` type derive from it),
// 2. add the same key + EN value in `en.ts`,
// 3. consume via `t('key', { var: 'x' })`.
// Interpolation uses `{name}` placeholders resolved against the second arg.
//
// The hero (name/avatar) is OWNED BY THE HOST (src/app/features/bots/BotShell).
// The widget renders action cards + a privacy modal (Telegram «О боте» pattern).
export const RU = {
// Action card.
'card.add.name': 'Добавить в чат',
'card.add.desc': 'Пригласите Vojo AI в комнату — он будет отвечать на упоминания в ней.',
// Privacy card → opens the full policy modal.
'card.privacy.name': 'Конфиденциальность и данные',
'card.privacy.desc': 'Что отправляется в ИИ-сервис и как хранится',
// Full privacy notice (the «Политика» spelled out — see card.privacy).
'about.title': 'Конфиденциальность Vojo AI',
'about.body-1':
'Vojo AI — виртуальный собеседник на базе искусственного интеллекта. Модель предоставляет компания xAI (США).',
'about.body-2':
'Когда вы упоминаете робота в чате или пишете ему напрямую, текст сообщений из этого чата передаётся в xAI для генерации ответа и может храниться там до 30 дней.',
'about.body-3':
'Если робот добавлен в групповой чат, в xAI могут попасть и сообщения других участников. Добавляйте робота только туда, где это уместно, и предупреждайте собеседников.',
'about.body-4':
'Не отправляйте роботу персональные, платёжные или иные конфиденциальные данные. Ответы генерирует ИИ — они могут содержать ошибки.',
'about.consent': 'Добавляя робота в чат, вы соглашаетесь на передачу сообщений этого чата в xAI.',
'about.close': 'Закрыть',
'about.aria-close': 'Закрыть «Конфиденциальность Vojo AI»',
'bootstrap.failed': 'Виджет не запустился',
'bootstrap.missing-params': 'Не хватает параметров URL: {names}.',
'bootstrap.embedded-only': 'Эта страница предназначена для встраивания в Vojo по адресу {route}.',
} as const;
export type StringKey = keyof typeof RU;

View file

@ -1,61 +0,0 @@
import { render } from 'preact';
import { readBootstrap } from './bootstrap';
import { App } from './App';
import { createT } from './i18n';
import { WidgetApi } from './widget-api';
import './styles.css';
// Input-mode detector for hover styling (same rationale as widget-telegram):
// Capacitor's Android WebView synthesises a sticky `:hover` on the tapped
// element after a tap. CSS gates `:hover` on `:root[data-input="mouse"]`; truth
// comes from `pointerdown.pointerType`. Default 'mouse' is no worse than any
// interaction-media query, which mis-report on shipping devices.
const setInputMode = (mode: 'touch' | 'mouse'): void => {
document.documentElement.dataset.input = mode;
};
setInputMode('mouse');
window.addEventListener(
'pointerdown',
(event) => {
setInputMode(event.pointerType === 'mouse' ? 'mouse' : 'touch');
},
{ passive: true, capture: true }
);
const root = document.getElementById('app');
if (!root) {
throw new Error('#app root element missing — index.html out of sync');
}
const result = readBootstrap(window.location.search);
if (!result.ok) {
// Either the widget URL was opened directly (no host params) or a host bug
// failed to provide them. Render a self-contained diagnostic. Bootstrap failed
// before clientLanguage could be read, so createT falls back to
// navigator.language.
const t = createT();
render(
<div class="app">
<div class="error-banner">
<strong>{t('bootstrap.failed')}</strong>
{t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '}
{t('bootstrap.embedded-only', { route: '/bots/vojo-ai' })}
</div>
</div>,
root
);
} else {
// Apply the initial theme synchronously so the first paint isn't flashed
// through the wrong palette.
document.documentElement.dataset.theme = result.bootstrap.theme;
// Instantiate the WidgetApi BEFORE React render — its constructor attaches the
// `message` listener synchronously, so the host's capability request (fired on
// iframe `load`) is never missed on a warm/cached second mount. This widget
// requests NO matrix-widget-api capabilities (it neither reads nor sends
// events); the `add-to-chat` verb rides the separate io.vojo.bot-widget
// side-channel and is intentionally NOT a matrix-widget-api capability.
const api = new WidgetApi(result.bootstrap, []);
render(<App bootstrap={result.bootstrap} api={api} />, root);
}

View file

@ -1,360 +0,0 @@
/* Dawn palette + command-card / about-modal vocabulary a faithful subset of
* apps/widget-telegram/src/styles.css so the Vojo AI widget reads as the same
* surface as the bridge widgets (same palette, sections, command cards, and
* the «about» modal pattern for detailed copy). */
:root {
--bg: #181a20;
--bg2: #0d0e11;
--surface: #21232b;
--surface2: #2a2d36;
--divider: rgba(255, 255, 255, 0.06);
--hairline: rgba(255, 255, 255, 0.08);
--text: #e6e6e9;
--muted: rgba(230, 230, 233, 0.55);
--faint: rgba(230, 230, 233, 0.32);
--fleet: #9580ff;
--fleet-soft: #a59cff;
--green: #7dd3a8;
--rose: #c08e7b;
--section-pad-x: 40px;
}
[data-theme='light'] {
/* Light theme is intentionally a thin remap. Vojo is dark-default. */
--bg: #f5f5f7;
--bg2: #ffffff;
--surface: #f0f0f2;
--surface2: #e8e8ec;
--divider: rgba(0, 0, 0, 0.08);
--hairline: rgba(0, 0, 0, 0.1);
--text: #1a1a1d;
--muted: rgba(26, 26, 29, 0.62);
--faint: rgba(26, 26, 29, 0.4);
}
@media (max-width: 600px) {
:root {
--section-pad-x: 20px;
}
}
* {
box-sizing: border-box;
/* Kills the translucent grey overlay iOS/Android WebViews paint over a
* tapped element (read as «button stuck on grey»). Web browsers ignore it. */
-webkit-tap-highlight-color: transparent;
}
html,
body,
#app {
height: 100%;
}
body {
margin: 0;
padding: 0;
background: var(--bg);
color: var(--text);
font: 14px/1.45 -apple-system, 'Segoe UI', 'Inter', system-ui, sans-serif;
-webkit-font-smoothing: antialiased;
text-rendering: optimizeLegibility;
}
.app {
display: flex;
flex-direction: column;
min-height: 100%;
max-width: 960px;
margin: 0 auto;
}
/* The hero (avatar + name + handle + description + three-dots menu) is OWNED
* BY THE HOST see src/app/features/bots/BotShell.tsx. The widget body starts
* with the action/privacy section directly. */
/* ── Section ──────────────────────────────────────────────────────── */
.section {
padding: 24px var(--section-pad-x) 20px;
}
.section + .section {
padding-top: 4px;
}
/* Section label — dark-bg pill, uppercase letter-spaced caption. */
.section-label {
display: inline-flex;
align-items: center;
font-size: 13px;
line-height: 20px;
text-transform: uppercase;
letter-spacing: 1.4px;
font-weight: 600;
color: var(--muted);
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 8px;
padding: 8px 14px;
margin: 0 0 14px;
white-space: nowrap;
user-select: none;
}
/* ── Command card (action card with lead icon + name + desc + chevron) ─ */
/* Lifted verbatim from the bridge widget so «Добавить в чат» / «Конфиденци-
* альность» are pixel-identical to the Telegram login/about cards. */
.command-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
grid-auto-rows: 1fr;
gap: 10px;
}
.command-card {
/* The widget runs in an iframe and does NOT inherit the host's
* `button { -webkit-appearance: button }` rule, so on iOS/Android WebView a
* <button> draws a native focus/active overlay ON TOP of our background
* (the «greys out and doesn't snap back» bug). appearance:none makes our CSS
* the sole source of truth. Web browsers ignore appearance for <button>. */
-webkit-appearance: none;
appearance: none;
background: var(--bg2);
border: 1px solid var(--divider);
border-radius: 10px;
padding: 14px 16px;
display: flex;
align-items: center;
gap: 12px;
cursor: pointer;
text-align: left;
font: inherit;
color: inherit;
transition: border-color 0.12s, background 0.12s;
}
/* Hover scoped to mouse-mode sessions only Capacitor Android WebView reports
* `(hover: hover)` TRUE on pure-touch devices, so a media query alone would
* leave a sticky synthesised :hover after tap. `[data-input]` is set in
* main.tsx from the real `pointerdown.pointerType`. */
:root[data-input='mouse'] .command-card:hover:not(:disabled) {
background: var(--surface);
border-color: var(--hairline);
}
.command-card:focus {
outline: none;
}
:root[data-input='mouse'] .command-card:focus-visible {
outline: 2px solid var(--fleet);
outline-offset: 2px;
}
.command-card-lead-icon {
flex-shrink: 0;
display: inline-flex;
align-items: center;
justify-content: center;
color: var(--muted);
}
.command-card-lead-icon svg {
width: 20px;
height: 20px;
display: block;
}
.command-card-body {
flex: 1;
min-width: 0;
}
.command-card-name {
font-size: 15px;
color: var(--text);
font-weight: 600;
margin-bottom: 3px;
}
.command-card-desc {
font-size: 14px;
color: var(--muted);
line-height: 19px;
}
.command-card-chevron {
color: var(--muted);
font-size: 18px;
flex-shrink: 0;
line-height: 1;
display: inline-flex;
align-items: center;
justify-content: center;
}
@media (max-width: 600px) {
.command-grid {
grid-template-columns: minmax(0, 1fr);
}
.command-card {
padding: 12px 14px;
border-radius: 8px;
}
.command-card-name {
font-size: 14px;
margin-bottom: 2px;
}
.command-card-desc {
font-size: 13px;
line-height: 17px;
}
}
/* ── Buttons ─────────────────────────────────────────────────────────── */
.btn-primary {
-webkit-appearance: none;
appearance: none;
background: var(--fleet);
color: #0c0c0e;
border: none;
border-radius: 8px;
padding: 10px 18px;
font: inherit;
font-size: 13px;
font-weight: 600;
cursor: pointer;
}
.btn-primary:disabled {
opacity: 0.5;
cursor: not-allowed;
}
/* ── About / policy modal ───────────────────────────────────────────── */
/* Lightweight modal fixed inside the widget iframe, not crossing into the
* host. Backdrop click + Escape close; no focus-trap library (small surface).
* Identical chrome to the Telegram widget's «О боте» modal. */
.about-overlay {
position: fixed;
inset: 0;
background: rgba(13, 14, 17, 0.72);
display: flex;
align-items: center;
justify-content: center;
z-index: 1000;
padding: 20px;
animation: about-fade 0.15s ease-out;
}
@keyframes about-fade {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.about-panel {
background: var(--bg);
border: 1px solid var(--hairline);
border-radius: 14px;
width: 100%;
max-width: 520px;
max-height: 90vh;
display: flex;
flex-direction: column;
overflow: hidden;
box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);
}
.about-header {
display: flex;
align-items: center;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--divider);
}
.about-title {
flex: 1;
font-size: 17px;
font-weight: 600;
color: var(--text);
margin: 0;
line-height: 1.3;
}
.about-close-x {
background: transparent;
border: none;
color: var(--muted);
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: inline-flex;
align-items: center;
justify-content: center;
flex-shrink: 0;
font: inherit;
font-size: 24px;
line-height: 1;
transition: background 0.12s, color 0.12s;
}
.about-close-x:hover {
background: var(--surface);
color: var(--text);
}
.about-body {
padding: 16px 18px;
overflow-y: auto;
display: flex;
flex-direction: column;
gap: 12px;
}
.about-body p {
margin: 0;
font-size: 14px;
line-height: 1.55;
color: var(--text);
}
.about-body a {
color: var(--fleet-soft);
text-decoration: underline;
overflow-wrap: anywhere;
}
.about-body a:hover {
color: var(--text);
}
.about-footer {
padding: 12px 18px 16px;
display: flex;
justify-content: flex-end;
border-top: 1px solid var(--divider);
}
/* ── Diagnostic banner (pre-bootstrap failure) ────────────────────── */
.error-banner {
margin: var(--section-pad-x);
padding: 14px 16px;
background: rgba(192, 142, 123, 0.08);
border: 1px solid var(--rose);
border-radius: 10px;
color: var(--rose);
font-size: 13px;
line-height: 19px;
}
.error-banner strong {
display: block;
margin-bottom: 4px;
color: var(--rose);
font-weight: 600;
}

View file

@ -1 +0,0 @@
/// <reference types="vite/client" />

View file

@ -1,144 +0,0 @@
// Minimal matrix-widget-api transport, implemented inline (same approach as
// apps/widget-telegram). This widget neither reads nor sends Matrix events — it
// only needs to (a) complete the host's capability handshake so the host's
// loading bar fades and `onReady` fires, (b) follow theme changes, and (c) post
// two Vojo-extension side-channel verbs: `add-to-chat` and `open-external-url`.
//
// Protocol shapes match
// node_modules/matrix-widget-api/lib/transport/PostmessageTransport.ts
// in the host repo. Default host request timeout is 10s.
import type { WidgetBootstrap } from './bootstrap';
type ToWidgetMessage = {
api: 'toWidget';
widgetId: string;
requestId: string;
action: string;
data: Record<string, unknown>;
response?: Record<string, unknown>;
};
export type Capability = string;
export type WidgetApiEvents = {
ready: () => void;
themeChange: (name: 'light' | 'dark') => void;
};
export class WidgetApi {
private readonly listeners: { [K in keyof WidgetApiEvents]?: Array<WidgetApiEvents[K]> } = {};
private isReady = false;
public constructor(
private readonly bootstrap: WidgetBootstrap,
private readonly capabilities: Capability[]
) {
window.addEventListener('message', this.onMessage);
}
public dispose(): void {
window.removeEventListener('message', this.onMessage);
}
public on<K extends keyof WidgetApiEvents>(event: K, listener: WidgetApiEvents[K]): void {
const list = (this.listeners[event] ??= []) as Array<WidgetApiEvents[K]>;
list.push(listener);
// `ready` is a one-shot lifecycle signal. If the handshake completed before
// this listener attached (cached-bundle race: host fires the capabilities
// request on iframe `load`, we resolve it during script init, then a
// post-render effect attaches the listener), replay synchronously.
if (event === 'ready' && this.isReady) {
(listener as () => void)();
}
}
// `add-to-chat` — ask the host to invite this bot into a room the USER picks.
// The host owns the picker and substitutes the invitee (`preset.mxid`); we
// send NO room and NO mxid. Distinct `io.vojo.bot-widget` channel — does not
// route through the matrix-widget-api request/response machinery.
public addToChat(): void {
window.parent.postMessage(
{ api: 'io.vojo.bot-widget', action: 'add-to-chat', data: {} },
this.bootstrap.parentOrigin
);
}
// Open an external URL via the host (cross-origin iframes drop
// `<a target="_blank">` clicks inside Capacitor's Android WebView; the host
// routes this through `openExternalUrl`). https only — the host re-validates.
public openExternalUrl(url: string): void {
window.parent.postMessage(
{ api: 'io.vojo.bot-widget', action: 'open-external-url', data: { url } },
this.bootstrap.parentOrigin
);
}
private emit<K extends keyof WidgetApiEvents>(
event: K,
...args: Parameters<WidgetApiEvents[K]>
): void {
const list = this.listeners[event] as
| Array<(...a: Parameters<WidgetApiEvents[K]>) => void>
| undefined;
list?.forEach((fn) => fn(...args));
}
private replyTo(msg: ToWidgetMessage, response: Record<string, unknown>): void {
window.parent.postMessage(
{
api: msg.api,
widgetId: msg.widgetId,
requestId: msg.requestId,
action: msg.action,
data: msg.data,
response,
},
this.bootstrap.parentOrigin
);
}
private onMessage = (ev: MessageEvent): void => {
if (ev.origin !== this.bootstrap.parentOrigin) return;
// Hard source guard: every legit widget-API message comes from the host
// window that embedded our iframe (window.parent). A foreign tab/frame on
// the same origin could otherwise forge a message that passes the origin
// check — `widgetId` below is a soft filter, this is the hard one.
if (ev.source !== window.parent) return;
const msg = ev.data as ToWidgetMessage | undefined;
if (!msg || typeof msg !== 'object') return;
if (msg.api !== 'toWidget') return;
if (msg.widgetId !== this.bootstrap.widgetId) return;
if (!msg.requestId || !msg.action) return;
switch (msg.action) {
case 'capabilities': {
this.replyTo(msg, { capabilities: this.capabilities });
return;
}
case 'notify_capabilities': {
this.replyTo(msg, {});
if (!this.isReady) {
this.isReady = true;
this.emit('ready');
}
return;
}
case 'supported_api_versions': {
this.replyTo(msg, { supported_versions: ['0.0.2', 'org.matrix.msc2762'] });
return;
}
case 'theme_change': {
const name = (msg.data?.name as string | undefined) ?? '';
this.emit('themeChange', name === 'dark' ? 'dark' : 'light');
this.replyTo(msg, {});
return;
}
default: {
// Be liberal — reply empty so the host's request promise resolves.
this.replyTo(msg, {});
}
}
};
}

View file

@ -1,21 +0,0 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "Bundler",
"lib": ["ES2020", "DOM", "DOM.Iterable"],
"jsx": "react-jsx",
"jsxImportSource": "preact",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"noFallthroughCasesInSwitch": true,
"skipLibCheck": true,
"isolatedModules": true,
"esModuleInterop": true,
"resolveJsonModule": true,
"allowSyntheticDefaultImports": true,
"useDefineForClassFields": true
},
"include": ["src", "vite.config.ts"]
}

View file

@ -1,25 +0,0 @@
import { defineConfig } from 'vite';
import preact from '@preact/preset-vite';
// Build artefact lives at apps/widget-vojo-ai/dist/. The «Deploy widgets» task
// rsyncs it into ~/vojo/widgets/vojo-ai/ on the server, which Caddy serves from
// the widgets.vojo.chat block at /vojo-ai/ (mirror of the telegram widget).
//
// `base: './'` keeps every generated asset path relative so the same build can
// sit under /vojo-ai/ on widgets.vojo.chat without rewrites.
export default defineConfig({
base: './',
plugins: [preact()],
build: {
target: 'es2020',
sourcemap: true,
// Inline CSS for a single round-trip; the widget is tiny and the host's
// iframe handshake budget is already tight (10s default).
cssCodeSplit: false,
},
server: {
// 8081 telegram / 8082 discord / 8083 whatsapp / 8084 vojo-ai.
port: 8084,
host: true,
},
});

View file

@ -49,9 +49,7 @@
"mxid": "@ai:vojo.chat", "mxid": "@ai:vojo.chat",
"name": "Vojo AI", "name": "Vojo AI",
"experience": { "experience": {
"type": "matrix-widget", "type": "ai-chat"
"url": "https://widgets.vojo.chat/vojo-ai/index.html",
"capabilities": ["vojo.add_to_chat"]
} }
} }
], ],

View file

@ -15,9 +15,28 @@ Synapse pushes a transaction → the bot **acks 200 instantly, then processes as
([appservice.go](../../apps/ai-bot/appservice.go)), so a slow model call never blocks other ([appservice.go](../../apps/ai-bot/appservice.go)), so a slow model call never blocks other
rooms or the homeserver. `handleMessage` ([bot.go](../../apps/ai-bot/bot.go)) gates in order: rooms or the homeserver. `handleMessage` ([bot.go](../../apps/ai-bot/bot.go)) gates in order:
durable+in-memory dedup → encrypted-room skip → decode / edit / own-message / notice → durable+in-memory dedup → encrypted-room skip → decode / edit / own-message / notice →
foreign-server leave → DM-or-mention → media react → **per-room single-flight** → spawn foreign-server leave → DM-or-mention → media react → resolve conversation (thread) →
`respond`. `respond` = `Reserve(estimate)``generate()``Settle(actual)``sendReply`; **per-(room,thread) single-flight** → spawn `respond`. `respond` = `Reserve(estimate)`
**any failure produces an emoji react, never silence.** `generate()``Settle(actual)``sendReply`; **any failure produces an emoji react, never silence.**
## Conversations (threads) — ChatGPT-style multi-chat
In a 1:1 DM a top-level message **roots a new thread** (a fresh conversation) and the bot answers
inside it ([bot.go](../../apps/ai-bot/bot.go) `resolveThreadRoot`); a message already in a thread
continues it (F27). **Groups are never auto-threaded** — the gate is structural (`isDM`), not a
flag, so the threading feature can never change group behavior. Auto-threading in DMs is **always
on** (the old `THREAD_CONVERSATIONS` env flag was removed — it only created a host/backend
mismatch footgun). Context and single-flight are keyed per-`(room, thread)` so conversations
neither share history nor block each other; typing is room-level (Matrix has no per-thread typing)
via a refcount; per-thread context buffers are LRU-bounded (`maxConvBuffersPerRoom`).
**Host pairing:** the cinny host shows the conversation surface for a bot only when its
`config.json` preset has `experience.type: "ai-chat"` (today only `@ai`). That surface is a
**fully isolated, native in-client chat** (`features/bots/BotConversations` + `AiChatHeader` +
`AiChatMenu`, reusing the generic `ThreadDrawer`/`RoomInput`) — it shares **no** runtime with the
bridge widget pipeline (no `BotShell`/iframe, no `show-chat` toggle; there is no `vojo-ai` widget
any more). Bridges keep `experience.type: "matrix-widget"` (iframe + show-chat fallback). Because
the backend now always threads DMs, any DM message @ai answers lands in a thread the host can open.
## Cascade (flag-gated "operator cascade", every layer default OFF) ## Cascade (flag-gated "operator cascade", every layer default OFF)
@ -51,7 +70,7 @@ adapters [provider_xai.go](../../apps/ai-bot/provider_xai.go) /
a panic releases via a deferred guard. a panic releases via a deferred guard.
- Caps: `DAILY_USD_CEILING` (global $), `PER_USER_DAILY_CAP` (requests/user), `PER_USER_DAILY_USD` - Caps: `DAILY_USD_CEILING` (global $), `PER_USER_DAILY_CAP` (requests/user), `PER_USER_DAILY_USD`
(optional $/user). **at-most-once** dedup is durable (`SeenEvent`/`MarkTxn`); generation is (optional $/user). **at-most-once** dedup is durable (`SeenEvent`/`MarkTxn`); generation is
per-room single-flight. per-(room,thread) single-flight.
- One overall **per-request deadline** bounds the whole cascade (no per-stage 3×60s accretion). - One overall **per-request deadline** bounds the whole cascade (no per-stage 3×60s accretion).
- **Telemetry:** one `request_log` row per engaged request (route, per-component $, latency, - **Telemetry:** one `request_log` row per engaged request (route, per-component $, latency,
degrade reasons), written async + isolated (its failure never drops a reply), `TELEMETRY_ENABLED` degrade reasons), written async + isolated (its failure never drops a reply), `TELEMETRY_ENABLED`

View file

@ -942,6 +942,17 @@
"show_widget": "Show robot", "show_widget": "Show robot",
"retry_widget": "Retry robot", "retry_widget": "Retry robot",
"more_options": "More", "more_options": "More",
"conversations": {
"title": "Chats",
"new_chat": "New chat",
"empty": "No conversations yet.",
"start_first": "Start your first chat",
"untitled": "Untitled chat",
"back": "Back to chats",
"new_chat_hint": "Ask anything to start a new conversation.",
"composer_placeholder": "Message Vojo AI…",
"send": "Send"
},
"description": { "description": {
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.", "telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.", "discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",

View file

@ -960,6 +960,17 @@
"show_widget": "Показать робота", "show_widget": "Показать робота",
"retry_widget": "Повторить", "retry_widget": "Повторить",
"more_options": "Ещё", "more_options": "Ещё",
"conversations": {
"title": "Чаты",
"new_chat": "Новый чат",
"empty": "Пока нет бесед.",
"start_first": "Начать первый чат",
"untitled": "Без названия",
"back": "К списку чатов",
"new_chat_hint": "Спросите что угодно, чтобы начать новую беседу.",
"composer_placeholder": "Сообщение для Vojo AI…",
"send": "Отправить"
},
"description": { "description": {
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.", "telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.", "discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",

View file

@ -0,0 +1,142 @@
import React, { useState } from 'react';
import type { Room } from 'matrix-js-sdk';
import { Icon, IconButton, Icons, PopOut, RectCords, Tooltip, TooltipProvider, Text } from 'folds';
import FocusTrap from 'focus-trap-react';
import { useTranslation } from 'react-i18next';
import type { BotPreset } from './catalog';
import { AiChatMenu } from './AiChatMenu';
import { BackRouteHandler } from '../../components/BackRouteHandler';
import { ScreenSize, useScreenSizeContext } from '../../hooks/useScreenSize';
import { stopPropagation } from '../../utils/keyboard';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useMediaAuthentication } from '../../hooks/useMediaAuthentication';
import { mxcUrlToHttp } from '../../utils/matrix';
// Reuse the bridge hero's EXACT styles so the AI header is pixel-identical to every other bot
// header (avatar 56px, name 22px, handle, description, mobile back chevron). The hero markup
// below mirrors BotShellHero verbatim — keep them in sync. Only the ⋮ menu differs (AiChatMenu).
import * as css from './BotShell.css';
// Initial for the avatar block — same rule as BotShellHero.
const heroInitial = (preset: BotPreset): string => {
const fromName = preset.name.trim().charAt(0);
if (fromName) return fromName.toUpperCase();
const local = preset.mxid.split(':')[0].replace('@', '');
return local.charAt(0).toUpperCase() || '?';
};
type AiChatHeaderProps = {
preset: BotPreset;
room: Room;
onNewChat: () => void;
onOpenHistory: () => void;
};
// Dedicated header for the native AI conversation surface. Renders the SAME hero as BotShellHero
// (shared BotShell.css), but is fully decoupled from the widget pipeline: its ⋮ opens AiChatMenu
// (New chat / History / generic room actions) — no "Show chat", no widget toggle, no BotShellMenu.
export function AiChatHeader({ preset, room, onNewChat, onOpenHistory }: AiChatHeaderProps) {
const { t } = useTranslation();
const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication();
const screenSize = useScreenSizeContext();
const isMobile = screenSize === ScreenSize.Mobile;
const [menuAnchor, setMenuAnchor] = useState<RectCords>();
const avatarMxc = room.getMember(preset.mxid)?.getMxcAvatarUrl();
const avatarUrl = avatarMxc
? mxcUrlToHttp(mx, avatarMxc, useAuthentication, 96, 96, 'crop') ?? undefined
: undefined;
const handleOpenMenu: React.MouseEventHandler<HTMLButtonElement> = (evt) => {
setMenuAnchor(evt.currentTarget.getBoundingClientRect());
};
const description = t(`Bots.description.${preset.id}`, {
defaultValue: preset.description ?? '',
});
const descriptionShort = t(`Bots.description_short.${preset.id}`, {
defaultValue: description,
});
const initial = heroInitial(preset);
return (
<header className={css.Hero}>
<div className={css.HeroInner}>
{isMobile && (
<BackRouteHandler>
{(onBack) => (
<IconButton
className={css.HeroBack}
fill="None"
onClick={onBack}
aria-label={t('Room.close')}
>
<Icon src={Icons.ChevronLeft} />
</IconButton>
)}
</BackRouteHandler>
)}
<div className={css.HeroAvatar} aria-hidden="true">
{avatarUrl ? <img className={css.HeroAvatarImg} src={avatarUrl} alt="" /> : initial}
</div>
<div className={css.HeroBody}>
<div className={css.HeroTitleRow}>
<span className={css.HeroName}>{preset.name}</span>
<span className={css.HeroHandle}>{preset.mxid}</span>
</div>
{description ? <p className={css.HeroDescription}>{description}</p> : null}
{descriptionShort ? <p className={css.HeroDescriptionShort}>{descriptionShort}</p> : null}
</div>
<TooltipProvider
position="Bottom"
align="End"
offset={4}
tooltip={
<Tooltip>
<Text>{t('Bots.more_options')}</Text>
</Tooltip>
}
>
{(triggerRef) => (
<IconButton
fill="None"
ref={triggerRef}
onClick={handleOpenMenu}
aria-pressed={!!menuAnchor}
aria-label={t('Bots.more_options')}
>
<Icon src={Icons.VerticalDots} filled={!!menuAnchor} />
</IconButton>
)}
</TooltipProvider>
</div>
<PopOut
anchor={menuAnchor}
position="Bottom"
align="End"
content={
<FocusTrap
focusTrapOptions={{
initialFocus: false,
returnFocusOnDeactivate: false,
onDeactivate: () => setMenuAnchor(undefined),
clickOutsideDeactivates: true,
isKeyForward: (evt: KeyboardEvent) => evt.key === 'ArrowDown',
isKeyBackward: (evt: KeyboardEvent) => evt.key === 'ArrowUp',
escapeDeactivates: stopPropagation,
}}
>
<AiChatMenu
room={room}
requestClose={() => setMenuAnchor(undefined)}
onNewChat={onNewChat}
onOpenHistory={onOpenHistory}
/>
</FocusTrap>
}
/>
</header>
);
}

View file

@ -0,0 +1,140 @@
import React, { forwardRef } from 'react';
import { Box, Icon, Icons, Line, Menu, MenuItem, Spinner, Text, config, toRem } from 'folds';
import type { Room } from 'matrix-js-sdk';
import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient';
import { useSetting } from '../../state/hooks/settings';
import { settingsAtom } from '../../state/settings';
import { useRoomUnread } from '../../state/hooks/unread';
import { roomToUnreadAtom } from '../../state/room/roomToUnread';
import {
getRoomNotificationMode,
getRoomNotificationModeIcon,
useRoomsNotificationPreferencesContext,
} from '../../hooks/useRoomsNotificationPreferences';
import { RoomNotificationModeSwitcher } from '../../components/RoomNotificationSwitcher';
import { LeaveRoomPrompt } from '../../components/leave-room-prompt';
import { UseStateProvider } from '../../components/UseStateProvider';
import { markAsRead } from '../../utils/notifications';
type AiChatMenuProps = {
room: Room;
requestClose: () => void;
onNewChat: () => void;
onOpenHistory: () => void;
};
// The native AI conversation surface's ⋮ menu. Deliberately NOT BotShellMenu: there is no widget
// here, so there is no "Show chat" toggle (it would be meaningless). It carries the conversation
// actions (New chat / History) plus the generic room actions (mark read / notifications / leave)
// that apply to any DM — reusing the same shared switcher / leave-prompt primitives so behaviour
// can't drift from the rest of the app.
export const AiChatMenu = forwardRef<HTMLDivElement, AiChatMenuProps>(
({ room, requestClose, onNewChat, onOpenHistory }, ref) => {
const { t } = useTranslation();
const mx = useMatrixClient();
const [hideActivity] = useSetting(settingsAtom, 'hideActivity');
const unread = useRoomUnread(room.roomId, roomToUnreadAtom);
const notificationPreferences = useRoomsNotificationPreferencesContext();
const notificationMode = getRoomNotificationMode(notificationPreferences, room.roomId);
const handleMarkAsRead = () => {
markAsRead(mx, room.roomId, hideActivity);
requestClose();
};
return (
<Menu ref={ref} style={{ maxWidth: toRem(240), width: '100vw' }}>
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<MenuItem
onClick={() => {
onNewChat();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.Plus} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.conversations.new_chat')}
</Text>
</MenuItem>
<MenuItem
onClick={() => {
onOpenHistory();
requestClose();
}}
size="300"
after={<Icon size="100" src={Icons.RecentClock} />}
radii="300"
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Bots.conversations.title')}
</Text>
</MenuItem>
<MenuItem
onClick={handleMarkAsRead}
size="300"
after={<Icon size="100" src={Icons.CheckTwice} />}
radii="300"
disabled={!unread}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.mark_as_read')}
</Text>
</MenuItem>
<RoomNotificationModeSwitcher roomId={room.roomId} value={notificationMode}>
{(handleOpen, opened, changing) => (
<MenuItem
size="300"
after={
changing ? (
<Spinner size="100" variant="Secondary" />
) : (
<Icon size="100" src={getRoomNotificationModeIcon(notificationMode)} />
)
}
radii="300"
aria-pressed={opened}
onClick={handleOpen}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.notifications')}
</Text>
</MenuItem>
)}
</RoomNotificationModeSwitcher>
</Box>
<Line variant="Surface" size="300" />
<Box direction="Column" gap="100" style={{ padding: config.space.S100 }}>
<UseStateProvider initial={false}>
{(promptLeave, setPromptLeave) => (
<>
<MenuItem
onClick={() => setPromptLeave(true)}
variant="Critical"
fill="None"
size="300"
after={<Icon size="100" src={Icons.ArrowGoLeft} />}
radii="300"
aria-pressed={promptLeave}
>
<Text style={{ flexGrow: 1 }} as="span" size="T300" truncate>
{t('Room.leave_room')}
</Text>
</MenuItem>
{promptLeave && (
<LeaveRoomPrompt
roomId={room.roomId}
onDone={requestClose}
onCancel={() => setPromptLeave(false)}
/>
)}
</>
)}
</UseStateProvider>
</Box>
</Menu>
);
}
);

View file

@ -0,0 +1,172 @@
import { style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds';
// ChatGPT-style conversation surface for assistant bots. A slim top bar (bot name + "New chat"
// + history toggle) sits above a body that holds the active conversation / new-chat composer,
// with a half-window history panel that slides in from the LEFT over it. Works identically on
// desktop and mobile (single column; the panel is an overlay, not a second pane).
export const Surface = style({
height: '100%',
width: '100%',
display: 'flex',
flexDirection: 'column',
backgroundColor: color.SurfaceVariant.Container,
// Native safe-top: `#root` no longer reserves the status-bar inset (src/index.css), so the
// surface extends to the screen top and the top bar clears the system icons. Web → 0.
paddingTop: 'var(--vojo-safe-top, 0px)',
});
export const Body = style({
position: 'relative',
flexGrow: 1,
minHeight: 0,
overflow: 'hidden',
display: 'flex',
});
export const Main = style({
flexGrow: 1,
minWidth: 0,
height: '100%',
display: 'flex',
flexDirection: 'column',
});
// Half-window history panel, anchored to the RIGHT edge of the body (the left is the hero's
// back affordance), slid off-screen until toggled. Sits above the Main content (zIndex 2) over
// a click-to-close Backdrop (zIndex 1).
export const History = style({
position: 'absolute',
insetBlock: 0,
right: 0,
zIndex: 2,
width: '50%',
maxWidth: toRem(420),
minWidth: toRem(260),
display: 'flex',
flexDirection: 'column',
backgroundColor: color.Surface.Container,
borderLeft: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
transform: 'translateX(100%)',
transition: 'transform 200ms ease',
selectors: {
'&[data-open]': { transform: 'translateX(0)' },
},
'@media': {
'(prefers-reduced-motion: reduce)': { transition: 'none' },
'(max-width: 600px)': { width: '85%', maxWidth: 'none' },
},
});
export const HistoryHeader = style({
flexShrink: 0,
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
padding: `${config.space.S200} ${config.space.S200} ${config.space.S200} ${config.space.S400}`,
borderBottom: `${config.borderWidth.B300} solid ${color.Surface.ContainerLine}`,
});
export const HistoryTitle = style({
flexGrow: 1,
minWidth: 0,
fontWeight: 600,
});
export const HistoryList = style({
flexGrow: 1,
minHeight: 0,
overflowY: 'auto',
padding: config.space.S200,
display: 'flex',
flexDirection: 'column',
gap: config.space.S100,
});
export const Backdrop = style({
position: 'absolute',
inset: 0,
zIndex: 1,
border: 'none',
padding: 0,
margin: 0,
appearance: 'none',
cursor: 'default',
backgroundColor: 'rgba(0, 0, 0, 0.4)',
});
export const Row = style({
display: 'flex',
alignItems: 'center',
gap: config.space.S200,
width: '100%',
padding: config.space.S300,
borderRadius: config.radii.R400,
border: 'none',
background: 'transparent',
color: color.Surface.OnContainer,
cursor: 'pointer',
textAlign: 'left',
appearance: 'none',
font: 'inherit',
minWidth: 0,
selectors: {
'&:hover': { backgroundColor: color.Surface.ContainerHover },
'&[data-active]': { backgroundColor: color.Surface.ContainerActive },
'&:focus-visible': {
outline: `${config.borderWidth.B400} solid ${color.Primary.Main}`,
outlineOffset: toRem(1),
backgroundColor: color.Surface.ContainerHover,
},
},
});
export const RowBody = style({
flexGrow: 1,
minWidth: 0,
});
export const RowTitle = style({
display: 'block',
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
});
export const Empty = style({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: config.space.S700,
textAlign: 'center',
});
// New-chat composer: fills the Main column, composer pinned to the bottom like a chat.
export const NewChat = style({
flexGrow: 1,
minHeight: 0,
display: 'flex',
flexDirection: 'column',
});
export const NewChatHint = style({
flexGrow: 1,
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
gap: config.space.S200,
padding: config.space.S700,
textAlign: 'center',
});
// Wrapper for the standard RoomInput in the new-chat view. Mirrors ThreadDrawer's ThreadComposer
// geometry (the `ChatComposer` marker class applied alongside carries the rounded-card chrome via
// globalStyle in RoomView.css), so the new-chat composer looks identical to the in-conversation one.
export const ComposerWrap = style({
flexShrink: 0,
padding: `0 ${config.space.S400} ${config.space.S400}`,
});

View file

@ -0,0 +1,184 @@
import React, { useCallback, useEffect, useRef, useState } from 'react';
import { useNavigate } from 'react-router-dom';
import { useTranslation } from 'react-i18next';
import { Icon, IconButton, Icons, Text } from 'folds';
import { Room } from 'matrix-js-sdk';
import { PowerLevelsContextProvider, usePowerLevels } from '../../hooks/usePowerLevels';
import { useEditor } from '../../components/editor';
import { ThreadDrawer } from '../room/ThreadDrawer';
import { RoomInput } from '../room/RoomInput';
import { ChatComposer } from '../room/RoomView.css';
import { getBotPath, getBotThreadPath } from '../../pages/pathUtils';
import { useBotConversations } from './useBotConversations';
import { AiChatHeader } from './AiChatHeader';
import type { BotPreset } from './catalog';
import * as css from './BotConversations.css';
type BotConversationsProps = {
preset: BotPreset;
room: Room;
// The open conversation's thread root, from /bots/:botId/thread/:rootId. Absent → the
// new-chat composer (the default landing — every open of the bot starts a fresh chat).
rootId?: string;
};
// New-chat composer — the default view when no conversation is open. It mounts the STANDARD
// RoomInput (markdown / mentions / uploads / commands), sending TOP-LEVEL (threadId undefined).
// The always-on backend roots a thread on that first message and answers inside, so we navigate
// into the freshly-rooted thread the moment the send resolves — the just-sent event id IS the
// new conversation's root, delivered via RoomInput's onSend callback.
function NewChatComposer({ preset, room }: { preset: BotPreset; room: Room }) {
const { t } = useTranslation();
const navigate = useNavigate();
const editor = useEditor();
const fileDropRef = useRef<HTMLDivElement>(null);
// Navigate exactly once: a fast double-send (Enter + click) must not double-navigate.
const navigatedRef = useRef(false);
const handleSend = useCallback(
(eventId: string) => {
if (navigatedRef.current) return;
navigatedRef.current = true;
navigate(getBotThreadPath(preset.id, eventId));
},
[navigate, preset.id]
);
return (
<div className={css.NewChat}>
<div className={css.NewChatHint}>
<Text size="T300" priority="400" align="Center">
{t('Bots.conversations.new_chat_hint')}
</Text>
</div>
<div ref={fileDropRef} className={`${css.ComposerWrap} ${ChatComposer}`}>
<RoomInput
room={room}
editor={editor}
roomId={room.roomId}
fileDropContainerRef={fileDropRef}
onSend={handleSend}
/>
</div>
</div>
);
}
// ChatGPT-style conversation surface for an assistant bot's control DM. The bot hero (avatar /
// name / handle / description + ⋮ menu, which carries the "show raw chat" escape hatch) tops the
// surface, with a leading back-chevron and trailing "New chat" + "History" controls embedded in
// it. Opening the bot lands on a fresh new-chat composer; sending roots a thread and opens it
// (the reused ThreadDrawer in `assistant` style — full-width bot text, user bubbles, no avatars).
// Past chats live in a half-window panel that slides in from the RIGHT. Reached only for
// `experience.type === 'ai-chat'` bots (BotExperienceRoute decides); bridge bots never get here.
export function BotConversations({ preset, room, rootId }: BotConversationsProps) {
const { t } = useTranslation();
const navigate = useNavigate();
const conversations = useBotConversations(room);
// ThreadDrawer + the new-chat RoomInput both read usePowerLevelsContext; the bot surface has
// no provider of its own (Room.tsx supplies one in channels), so wrap the whole surface here.
// RoomProvider + IsOneOnOneProvider come from BotRoomProvider above.
const powerLevels = usePowerLevels(room);
const [historyOpen, setHistoryOpen] = useState(false);
// Close the history panel whenever the route changes (a chat was picked, or "New chat" was
// tapped), so the panel slides away and reveals the chosen surface.
useEffect(() => {
setHistoryOpen(false);
}, [rootId]);
const goNewChat = useCallback(() => {
setHistoryOpen(false);
navigate(getBotPath(preset.id));
}, [navigate, preset.id]);
const openConversation = useCallback(
(rid: string) => navigate(getBotThreadPath(preset.id, rid)),
[navigate, preset.id]
);
return (
<PowerLevelsContextProvider value={powerLevels}>
<div className={css.Surface}>
<AiChatHeader
preset={preset}
room={room}
onNewChat={goNewChat}
onOpenHistory={() => setHistoryOpen(true)}
/>
{/* Reset --vojo-safe-top for everything below the hero: css.Surface already paid the
status-bar inset for the whole surface, and the reused ThreadDrawerMobile re-applies
`padding-top: var(--vojo-safe-top)` (it was written for the /channels case where the
drawer sits at the screen top with no header above it). Without this reset that inset
double-counts on native and paints an empty band under the header. Mirrors Modal500 /
MobileMediaViewerHorseshoe / ChannelsWorkspaceHorseshoe. */}
<div className={css.Body} style={{ ['--vojo-safe-top' as string]: '0px' }}>
{historyOpen && (
<button
type="button"
className={css.Backdrop}
aria-label={t('Bots.conversations.back')}
onClick={() => setHistoryOpen(false)}
/>
)}
<aside className={css.History} data-open={historyOpen || undefined}>
<div className={css.HistoryHeader}>
<Text className={css.HistoryTitle} size="H4" truncate>
{t('Bots.conversations.title')}
</Text>
<IconButton
size="300"
variant="Background"
onClick={() => setHistoryOpen(false)}
aria-label={t('Bots.conversations.back')}
>
<Icon src={Icons.Cross} />
</IconButton>
</div>
<div className={css.HistoryList}>
{conversations.length === 0 ? (
<div className={css.Empty}>
<Text size="T300" priority="400" align="Center">
{t('Bots.conversations.empty')}
</Text>
</div>
) : (
conversations.map((conversation) => (
<button
key={conversation.rootId}
type="button"
className={css.Row}
data-active={conversation.rootId === rootId || undefined}
onClick={() => openConversation(conversation.rootId)}
>
<Icon size="100" src={Icons.Message} />
<span className={css.RowBody}>
<Text className={css.RowTitle} as="span" size="T300">
{conversation.title || t('Bots.conversations.untitled')}
</Text>
</span>
</button>
))
)}
</div>
</aside>
<div className={css.Main}>
{rootId ? (
<ThreadDrawer
key={`${room.roomId}/${rootId}`}
room={room}
rootId={rootId}
parentRoomPath={getBotPath(preset.id)}
variant="mobile"
hideHeader
messageStyle="assistant"
/>
) : (
<NewChatComposer preset={preset} room={room} />
)}
</div>
</div>
</div>
</PowerLevelsContextProvider>
);
}

View file

@ -19,7 +19,7 @@ import {
import { Theme } from '../../hooks/useTheme'; import { Theme } from '../../hooks/useTheme';
import { openExternalUrl } from '../../utils/capacitor'; import { openExternalUrl } from '../../utils/capacitor';
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to'; import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog'; import { BOT_CAP_ADD_TO_CHAT, isWidgetExperience, type BotPreset } from './catalog';
import { import {
BotWidgetDriver, BotWidgetDriver,
sanitizeBotWidgetMessageEvent, sanitizeBotWidgetMessageEvent,
@ -61,7 +61,8 @@ const getBotWidgetUrl = (
language: string, language: string,
widgetId: string widgetId: string
): string => { ): string => {
if (!preset.experience) throw new Error('Bot widget experience is not configured'); if (!isWidgetExperience(preset.experience))
throw new Error('Bot widget experience is not configured');
const url = new URL(preset.experience.url, window.location.origin); const url = new URL(preset.experience.url, window.location.origin);
url.searchParams.set('widgetId', widgetId); url.searchParams.set('widgetId', widgetId);
@ -306,7 +307,9 @@ export class BotWidgetEmbed {
// capability from the trusted config (`preset.experience.capabilities`), // capability from the trusted config (`preset.experience.capabilities`),
// never from the widget message; the invitee is `preset.mxid`, host-set. // never from the widget message; the invitee is `preset.mxid`, host-set.
if (msg.action === 'add-to-chat') { if (msg.action === 'add-to-chat') {
if (!this.options.preset.experience?.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) return; const { experience } = this.options.preset;
if (!isWidgetExperience(experience) || !experience.capabilities.includes(BOT_CAP_ADD_TO_CHAT))
return;
this.options.onAddToChat?.(); this.options.onAddToChat?.();
return; return;
} }

View file

@ -2,7 +2,14 @@ import { useMemo } from 'react';
import { useClientConfig } from '../../hooks/useClientConfig'; import { useClientConfig } from '../../hooks/useClientConfig';
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig'; import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
export type BotExperience = { // A bot's experience is a discriminated union on `type`:
// - 'matrix-widget' — a sandboxed iframe widget (the bridge bots). Carries the widget url, the
// command prefix, and the elevated-capability allowlist.
// - 'ai-chat' — the native, fully in-client ChatGPT-style conversation surface (AiBotChat). No
// widget, so no url/capabilities to carry or validate. Only first-party assistant bots use it.
// The two experiences share NO runtime pipeline: 'ai-chat' never touches BotShell / the widget
// embed / the show-chat toggle, and 'matrix-widget' never touches the conversation surface.
export type BotWidgetExperience = {
type: 'matrix-widget'; type: 'matrix-widget';
url: string; url: string;
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`). /** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
@ -15,6 +22,22 @@ export type BotExperience = {
capabilities: string[]; capabilities: string[];
}; };
export type AiChatExperience = {
type: 'ai-chat';
};
export type BotExperience = BotWidgetExperience | AiChatExperience;
/** True when the bot is rendered through the sandboxed iframe widget pipeline (bridges). */
export const isWidgetExperience = (
experience: BotExperience | undefined
): experience is BotWidgetExperience => experience?.type === 'matrix-widget';
/** True when the bot uses the native in-client conversation surface (no widget). */
export const isAiChatExperience = (
experience: BotExperience | undefined
): experience is AiChatExperience => experience?.type === 'ai-chat';
/** The only elevated side-channel verb today: lets the widget ask the host to /** The only elevated side-channel verb today: lets the widget ask the host to
* invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The * invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The
* widget never supplies the room or the mxid. Privilege originates from the * widget never supplies the room or the mxid. Privilege originates from the
@ -94,6 +117,8 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => { const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
const type = experience?.type; const type = experience?.type;
// Native AI chat: no widget url / capabilities to validate, rendered fully in-client.
if (type === 'ai-chat') return { type: 'ai-chat' };
const url = experience?.url?.trim(); const url = experience?.url?.trim();
if (type !== 'matrix-widget' || !url) return undefined; if (type !== 'matrix-widget' || !url) return undefined;
if (url.startsWith('//')) return undefined; if (url.startsWith('//')) return undefined;

View file

@ -0,0 +1,99 @@
import { useEffect, useMemo, useState } from 'react';
import { MsgType, Room, ThreadEvent } from 'matrix-js-sdk';
import { trimReplyFromBody } from '../../utils/room';
import { useMatrixClient } from '../../hooks/useMatrixClient';
// One past conversation in an assistant bot's control DM. A conversation IS an m.thread
// root: the bot threads every top-level DM turn, so each chat is a thread the server
// enumerates for free via /rooms/{id}/threads.
export type BotConversation = {
rootId: string;
// First non-empty line of the thread root's body, reply-fallback stripped. "" when the
// root isn't loaded yet or is media-only — the caller renders a localized placeholder.
title: string;
// Last-activity timestamp (latest reply, else the root), for recency ordering.
ts: number;
};
// Derive a conversation title from its thread root WITHOUT storing anything: the title is
// the root message text (the §4.4 "root-text now" tier). A future account_data override
// (io.vojo.thread_titles) would layer on top of this at the call site.
const deriveTitle = (room: Room, rootId: string): string => {
const rootEvent = room.getThread(rootId)?.rootEvent ?? room.findEventById(rootId);
const content = rootEvent?.getContent();
if (!content) return '';
// Only text roots make a sensible title. For m.image/m.file/m.video the body is the
// FILENAME (a non-empty string), so without this gate the list would leak "photo.png" as
// a chat title; return "" so the caller shows the localized fallback instead. (The bot
// never auto-threads media — it reacts "text only" first — but a manually-created media
// thread in the same DM would otherwise surface here.)
if (content.msgtype !== MsgType.Text && content.msgtype !== MsgType.Emote) return '';
const { body } = content;
if (typeof body !== 'string') return '';
const firstLine = trimReplyFromBody(body)
.split('\n')
.find((line) => line.trim() !== '');
return firstLine?.trim() ?? '';
};
// useBotConversations lists the control DM's conversations (thread roots), most-recent
// first, and re-renders as threads are created or get new replies. It triggers a one-shot
// server thread-list load (room.fetchRoomThreads) so cold conversations show after a
// reload without opening each thread. Read-only — never mutates room state.
export const useBotConversations = (room: Room): BotConversation[] => {
const mx = useMatrixClient();
const myUserId = mx.getUserId();
const [tick, setTick] = useState(0);
useEffect(() => {
let cancelled = false;
const bump = () => {
if (!cancelled) setTick((n) => n + 1);
};
// Populate room.getThreads() from the server once; getThreads() then reflects it.
// Best-effort — a failure just leaves the list at whatever /sync already materialized.
room
.fetchRoomThreads()
.catch(() => undefined)
.finally(bump);
// ThreadEvent.New fires on the ROOM when a brand-new conversation roots (the SDK
// excludes it from a thread's own emitter). ThreadEvent.Update is re-emitted onto the
// room from each thread on every reply / latest-event change, so it covers both "a
// reply landed" and the recency re-sort — we no longer also listen on RoomEvent.Timeline
// (which fired an extra bump per reply plus one for every unrelated main-timeline event).
room.on(ThreadEvent.New, bump);
room.on(ThreadEvent.Update, bump);
return () => {
cancelled = true;
room.off(ThreadEvent.New, bump);
room.off(ThreadEvent.Update, bump);
};
}, [room]);
return useMemo(() => {
const conversations = room
.getThreads()
// Hide a not-yet-answered conversation from the list: a brand-new chat is the user's own
// top-level message that the bot roots a thread on and answers a moment later. Until that
// reply lands (or if the bot failed to answer at all), the thread has zero replies and a
// self-authored root. Dropping those keeps stale/empty chats out of the history; a real
// conversation reappears the instant the bot's threaded reply arrives — and at the moment
// a chat is created the user is INSIDE it, not viewing this list. rootEvent unknown ⇒ keep.
.filter((thread) => !(thread.length === 0 && thread.rootEvent?.getSender() === myUserId))
.map<BotConversation>((thread) => {
const last = thread.replyToEvent ?? thread.rootEvent;
return {
rootId: thread.id,
title: deriveTitle(room, thread.id),
ts: last?.getTs() ?? 0,
};
});
conversations.sort((a, b) => b.ts - a.ts);
return conversations;
// `tick` is the reactivity trigger (room.getThreads() mutates in place); `room` and
// `myUserId` are stable for the lifetime of this surface.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [room, tick]);
};

View file

@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
import { useMatrixClient } from '../../hooks/useMatrixClient'; import { useMatrixClient } from '../../hooks/useMatrixClient';
import { Theme, useTheme } from '../../hooks/useTheme'; import { Theme, useTheme } from '../../hooks/useTheme';
import type { MatrixToRoom } from '../../plugins/matrix-to'; import type { MatrixToRoom } from '../../plugins/matrix-to';
import type { BotPreset } from './catalog'; import { isWidgetExperience, type BotPreset } from './catalog';
import { BotWidgetEmbed } from './BotWidgetEmbed'; import { BotWidgetEmbed } from './BotWidgetEmbed';
type UseBotWidgetEmbedOptions = { type UseBotWidgetEmbedOptions = {
@ -69,7 +69,7 @@ export const useBotWidgetEmbed = ({
// every time `useBotPresets` returns a fresh memo, e.g. after a runtime // every time `useBotPresets` returns a fresh memo, e.g. after a runtime
// config refresh. // config refresh.
const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset; const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset;
const experienceUrl = experience?.url; const experienceUrl = isWidgetExperience(experience) ? experience.url : undefined;
const experienceType = experience?.type; const experienceType = experience?.type;
const { roomId } = room; const { roomId } = room;

View file

@ -199,9 +199,14 @@ interface RoomInputProps {
// composers leave this undefined so messages land in the main timeline // composers leave this undefined so messages land in the main timeline
// unchanged. The drawer composer passes threadId={rootId}. // unchanged. The drawer composer passes threadId={rootId}.
threadId?: string; threadId?: string;
// Optional: fired with the real server event id once a text message send
// resolves. The AI bot's "new chat" composer uses it to navigate into the
// freshly-rooted thread (the just-sent top-level event IS the thread root).
// Channel / DM / thread composers omit it and stay fire-and-forget.
onSend?: (eventId: string) => void;
} }
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>( export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => { ({ editor, fileDropContainerRef, roomId, room, threadId, onSend }, ref) => {
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const useAuthentication = useMediaAuthentication(); const useAuthentication = useMediaAuthentication();
@ -492,7 +497,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
content['m.relates_to'].is_falling_back = false; content['m.relates_to'].is_falling_back = false;
} }
} }
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
if (onSend) {
pending.then((res) => onSend(res.event_id)).catch(() => undefined);
}
resetEditor(editor); resetEditor(editor);
resetEditorHistory(editor); resetEditorHistory(editor);
setReplyDraft(undefined); setReplyDraft(undefined);
@ -501,6 +509,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
mx, mx,
roomId, roomId,
threadId, threadId,
onSend,
editor, editor,
replyDraft, replyDraft,
sendTypingStatus, sendTypingStatus,

View file

@ -1,4 +1,4 @@
import { style } from '@vanilla-extract/css'; import { keyframes, style } from '@vanilla-extract/css';
import { color, config, toRem } from 'folds'; import { color, config, toRem } from 'folds';
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
@ -169,6 +169,16 @@ export const ThreadDrawerContent = style({
paddingBottom: config.space.S400, paddingBottom: config.space.S400,
}); });
// Assistant transcript variant: the channels `<Message>` hover action bar (which the S700 top
// pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be
// dead space under the header. Tighten it to a small, comfortable gap.
export const ThreadDrawerContentAssistant = style([
ThreadDrawerContent,
{
paddingTop: config.space.S400,
},
]);
export const ThreadDivider = style({ export const ThreadDivider = style({
height: 1, height: 1,
backgroundColor: color.Surface.ContainerLine, backgroundColor: color.Surface.ContainerLine,
@ -250,3 +260,67 @@ export const ThreadErrorState = style({
alignItems: 'center', alignItems: 'center',
gap: config.space.S300, gap: config.space.S300,
}); });
// --- Assistant (ChatGPT-style) transcript -----------------------------------------
// Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface).
// The bot's turn is full-width plain text; the user's turn is a right-aligned bubble.
// Bot reply: full-width, no avatar/name/timestamp. Just the rendered body on the surface.
export const AssistantBotRow = style({
width: '100%',
padding: `0 ${config.space.S400}`,
color: color.Surface.OnContainer,
});
// User turn: right-aligned row holding a bubble.
export const AssistantUserRow = style({
display: 'flex',
justifyContent: 'flex-end',
padding: `0 ${config.space.S400}`,
});
export const AssistantUserBubble = style({
maxWidth: '85%',
minWidth: 0,
padding: `${config.space.S200} ${config.space.S400}`,
borderRadius: config.radii.R400,
// Match the input-form tone (RoomView.css `ChatComposer .Editor` = Surface.Container, the dark
// card the user types into) rather than the brand lavender, so a sent bubble reads as the same
// surface as the composer it came from.
backgroundColor: color.Surface.Container,
color: color.Surface.OnContainer,
});
// "Bot is typing" dots, shown after the last turn while the bot composes a reply.
const typingBlink = keyframes({
'0%, 80%, 100%': { opacity: 0.2, transform: 'translateY(0)' },
'40%': { opacity: 1, transform: `translateY(-${toRem(2)})` },
});
export const TypingDots = style({
display: 'inline-flex',
alignItems: 'center',
gap: toRem(4),
padding: `${config.space.S200} 0`,
});
export const TypingDot = style({
width: toRem(6),
height: toRem(6),
borderRadius: '50%',
backgroundColor: color.Surface.OnContainer,
opacity: 0.2,
animationName: typingBlink,
animationDuration: '1.2s',
animationIterationCount: 'infinite',
selectors: {
'&:nth-child(2)': { animationDelay: '0.2s' },
'&:nth-child(3)': { animationDelay: '0.4s' },
},
'@media': {
'(prefers-reduced-motion: reduce)': {
animationName: 'none',
opacity: 0.5,
},
},
});

View file

@ -6,6 +6,7 @@ import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, co
import { import {
Direction, Direction,
EventTimeline, EventTimeline,
EventTimelineSet,
EventType, EventType,
MatrixEvent, MatrixEvent,
MatrixEventEvent, MatrixEventEvent,
@ -61,6 +62,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
import { useChannelsMode } from '../../hooks/useChannelsMode'; import { useChannelsMode } from '../../hooks/useChannelsMode';
import { useIsOneOnOne } from '../../hooks/useRoom'; import { useIsOneOnOne } from '../../hooks/useRoom';
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
import { useTheme } from '../../hooks/useTheme'; import { useTheme } from '../../hooks/useTheme';
import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useImagePackRooms } from '../../hooks/useImagePackRooms';
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
@ -121,6 +123,19 @@ type ThreadDrawerProps = {
// sitting beside the timeline. Layout is otherwise identical so the // sitting beside the timeline. Layout is otherwise identical so the
// M5 mobile-stack work doesn't need to fork the component. // M5 mobile-stack work doesn't need to fork the component.
variant: 'desktop' | 'mobile'; variant: 'desktop' | 'mobile';
// When true, suppress the drawer's own thread header (the «ТРЕД / в
// #<room>» caption + ✕ close). The bot conversation surface mounts an
// outer hero + back-row that owns the chrome, so the channels-oriented
// header would be foreign there. Defaults to false → channels usage is
// unchanged. The caller is then responsible for a back affordance
// (the ✕'s `close()` navigation lives in the header we drop here).
hideHeader?: boolean;
// 'assistant' renders a ChatGPT-style transcript instead of the channel chrome: the bot's
// turns are full-width plain text (no avatar / name / timestamp / hover-menu / reactions),
// the user's turns are right-aligned bubbles, and a typing-dots indicator shows while the
// other member (the bot) is typing. Used by the AI bot conversation surface. Defaults to
// 'default' (the channels Message rendering), so existing callers are unchanged.
messageStyle?: 'default' | 'assistant';
}; };
// M4a: thread events render through the shared `<Message>` component // M4a: thread events render through the shared `<Message>` component
@ -135,7 +150,15 @@ type ThreadDrawerProps = {
// empty). The component is intentionally light on chrome compared to // empty). The component is intentionally light on chrome compared to
// the full Message renderer in the timeline: M2 is MVP and rich // the full Message renderer in the timeline: M2 is MVP and rich
// reactions / hover-menus / rail-dot inside the drawer is M9 territory. // reactions / hover-menus / rail-dot inside the drawer is M9 territory.
export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDrawerProps) { export function ThreadDrawer({
room,
rootId,
parentRoomPath,
variant,
hideHeader = false,
messageStyle = 'default',
}: ThreadDrawerProps) {
const assistantStyle = messageStyle === 'assistant';
const { t } = useTranslation(); const { t } = useTranslation();
const mx = useMatrixClient(); const mx = useMatrixClient();
const navigate = useNavigate(); const navigate = useNavigate();
@ -341,6 +364,10 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
const isOneOnOne = useIsOneOnOne(); const isOneOnOne = useIsOneOnOne();
const channelsMode = useChannelsMode(); const channelsMode = useChannelsMode();
const isBridged = channelsMode && isBridgedRoom(room); const isBridged = channelsMode && isBridgedRoom(room);
// Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any
// non-self typing receipt means the bot is composing a reply. Drives the typing-dots row.
const typingReceipts = useRoomTypingMember(room.roomId);
const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId());
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
const roomToParents = useAtomValue(roomToParentsAtom); const roomToParents = useAtomValue(roomToParentsAtom);
@ -898,6 +925,19 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [repliesCount, myUserId, tryMarkThreadRead]); }, [repliesCount, myUserId, tryMarkThreadRead]);
// Assistant typing-dots follow. The "bot is typing" row renders INSIDE the scroll, after the
// last reply. When the bot starts composing while we're parked at the bottom, those dots add
// height below the fold, the bottom sentinel scrolls out, and the IntersectionObserver flips
// isAtBottomRef false — which would then make the growth effect suppress the bot's (non-own)
// reply. So snap to the new bottom when the dots appear (pre-paint, so the sentinel never
// leaves view). Only when already at-bottom — a user reading older replies is never yanked down.
useLayoutEffect(() => {
if (!botTyping) return;
const host = scrollHostRef.current;
if (!host || !isAtBottomRef.current) return;
host.scrollTop = host.scrollHeight;
}, [botTyping]);
// Stay at-bottom when the drawer scroll container resizes — Android // Stay at-bottom when the drawer scroll container resizes — Android
// soft-keyboard open/close shrinks the viewport, and Capacitor's // soft-keyboard open/close shrinks the viewport, and Capacitor's
// default `windowSoftInputMode=adjustResize` shrinks the WebView // default `windowSoftInputMode=adjustResize` shrinks the WebView
@ -954,6 +994,69 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
navigate(parentRoomPath, { replace: true }); navigate(parentRoomPath, { replace: true });
}, [navigate, parentRoomPath]); }, [navigate, parentRoomPath]);
// ChatGPT-style row: the bot's reply is full-width plain text (no avatar / name / timestamp /
// hover-menu / reactions); the user's turn is a right-aligned bubble. Body is rendered through
// the shared RenderMessageContent (markdown/html/media) so links, code, and edits behave like
// anywhere else — only the surrounding Message chrome is dropped.
const renderAssistantEvent = (
mEvent: MatrixEvent,
eventId: string,
timelineSet: EventTimelineSet
) => {
const isOwn = mEvent.getSender() === mx.getUserId();
const senderId = mEvent.getSender() ?? '';
const senderDisplayName =
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
const eventType = mEvent.getType();
const body = (() => {
if (mEvent.isRedacted()) {
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />;
}
if (eventType === MessageEvent.RoomMessage) {
const editedEvent = getEditedEvent(eventId, mEvent, timelineSet);
const getContent = (() =>
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
return (
<RenderMessageContent
displayName={senderDisplayName}
msgType={mEvent.getContent().msgtype ?? ''}
ts={mEvent.getTs()}
edited={!!editedEvent}
getContent={getContent}
mediaAutoLoad={mediaAutoLoad}
urlPreview={showUrlPreview}
htmlReactParserOptions={htmlReactParserOptions}
linkifyOpts={linkifyOpts}
outlineAttachment
/>
);
}
if (eventType === MessageEvent.RoomMessageEncrypted) {
return (
<Text>
<MessageNotDecryptedContent />
</Text>
);
}
return (
<Text>
<MessageUnsupportedContent />
</Text>
);
})();
return (
<div
key={eventId}
data-message-id={eventId}
className={isOwn ? css.AssistantUserRow : css.AssistantBotRow}
>
{isOwn ? <div className={css.AssistantUserBubble}>{body}</div> : body}
</div>
);
};
const renderThreadEvent = (mEvent: MatrixEvent) => { const renderThreadEvent = (mEvent: MatrixEvent) => {
const eventId = mEvent.getId(); const eventId = mEvent.getId();
if (!eventId) return null; if (!eventId) return null;
@ -973,6 +1076,11 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId; const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
const timelineSet = const timelineSet =
isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet(); isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
// Assistant transcript: strip all the channel chrome and render bot = full-width text,
// user = right-aligned bubble. Reactions/edits-as-aggregations machinery below is skipped.
if (assistantStyle) {
return renderAssistantEvent(mEvent, eventId, timelineSet);
}
const reactionRelations = getEventReactions(timelineSet, eventId); const reactionRelations = getEventReactions(timelineSet, eventId);
const reactionList = reactionRelations?.getSortedAnnotationsByKey(); const reactionList = reactionRelations?.getSortedAnnotationsByKey();
const hasReactions = reactionList && reactionList.length > 0; const hasReactions = reactionList && reactionList.length > 0;
@ -1164,26 +1272,29 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
} }
return ( return (
<div className={css.ThreadDrawerContent}> <div className={assistantStyle ? css.ThreadDrawerContentAssistant : css.ThreadDrawerContent}>
{renderThreadEvent(rootEvent)} {renderThreadEvent(rootEvent)}
{(() => { {/* Channel-style root↔replies divider / counter. Skipped in assistant mode, which
// Counter prefers the larger of materialized `thread.length` renders a continuous ChatGPT-style transcript with no "N replies" affordance. */}
// (replyCount + pending) and loaded `replies.length` — covers {!assistantStyle &&
// the cold-load window where the SDK can synthesize an empty (() => {
// Thread (length=0) shortly before the relations fetch lands // Counter prefers the larger of materialized `thread.length`
// a populated `replies` list (`??` would have collapsed to 0 // (replyCount + pending) and loaded `replies.length` — covers
// and silently shown the divider despite present replies). // the cold-load window where the SDK can synthesize an empty
const counterCount = Math.max(thread?.length ?? 0, replies.length); // Thread (length=0) shortly before the relations fetch lands
if (counterCount === 0) return <div className={css.ThreadDivider} />; // a populated `replies` list (`??` would have collapsed to 0
return ( // and silently shown the divider despite present replies).
<div className={css.ThreadCounterRow} aria-hidden> const counterCount = Math.max(thread?.length ?? 0, replies.length);
<span className={css.ThreadCounterDot} /> if (counterCount === 0) return <div className={css.ThreadDivider} />;
<span className={css.ThreadCounterText}> return (
{t('Room.thread_summary_count', { count: counterCount })} <div className={css.ThreadCounterRow} aria-hidden>
</span> <span className={css.ThreadCounterDot} />
</div> <span className={css.ThreadCounterText}>
); {t('Room.thread_summary_count', { count: counterCount })}
})()} </span>
</div>
);
})()}
{coldLoadError && ( {coldLoadError && (
<div className={css.ThreadErrorState}> <div className={css.ThreadErrorState}>
<Text size="T300" align="Center" priority="400"> <Text size="T300" align="Center" priority="400">
@ -1214,7 +1325,8 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
</Button> </Button>
</div> </div>
)} )}
{thread && {!assistantStyle &&
thread &&
!coldLoadError && !coldLoadError &&
!coldLoadFetching && !coldLoadFetching &&
!paginating && !paginating &&
@ -1238,6 +1350,15 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
</Box> </Box>
)} )}
{replies.map((reply) => renderThreadEvent(reply))} {replies.map((reply) => renderThreadEvent(reply))}
{botTyping && (
<div className={css.AssistantBotRow}>
<div className={css.TypingDots} aria-label={t('Room.is_typing')}>
<span className={css.TypingDot} />
<span className={css.TypingDot} />
<span className={css.TypingDot} />
</div>
</div>
)}
<div ref={setBottomSentinel} /> <div ref={setBottomSentinel} />
</div> </div>
); );
@ -1248,30 +1369,32 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
ref={fileDropContainerRef} ref={fileDropContainerRef}
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile} className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
role="region" role="region"
aria-labelledby={headerId} aria-labelledby={hideHeader ? undefined : headerId}
> >
<Header className={css.ThreadDrawerHeader} variant="Background" size="600"> {!hideHeader && (
<Box grow="Yes" alignItems="Center" gap="200"> <Header className={css.ThreadDrawerHeader} variant="Background" size="600">
<Box grow="Yes" direction="Column" id={headerId}> <Box grow="Yes" alignItems="Center" gap="200">
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200"> <Box grow="Yes" direction="Column" id={headerId}>
{t('Room.thread_caption')} <Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
</Text> {t('Room.thread_caption')}
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate> </Text>
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })} <Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
</Text> {t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
</Text>
</Box>
<Box shrink="No" alignItems="Center">
<IconButton
ref={closeBtnRef}
variant="Background"
onClick={close}
aria-label={t('Room.thread_close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box> </Box>
<Box shrink="No" alignItems="Center"> </Header>
<IconButton )}
ref={closeBtnRef}
variant="Background"
onClick={close}
aria-label={t('Room.thread_close')}
>
<Icon src={Icons.Cross} />
</IconButton>
</Box>
</Box>
</Header>
<div className={css.ThreadDrawerScroll}> <div className={css.ThreadDrawerScroll}>
<Scroll <Scroll
ref={scrollHostRef} ref={scrollHostRef}

View file

@ -20,7 +20,11 @@ export type BotConfig = {
* to i18n key `Bots.description.<id>` when absent. */ * to i18n key `Bots.description.<id>` when absent. */
description?: string; description?: string;
experience?: { experience?: {
/** `'matrix-widget'` (bridge bots sandboxed iframe) or `'ai-chat'` (first-party
* assistant bots native in-client conversation surface, no widget). See
* catalog.ts `normalizeBotExperience`. */
type?: string; type?: string;
/** Widget iframe URL. Required for `'matrix-widget'`; absent for `'ai-chat'`. */
url?: string; url?: string;
commandPrefix?: string; commandPrefix?: string;
/** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`). /** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).

View file

@ -328,6 +328,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
> >
{mobile ? null : <Route index element={<WelcomePage />} />} {mobile ? null : <Route index element={<WelcomePage />} />}
<Route path=":botId" element={routeSuspense(<BotExperienceHost />)} /> <Route path=":botId" element={routeSuspense(<BotExperienceHost />)} />
{/* A single conversation (m.thread root) inside an assistant bot's control
DM. Same element as :botId BotExperienceHost reads :rootId to open the
reused ThreadDrawer; without it the conversation list renders. */}
<Route path=":botId/thread/:rootId" element={routeSuspense(<BotExperienceHost />)} />
</Route> </Route>
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} /> <Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the {/* Channels segment. /channels/* is reserved before SPACE_PATH so the

View file

@ -4,8 +4,14 @@ import { Icon, Icons } from 'folds';
import { useTranslation } from 'react-i18next'; import { useTranslation } from 'react-i18next';
import { useAtomValue } from 'jotai'; import { useAtomValue } from 'jotai';
import type { Room as MatrixRoom } from 'matrix-js-sdk'; import type { Room as MatrixRoom } from 'matrix-js-sdk';
import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog'; import {
findBotPresetById,
isAiChatExperience,
useBotPresets,
type BotPreset,
} from '../../../features/bots/catalog';
import { BotChatFallback } from '../../../features/bots/BotChatFallback'; import { BotChatFallback } from '../../../features/bots/BotChatFallback';
import { BotConversations } from '../../../features/bots/BotConversations';
import { BotShell } from '../../../features/bots/BotShell'; import { BotShell } from '../../../features/bots/BotShell';
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState'; import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
import { useBotRoom } from '../../../features/bots/useBotRoom'; import { useBotRoom } from '../../../features/bots/useBotRoom';
@ -17,13 +23,23 @@ import { BotRoomProvider } from './BotRoomProvider';
import { BotStatePage } from './BotStatePage'; import { BotStatePage } from './BotStatePage';
import { BotUnsafeRoom } from './BotUnsafeRoom'; import { BotUnsafeRoom } from './BotUnsafeRoom';
// Branches between BotShell (widget mode, no Cinny header) and the standard // Picks the bot's experience. Two fully separate worlds, decided by `experience.type`:
// Room layout (chat fallback) based on the per-room showChat atom. Lives // - 'ai-chat' → the native conversation surface (BotConversations). Self-contained: its own
// inside BotRoomProvider so both arms see the same room context and so an // header/menu, NO widget, NO show-chat toggle. The showChat atom and the BotShell/chat-fallback
// atom write from BotShellMenu (via «Показать чат») is observed here // pipeline below are never reached for it.
// without prop-drilling. // - everything else (bridges) → the sandboxed widget (BotShell) with a "Show chat" fallback to
// the standard Room layout, toggled by the per-room showChat atom (set from BotShellMenu).
// Lives inside BotRoomProvider so every arm sees the same room context.
function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) { function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) {
// /bots/:botId/thread/:rootId carries the open conversation's thread root; bare
// /bots/:botId leaves it undefined (the new-chat landing).
const { rootId } = useParams();
// Read unconditionally (rules of hooks); only the bridge path below consumes it.
const showChat = useAtomValue(botShowChatAtomFamily(room.roomId)); const showChat = useAtomValue(botShowChatAtomFamily(room.roomId));
if (isAiChatExperience(preset.experience)) {
return <BotConversations preset={preset} room={room} rootId={rootId} />;
}
if (showChat) { if (showChat) {
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />; return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
} }

View file

@ -1,6 +1,7 @@
import { generatePath, Path } from 'react-router-dom'; import { generatePath, Path } from 'react-router-dom';
import { import {
BOTS_BOT_PATH, BOTS_BOT_PATH,
BOTS_BOT_THREAD_PATH,
BOTS_PATH, BOTS_PATH,
CHANNELS_PATH, CHANNELS_PATH,
CHANNELS_ROOM_EVENT_PATH, CHANNELS_ROOM_EVENT_PATH,
@ -170,6 +171,11 @@ export const getSettingsPath = (page?: string): string => {
export const getBotsPath = (): string => BOTS_PATH; export const getBotsPath = (): string => BOTS_PATH;
export const getBotPath = (botId: string): string => export const getBotPath = (botId: string): string =>
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) }); generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
export const getBotThreadPath = (botId: string, rootId: string): string =>
generatePath(BOTS_BOT_THREAD_PATH, {
botId: encodeURIComponent(botId),
rootId: encodeURIComponent(rootId),
});
export const getChannelsPath = (): string => CHANNELS_PATH; export const getChannelsPath = (): string => CHANNELS_PATH;
export const getChannelsSpacePath = (spaceIdOrAlias: string): string => { export const getChannelsSpacePath = (spaceIdOrAlias: string): string => {

View file

@ -87,6 +87,10 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
export const BOTS_PATH = '/bots/'; export const BOTS_PATH = '/bots/';
export const BOTS_BOT_PATH = '/bots/:botId/'; export const BOTS_BOT_PATH = '/bots/:botId/';
// A single conversation (m.thread root) inside an assistant bot's control DM. Mirrors
// CHANNELS_THREAD_PATH so the reused ThreadDrawer opens off a URL the same way it does in
// channels, but scoped under the bot route (no channelsMode, no space/room segments).
export const BOTS_BOT_THREAD_PATH = '/bots/:botId/thread/:rootId/';
export const CHANNELS_PATH = '/channels/'; export const CHANNELS_PATH = '/channels/';
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/'; export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
@ -95,8 +99,7 @@ export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/';
// optional eventId does not collide with the sibling `thread/:rootId/` // optional eventId does not collide with the sibling `thread/:rootId/`
// sub-route. Search/inbox/mention/push permalinks land here so the timeline // sub-route. Search/inbox/mention/push permalinks land here so the timeline
// can scroll to the cited event without dropping the user out of /channels/. // can scroll to the cited event without dropping the user out of /channels/.
export const CHANNELS_ROOM_EVENT_PATH = export const CHANNELS_ROOM_EVENT_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
'/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/'; export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
export const SPACE_SETTINGS_PATH = '/space-settings/'; export const SPACE_SETTINGS_PATH = '/space-settings/';