feat(ai): replace the Vojo AI widget with a native, isolated ChatGPT-style chat surface (threads, history, typing)
This commit is contained in:
parent
5d959311f2
commit
185d0a60a7
41 changed files with 1607 additions and 3104 deletions
2
.vscode/tasks.json
vendored
2
.vscode/tasks.json
vendored
|
|
@ -16,7 +16,7 @@
|
|||
{
|
||||
"label": "Deploy widgets",
|
||||
"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",
|
||||
"presentation": {
|
||||
"reveal": "always",
|
||||
|
|
|
|||
|
|
@ -53,12 +53,26 @@ type Bot struct {
|
|||
// 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
|
||||
// head-of-line hold was the root cause of the multi-minute silence.
|
||||
mu sync.Mutex
|
||||
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)
|
||||
meta map[string]*roomMeta
|
||||
buf map[string][]bufferedMsg
|
||||
inflight map[string]bool // roomID currently generating a reply (per-room single-flight)
|
||||
mu sync.Mutex
|
||||
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)
|
||||
meta map[string]*roomMeta
|
||||
// buf and inflight are keyed by roomID THEN by thread root ("" = main timeline), so
|
||||
// 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) {
|
||||
|
|
@ -80,8 +94,9 @@ func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error)
|
|||
seen: newLRUSet(5000),
|
||||
botSent: newLRUSet(5000),
|
||||
meta: make(map[string]*roomMeta),
|
||||
buf: make(map[string][]bufferedMsg),
|
||||
inflight: make(map[string]bool),
|
||||
buf: make(map[string]map[string]*convBuf),
|
||||
inflight: make(map[string]map[string]bool),
|
||||
typingRefs: make(map[string]int),
|
||||
}
|
||||
|
||||
// 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
|
||||
// in transaction order, so the FIRST message for a room wins and later ones are
|
||||
// dropped until release — never the reverse.
|
||||
if !b.tryClaim(roomID) {
|
||||
b.log.Debug("drop: room busy generating", "room", roomID, "sender", ev.Sender)
|
||||
// Resolve which conversation this turn belongs to: an existing thread (continue it),
|
||||
// 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
|
||||
}
|
||||
|
||||
// Snapshot the room history (excludes this trigger) under the claim, then run the
|
||||
// slow generation in its own goroutine so this transaction's remaining events and
|
||||
// other rooms are not blocked by the xAI call. respond appends the trigger+answer
|
||||
// to the buffer itself, only on success (see sendReply), and releases the claim.
|
||||
history := b.snapshotBuf(roomID)
|
||||
// Snapshot THIS conversation's history (excludes this trigger) under the claim, then
|
||||
// run the slow generation in its own goroutine so this transaction's remaining events
|
||||
// and other rooms/threads are not blocked by the xAI call. respond appends the
|
||||
// trigger+answer to the same per-thread buffer on success (see sendReply) and releases
|
||||
// the claim.
|
||||
history := b.snapshotBuf(roomID, threadRoot)
|
||||
b.safego("respond", func() {
|
||||
defer b.release(roomID)
|
||||
b.respond(ctx, roomID, isDM, ev, mc, history)
|
||||
defer b.release(roomID, threadRoot)
|
||||
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.
|
||||
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()
|
||||
// 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)
|
||||
|
|
@ -438,7 +462,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
|
|||
defer cancel()
|
||||
|
||||
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.
|
||||
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,
|
||||
"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
|
||||
// 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.
|
||||
|
|
@ -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
|
||||
// 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
|
||||
// a stable per-room id is the right unit — every turn in a room shares the system
|
||||
// prompt and history prefix. It carries no PII (the room id is opaque) and is hashed
|
||||
// to keep it compact and non-identifying.
|
||||
func (b *Bot) convID(roomID string) string {
|
||||
// only pins a conversation to the same backend to raise the hit rate (docs.x.ai). The
|
||||
// right unit is the (room,thread) pair, not the room: once conversations are threaded,
|
||||
// each thread has its OWN system+history prefix, so pinning all of a room's threads to
|
||||
// one id would thrash the cache between divergent prefixes. The main timeline keys on
|
||||
// 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 {
|
||||
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
|
||||
|
|
@ -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
|
||||
// caller can handle paid silence (§8.1): a billed answer that failed to deliver must
|
||||
// refund the slot and react, not vanish.
|
||||
func (b *Bot) sendReply(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||
if err := b.sendMessage(ctx, roomID, trigger, triggerMC, body); err != nil {
|
||||
func (b *Bot) sendReply(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||
if err := b.sendMessage(ctx, roomID, threadRoot, trigger, triggerMC, body); err != nil {
|
||||
return err
|
||||
}
|
||||
// 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
|
||||
// question with no reply) in the buffer — which would skew later completions.
|
||||
// Single-flight guarantees no other turn for this room interleaves between the two.
|
||||
b.appendBuf(roomID, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
|
||||
b.appendBuf(roomID, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
|
||||
// question with no reply) in the buffer — which would skew later completions. Both go
|
||||
// to THIS conversation's buffer (roomID,threadRoot). Per-(room,thread) single-flight
|
||||
// guarantees no other turn for this conversation interleaves between the two.
|
||||
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
|
||||
}
|
||||
|
||||
// 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.
|
||||
func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||
content := buildNoticeContent(trigger.EventID, trigger.Sender, triggerMC.RelatesTo, body)
|
||||
func (b *Bot) sendMessage(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||
content := buildNoticeContent(trigger.EventID, trigger.Sender, threadRoot, body)
|
||||
id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content)
|
||||
if err != nil {
|
||||
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
|
||||
}
|
||||
|
||||
// startTypingKeepalive starts the typing indicator and keeps it alive for the whole
|
||||
// generation (the CS-API server-side typing notification expires after the 30s we
|
||||
// pass, so we refresh every 20s). The returned stop clears the indicator and is safe
|
||||
// to call once via defer. Typing is best-effort UX — failures are non-fatal.
|
||||
// startTypingKeepalive shows the room-level "typing…" indicator for the whole generation
|
||||
// and keeps it alive (the CS-API notification expires after the 30s we pass, so we refresh
|
||||
// every 20s). The returned stop is safe to call once via defer. Typing is ROOM-scoped in
|
||||
// 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() {
|
||||
b.typingAcquire(roomID)
|
||||
|
||||
b.setTyping(ctx, roomID, true)
|
||||
done := make(chan struct{})
|
||||
go func() {
|
||||
|
|
@ -658,11 +694,40 @@ func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
|
|||
return func() {
|
||||
once.Do(func() {
|
||||
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
|
||||
// non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive.
|
||||
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
|
||||
// skip catches our own output. Thread-aware (F27): a trigger from a thread gets a
|
||||
// thread relation so the answer lands in the thread, not the main timeline.
|
||||
func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body string) map[string]any {
|
||||
// skip catches our own output. threadRoot decides where the answer lands: when non-empty
|
||||
// the answer carries an m.thread relation rooted there (F27) — either replying inside an
|
||||
// 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{}
|
||||
if triggerRelates != nil && triggerRelates.RelType == "m.thread" && triggerRelates.EventID != "" {
|
||||
if threadRoot != "" {
|
||||
relates["rel_type"] = "m.thread"
|
||||
relates["event_id"] = triggerRelates.EventID
|
||||
relates["event_id"] = threadRoot
|
||||
relates["is_falling_back"] = true
|
||||
relates["m.in_reply_to"] = map[string]any{"event_id": replyTo}
|
||||
} else {
|
||||
|
|
@ -701,24 +770,55 @@ func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body
|
|||
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
|
||||
// (no generation was already in flight). The loser must drop its message.
|
||||
func (b *Bot) tryClaim(roomID string) bool {
|
||||
// resolveThreadRoot decides which conversation a trigger belongs to, returning the thread
|
||||
// root event id, or "" for the main timeline. Order: (1) a trigger already inside a thread
|
||||
// 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()
|
||||
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
|
||||
}
|
||||
b.inflight[roomID] = true
|
||||
m[threadRoot] = true
|
||||
return true
|
||||
}
|
||||
|
||||
func (b *Bot) release(roomID string) {
|
||||
func (b *Bot) release(roomID, threadRoot string) {
|
||||
b.mu.Lock()
|
||||
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) -----
|
||||
|
|
@ -752,8 +852,9 @@ func (b *Bot) forgetRoom(roomID string) {
|
|||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
delete(b.meta, roomID)
|
||||
delete(b.buf, roomID)
|
||||
delete(b.inflight, roomID)
|
||||
delete(b.buf, roomID) // drops the room's whole per-thread subtree in one delete
|
||||
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
|
||||
|
|
@ -824,28 +925,69 @@ func (b *Bot) ensureCounts(ctx context.Context, roomID string) (countsKnown, isD
|
|||
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()
|
||||
defer b.mu.Unlock()
|
||||
src := b.buf[roomID]
|
||||
if len(src) == 0 {
|
||||
// A never-seen conversation has no buffer (nil inner map / nil convBuf) → nil history,
|
||||
// exactly what a fresh "new chat" wants (context = system + trigger).
|
||||
cb := b.buf[roomID][threadRoot]
|
||||
if cb == nil || len(cb.msgs) == 0 {
|
||||
return nil
|
||||
}
|
||||
out := make([]bufferedMsg, len(src))
|
||||
copy(out, src)
|
||||
out := make([]bufferedMsg, len(cb.msgs))
|
||||
copy(out, cb.msgs)
|
||||
return out
|
||||
}
|
||||
|
||||
func (b *Bot) appendBuf(roomID string, msg bufferedMsg) {
|
||||
func (b *Bot) appendBuf(roomID, threadRoot string, msg bufferedMsg) {
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
limit := b.cfg.MaxCtxEvent * 2
|
||||
if limit < 8 {
|
||||
limit = 8
|
||||
}
|
||||
buf := append(b.buf[roomID], msg)
|
||||
if len(buf) > limit {
|
||||
buf = buf[len(buf)-limit:]
|
||||
m := b.buf[roomID]
|
||||
if m == nil {
|
||||
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
158
apps/ai-bot/buffers_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
|
|
@ -5,52 +5,89 @@ import (
|
|||
"testing"
|
||||
)
|
||||
|
||||
// TestSingleFlightClaim documents the per-room single-flight invariant the async
|
||||
// refactor relies on: at most one generation per room at a time, the claim is
|
||||
// independent per room, and a release re-arms the room. handleEvent takes this claim
|
||||
// synchronously in transaction order, so the FIRST message for a room wins and later
|
||||
// ones are dropped until release (never the reverse).
|
||||
// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
|
||||
// async refactor relies on: at most one generation per conversation at a time, the claim
|
||||
// is independent per (room,thread), and a release re-arms only that conversation.
|
||||
// handleEvent takes this claim synchronously in transaction order, so the FIRST message
|
||||
// for a conversation wins and later ones are dropped until release (never the reverse).
|
||||
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") {
|
||||
t.Fatal("first claim on !a should win")
|
||||
if !b.tryClaim("!a", "") {
|
||||
t.Fatal("first claim on (!a, main) should win")
|
||||
}
|
||||
if b.tryClaim("!a") {
|
||||
t.Fatal("second claim on !a must fail while in flight")
|
||||
if b.tryClaim("!a", "") {
|
||||
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")
|
||||
}
|
||||
b.release("!a")
|
||||
if !b.tryClaim("!a") {
|
||||
t.Fatal("after release !a must be claimable again")
|
||||
b.release("!a", "")
|
||||
if !b.tryClaim("!a", "") {
|
||||
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
|
||||
// room and asserts EXACTLY ONE wins the claim — the property that prevents two
|
||||
// concurrent generations (double xAI spend) for one room. Run under -race.
|
||||
// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
|
||||
// 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) {
|
||||
b := &Bot{inflight: make(map[string]bool)}
|
||||
b := &Bot{inflight: make(map[string]map[string]bool)}
|
||||
const n = 64
|
||||
var wins int64
|
||||
var sameWins, threadAWins, threadBWins int64
|
||||
var mu sync.Mutex
|
||||
var wg sync.WaitGroup
|
||||
wg.Add(n)
|
||||
wg.Add(n * 3)
|
||||
for i := 0; i < n; i++ {
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
if b.tryClaim("!room") {
|
||||
if b.tryClaim("!room", "$same") {
|
||||
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()
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
if wins != 1 {
|
||||
t.Fatalf("exactly one goroutine must win the claim, got %d", wins)
|
||||
if sameWins != 1 {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -121,7 +121,7 @@ func TestMarkdownAdversarialNoPanicNoInjection(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 {
|
||||
t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat)
|
||||
}
|
||||
|
|
@ -135,7 +135,7 @@ func TestBuildNoticeContentAttachesFormatted(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 {
|
||||
t.Fatalf("format must be absent for plain text")
|
||||
}
|
||||
|
|
|
|||
70
apps/ai-bot/threads_test.go
Normal file
70
apps/ai-bot/threads_test.go
Normal 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"])
|
||||
}
|
||||
}
|
||||
4
apps/widget-vojo-ai/.gitignore
vendored
4
apps/widget-vojo-ai/.gitignore
vendored
|
|
@ -1,4 +0,0 @@
|
|||
node_modules/
|
||||
dist/
|
||||
.vite/
|
||||
*.local
|
||||
|
|
@ -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>
|
||||
1995
apps/widget-vojo-ai/package-lock.json
generated
1995
apps/widget-vojo-ai/package-lock.json
generated
File diff suppressed because it is too large
Load diff
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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'),
|
||||
},
|
||||
};
|
||||
};
|
||||
|
|
@ -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 chat’s 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}.',
|
||||
};
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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;
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
1
apps/widget-vojo-ai/src/vite-env.d.ts
vendored
1
apps/widget-vojo-ai/src/vite-env.d.ts
vendored
|
|
@ -1 +0,0 @@
|
|||
/// <reference types="vite/client" />
|
||||
|
|
@ -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, {});
|
||||
}
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -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"]
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
});
|
||||
|
|
@ -49,9 +49,7 @@
|
|||
"mxid": "@ai:vojo.chat",
|
||||
"name": "Vojo AI",
|
||||
"experience": {
|
||||
"type": "matrix-widget",
|
||||
"url": "https://widgets.vojo.chat/vojo-ai/index.html",
|
||||
"capabilities": ["vojo.add_to_chat"]
|
||||
"type": "ai-chat"
|
||||
}
|
||||
}
|
||||
],
|
||||
|
|
|
|||
|
|
@ -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
|
||||
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 →
|
||||
foreign-server leave → DM-or-mention → media react → **per-room single-flight** → spawn
|
||||
`respond`. `respond` = `Reserve(estimate)` → `generate()` → `Settle(actual)` → `sendReply`;
|
||||
**any failure produces an emoji react, never silence.**
|
||||
foreign-server leave → DM-or-mention → media react → resolve conversation (thread) →
|
||||
**per-(room,thread) single-flight** → spawn `respond`. `respond` = `Reserve(estimate)` →
|
||||
`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)
|
||||
|
||||
|
|
@ -51,7 +70,7 @@ adapters [provider_xai.go](../../apps/ai-bot/provider_xai.go) /
|
|||
a panic releases via a deferred guard.
|
||||
- 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
|
||||
per-room single-flight.
|
||||
per-(room,thread) single-flight.
|
||||
- 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,
|
||||
degrade reasons), written async + isolated (its failure never drops a reply), `TELEMETRY_ENABLED`
|
||||
|
|
|
|||
|
|
@ -942,6 +942,17 @@
|
|||
"show_widget": "Show robot",
|
||||
"retry_widget": "Retry robot",
|
||||
"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": {
|
||||
"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.",
|
||||
|
|
|
|||
|
|
@ -960,6 +960,17 @@
|
|||
"show_widget": "Показать робота",
|
||||
"retry_widget": "Повторить",
|
||||
"more_options": "Ещё",
|
||||
"conversations": {
|
||||
"title": "Чаты",
|
||||
"new_chat": "Новый чат",
|
||||
"empty": "Пока нет бесед.",
|
||||
"start_first": "Начать первый чат",
|
||||
"untitled": "Без названия",
|
||||
"back": "К списку чатов",
|
||||
"new_chat_hint": "Спросите что угодно, чтобы начать новую беседу.",
|
||||
"composer_placeholder": "Сообщение для Vojo AI…",
|
||||
"send": "Отправить"
|
||||
},
|
||||
"description": {
|
||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
|
||||
|
|
|
|||
142
src/app/features/bots/AiChatHeader.tsx
Normal file
142
src/app/features/bots/AiChatHeader.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
140
src/app/features/bots/AiChatMenu.tsx
Normal file
140
src/app/features/bots/AiChatMenu.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
);
|
||||
172
src/app/features/bots/BotConversations.css.ts
Normal file
172
src/app/features/bots/BotConversations.css.ts
Normal 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}`,
|
||||
});
|
||||
184
src/app/features/bots/BotConversations.tsx
Normal file
184
src/app/features/bots/BotConversations.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -19,7 +19,7 @@ import {
|
|||
import { Theme } from '../../hooks/useTheme';
|
||||
import { openExternalUrl } from '../../utils/capacitor';
|
||||
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 {
|
||||
BotWidgetDriver,
|
||||
sanitizeBotWidgetMessageEvent,
|
||||
|
|
@ -61,7 +61,8 @@ const getBotWidgetUrl = (
|
|||
language: string,
|
||||
widgetId: 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);
|
||||
url.searchParams.set('widgetId', widgetId);
|
||||
|
|
@ -306,7 +307,9 @@ export class BotWidgetEmbed {
|
|||
// capability from the trusted config (`preset.experience.capabilities`),
|
||||
// never from the widget message; the invitee is `preset.mxid`, host-set.
|
||||
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?.();
|
||||
return;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,7 +2,14 @@ import { useMemo } from 'react';
|
|||
import { useClientConfig } 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';
|
||||
url: string;
|
||||
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
|
||||
|
|
@ -15,6 +22,22 @@ export type BotExperience = {
|
|||
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
|
||||
* 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
|
||||
|
|
@ -94,6 +117,8 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
|
|||
|
||||
const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
|
||||
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();
|
||||
if (type !== 'matrix-widget' || !url) return undefined;
|
||||
if (url.startsWith('//')) return undefined;
|
||||
|
|
|
|||
99
src/app/features/bots/useBotConversations.ts
Normal file
99
src/app/features/bots/useBotConversations.ts
Normal 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]);
|
||||
};
|
||||
|
|
@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next';
|
|||
import { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||
import { Theme, useTheme } from '../../hooks/useTheme';
|
||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||
import type { BotPreset } from './catalog';
|
||||
import { isWidgetExperience, type BotPreset } from './catalog';
|
||||
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
||||
|
||||
type UseBotWidgetEmbedOptions = {
|
||||
|
|
@ -69,7 +69,7 @@ export const useBotWidgetEmbed = ({
|
|||
// every time `useBotPresets` returns a fresh memo, e.g. after a runtime
|
||||
// config refresh.
|
||||
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 { roomId } = room;
|
||||
|
||||
|
|
|
|||
|
|
@ -199,9 +199,14 @@ interface RoomInputProps {
|
|||
// composers leave this undefined so messages land in the main timeline
|
||||
// unchanged. The drawer composer passes threadId={rootId}.
|
||||
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>(
|
||||
({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => {
|
||||
({ editor, fileDropContainerRef, roomId, room, threadId, onSend }, ref) => {
|
||||
const { t } = useTranslation();
|
||||
const mx = useMatrixClient();
|
||||
const useAuthentication = useMediaAuthentication();
|
||||
|
|
@ -492,7 +497,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
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);
|
||||
resetEditorHistory(editor);
|
||||
setReplyDraft(undefined);
|
||||
|
|
@ -501,6 +509,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
|||
mx,
|
||||
roomId,
|
||||
threadId,
|
||||
onSend,
|
||||
editor,
|
||||
replyDraft,
|
||||
sendTypingStatus,
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { style } from '@vanilla-extract/css';
|
||||
import { keyframes, style } from '@vanilla-extract/css';
|
||||
import { color, config, toRem } from 'folds';
|
||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||
|
||||
|
|
@ -169,6 +169,16 @@ export const ThreadDrawerContent = style({
|
|||
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({
|
||||
height: 1,
|
||||
backgroundColor: color.Surface.ContainerLine,
|
||||
|
|
@ -250,3 +260,67 @@ export const ThreadErrorState = style({
|
|||
alignItems: 'center',
|
||||
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,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, co
|
|||
import {
|
||||
Direction,
|
||||
EventTimeline,
|
||||
EventTimelineSet,
|
||||
EventType,
|
||||
MatrixEvent,
|
||||
MatrixEventEvent,
|
||||
|
|
@ -61,6 +62,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
|||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||
import { useTheme } from '../../hooks/useTheme';
|
||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||
|
|
@ -121,6 +123,19 @@ type ThreadDrawerProps = {
|
|||
// sitting beside the timeline. Layout is otherwise identical so the
|
||||
// M5 mobile-stack work doesn't need to fork the component.
|
||||
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
|
||||
|
|
@ -135,7 +150,15 @@ type ThreadDrawerProps = {
|
|||
// empty). The component is intentionally light on chrome compared to
|
||||
// the full Message renderer in the timeline: M2 is MVP and rich
|
||||
// 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 mx = useMatrixClient();
|
||||
const navigate = useNavigate();
|
||||
|
|
@ -341,6 +364,10 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
const isOneOnOne = useIsOneOnOne();
|
||||
const channelsMode = useChannelsMode();
|
||||
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 roomToParents = useAtomValue(roomToParentsAtom);
|
||||
|
|
@ -898,6 +925,19 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [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
|
||||
// soft-keyboard open/close shrinks the viewport, and Capacitor's
|
||||
// default `windowSoftInputMode=adjustResize` shrinks the WebView
|
||||
|
|
@ -954,6 +994,69 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
navigate(parentRoomPath, { replace: true });
|
||||
}, [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 eventId = mEvent.getId();
|
||||
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 timelineSet =
|
||||
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 reactionList = reactionRelations?.getSortedAnnotationsByKey();
|
||||
const hasReactions = reactionList && reactionList.length > 0;
|
||||
|
|
@ -1164,26 +1272,29 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
}
|
||||
|
||||
return (
|
||||
<div className={css.ThreadDrawerContent}>
|
||||
<div className={assistantStyle ? css.ThreadDrawerContentAssistant : css.ThreadDrawerContent}>
|
||||
{renderThreadEvent(rootEvent)}
|
||||
{(() => {
|
||||
// Counter prefers the larger of materialized `thread.length`
|
||||
// (replyCount + pending) and loaded `replies.length` — covers
|
||||
// the cold-load window where the SDK can synthesize an empty
|
||||
// Thread (length=0) shortly before the relations fetch lands
|
||||
// a populated `replies` list (`??` would have collapsed to 0
|
||||
// and silently shown the divider despite present replies).
|
||||
const counterCount = Math.max(thread?.length ?? 0, replies.length);
|
||||
if (counterCount === 0) return <div className={css.ThreadDivider} />;
|
||||
return (
|
||||
<div className={css.ThreadCounterRow} aria-hidden>
|
||||
<span className={css.ThreadCounterDot} />
|
||||
<span className={css.ThreadCounterText}>
|
||||
{t('Room.thread_summary_count', { count: counterCount })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* Channel-style root↔replies divider / counter. Skipped in assistant mode, which
|
||||
renders a continuous ChatGPT-style transcript with no "N replies" affordance. */}
|
||||
{!assistantStyle &&
|
||||
(() => {
|
||||
// Counter prefers the larger of materialized `thread.length`
|
||||
// (replyCount + pending) and loaded `replies.length` — covers
|
||||
// the cold-load window where the SDK can synthesize an empty
|
||||
// Thread (length=0) shortly before the relations fetch lands
|
||||
// a populated `replies` list (`??` would have collapsed to 0
|
||||
// and silently shown the divider despite present replies).
|
||||
const counterCount = Math.max(thread?.length ?? 0, replies.length);
|
||||
if (counterCount === 0) return <div className={css.ThreadDivider} />;
|
||||
return (
|
||||
<div className={css.ThreadCounterRow} aria-hidden>
|
||||
<span className={css.ThreadCounterDot} />
|
||||
<span className={css.ThreadCounterText}>
|
||||
{t('Room.thread_summary_count', { count: counterCount })}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})()}
|
||||
{coldLoadError && (
|
||||
<div className={css.ThreadErrorState}>
|
||||
<Text size="T300" align="Center" priority="400">
|
||||
|
|
@ -1214,7 +1325,8 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{thread &&
|
||||
{!assistantStyle &&
|
||||
thread &&
|
||||
!coldLoadError &&
|
||||
!coldLoadFetching &&
|
||||
!paginating &&
|
||||
|
|
@ -1238,6 +1350,15 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
</Box>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
|
|
@ -1248,30 +1369,32 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
|||
ref={fileDropContainerRef}
|
||||
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
|
||||
role="region"
|
||||
aria-labelledby={headerId}
|
||||
aria-labelledby={hideHeader ? undefined : headerId}
|
||||
>
|
||||
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" direction="Column" id={headerId}>
|
||||
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
||||
{t('Room.thread_caption')}
|
||||
</Text>
|
||||
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
||||
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
|
||||
</Text>
|
||||
{!hideHeader && (
|
||||
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
||||
<Box grow="Yes" alignItems="Center" gap="200">
|
||||
<Box grow="Yes" direction="Column" id={headerId}>
|
||||
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
||||
{t('Room.thread_caption')}
|
||||
</Text>
|
||||
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
||||
{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 shrink="No" alignItems="Center">
|
||||
<IconButton
|
||||
ref={closeBtnRef}
|
||||
variant="Background"
|
||||
onClick={close}
|
||||
aria-label={t('Room.thread_close')}
|
||||
>
|
||||
<Icon src={Icons.Cross} />
|
||||
</IconButton>
|
||||
</Box>
|
||||
</Box>
|
||||
</Header>
|
||||
</Header>
|
||||
)}
|
||||
<div className={css.ThreadDrawerScroll}>
|
||||
<Scroll
|
||||
ref={scrollHostRef}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,11 @@ export type BotConfig = {
|
|||
* to i18n key `Bots.description.<id>` when absent. */
|
||||
description?: string;
|
||||
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;
|
||||
/** Widget iframe URL. Required for `'matrix-widget'`; absent for `'ai-chat'`. */
|
||||
url?: string;
|
||||
commandPrefix?: string;
|
||||
/** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).
|
||||
|
|
|
|||
|
|
@ -328,6 +328,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
|||
>
|
||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||
<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 path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
||||
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
|
||||
|
|
|
|||
|
|
@ -4,8 +4,14 @@ import { Icon, Icons } from 'folds';
|
|||
import { useTranslation } from 'react-i18next';
|
||||
import { useAtomValue } from 'jotai';
|
||||
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 { BotConversations } from '../../../features/bots/BotConversations';
|
||||
import { BotShell } from '../../../features/bots/BotShell';
|
||||
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
|
||||
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
||||
|
|
@ -17,13 +23,23 @@ import { BotRoomProvider } from './BotRoomProvider';
|
|||
import { BotStatePage } from './BotStatePage';
|
||||
import { BotUnsafeRoom } from './BotUnsafeRoom';
|
||||
|
||||
// Branches between BotShell (widget mode, no Cinny header) and the standard
|
||||
// Room layout (chat fallback) based on the per-room showChat atom. Lives
|
||||
// inside BotRoomProvider so both arms see the same room context and so an
|
||||
// atom write from BotShellMenu (via «Показать чат») is observed here
|
||||
// without prop-drilling.
|
||||
// Picks the bot's experience. Two fully separate worlds, decided by `experience.type`:
|
||||
// - 'ai-chat' → the native conversation surface (BotConversations). Self-contained: its own
|
||||
// header/menu, NO widget, NO show-chat toggle. The showChat atom and the BotShell/chat-fallback
|
||||
// pipeline below are never reached for it.
|
||||
// - 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 }) {
|
||||
// /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));
|
||||
|
||||
if (isAiChatExperience(preset.experience)) {
|
||||
return <BotConversations preset={preset} room={room} rootId={rootId} />;
|
||||
}
|
||||
if (showChat) {
|
||||
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { generatePath, Path } from 'react-router-dom';
|
||||
import {
|
||||
BOTS_BOT_PATH,
|
||||
BOTS_BOT_THREAD_PATH,
|
||||
BOTS_PATH,
|
||||
CHANNELS_PATH,
|
||||
CHANNELS_ROOM_EVENT_PATH,
|
||||
|
|
@ -170,6 +171,11 @@ export const getSettingsPath = (page?: string): string => {
|
|||
export const getBotsPath = (): string => BOTS_PATH;
|
||||
export const getBotPath = (botId: string): string =>
|
||||
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 getChannelsSpacePath = (spaceIdOrAlias: string): string => {
|
||||
|
|
|
|||
|
|
@ -87,6 +87,10 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
|
|||
|
||||
export const BOTS_PATH = '/bots/';
|
||||
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_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/`
|
||||
// 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/.
|
||||
export const CHANNELS_ROOM_EVENT_PATH =
|
||||
'/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
|
||||
export const CHANNELS_ROOM_EVENT_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
|
||||
export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
|
||||
|
||||
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue