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",
|
"label": "Deploy widgets",
|
||||||
"type": "shell",
|
"type": "shell",
|
||||||
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL",
|
"command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL",
|
||||||
"group": "none",
|
"group": "none",
|
||||||
"presentation": {
|
"presentation": {
|
||||||
"reveal": "always",
|
"reveal": "always",
|
||||||
|
|
|
||||||
|
|
@ -53,12 +53,26 @@ type Bot struct {
|
||||||
// off-lock, so several goroutines touch this shared state at once. mu is held only
|
// off-lock, so several goroutines touch this shared state at once. mu is held only
|
||||||
// for short map operations and is NEVER held across a network or xAI call — that
|
// for short map operations and is NEVER held across a network or xAI call — that
|
||||||
// head-of-line hold was the root cause of the multi-minute silence.
|
// head-of-line hold was the root cause of the multi-minute silence.
|
||||||
mu sync.Mutex
|
mu sync.Mutex
|
||||||
seen *lruSet // event ids already handled (dedup within a session; self-locking)
|
seen *lruSet // event ids already handled (dedup within a session; self-locking)
|
||||||
botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking)
|
botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking)
|
||||||
meta map[string]*roomMeta
|
meta map[string]*roomMeta
|
||||||
buf map[string][]bufferedMsg
|
// buf and inflight are keyed by roomID THEN by thread root ("" = main timeline), so
|
||||||
inflight map[string]bool // roomID currently generating a reply (per-room single-flight)
|
// each conversation (a thread) keeps an isolated context window and an independent
|
||||||
|
// single-flight claim. Two messages in different threads of one room no longer block
|
||||||
|
// each other or pollute each other's history. roomMeta stays room-level (membership
|
||||||
|
// /encryption are room facts). forgetRoom drops a room's whole subtree in one delete.
|
||||||
|
buf map[string]map[string]*convBuf
|
||||||
|
inflight map[string]map[string]bool
|
||||||
|
// bufSeq is a monotonic counter stamped onto a convBuf on every append (guarded by mu);
|
||||||
|
// it orders conversations by last activity so appendBuf can LRU-evict the coldest one
|
||||||
|
// when a room exceeds maxConvBuffersPerRoom.
|
||||||
|
bufSeq uint64
|
||||||
|
// typingRefs counts in-flight generations per ROOM that are currently showing the
|
||||||
|
// "typing…" indicator. Matrix typing notifications are room-scoped (no thread_id in
|
||||||
|
// the CS-API), so the indicator can't be split per thread: it goes on when the first
|
||||||
|
// generation in a room starts and off only when the last one finishes (refcount).
|
||||||
|
typingRefs map[string]int
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) {
|
func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) {
|
||||||
|
|
@ -80,8 +94,9 @@ func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error)
|
||||||
seen: newLRUSet(5000),
|
seen: newLRUSet(5000),
|
||||||
botSent: newLRUSet(5000),
|
botSent: newLRUSet(5000),
|
||||||
meta: make(map[string]*roomMeta),
|
meta: make(map[string]*roomMeta),
|
||||||
buf: make(map[string][]bufferedMsg),
|
buf: make(map[string]map[string]*convBuf),
|
||||||
inflight: make(map[string]bool),
|
inflight: make(map[string]map[string]bool),
|
||||||
|
typingRefs: make(map[string]int),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build the cascade backends only for enabled layers (config already fail-fast
|
// Build the cascade backends only for enabled layers (config already fail-fast
|
||||||
|
|
@ -330,19 +345,28 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
|
||||||
// the language-free "I'm busy" signal. The claim is taken here, synchronously and
|
// the language-free "I'm busy" signal. The claim is taken here, synchronously and
|
||||||
// in transaction order, so the FIRST message for a room wins and later ones are
|
// in transaction order, so the FIRST message for a room wins and later ones are
|
||||||
// dropped until release — never the reverse.
|
// dropped until release — never the reverse.
|
||||||
if !b.tryClaim(roomID) {
|
// Resolve which conversation this turn belongs to: an existing thread (continue it),
|
||||||
b.log.Debug("drop: room busy generating", "room", roomID, "sender", ev.Sender)
|
// a freshly rooted DM thread (auto-thread, DM-only), or the main timeline ("").
|
||||||
|
// Groups never auto-thread; see resolveThreadRoot.
|
||||||
|
threadRoot := b.resolveThreadRoot(isDM, ev, mc)
|
||||||
|
|
||||||
|
// Per-(room,thread) single-flight: a slow answer in one conversation no longer blocks
|
||||||
|
// another thread in the same room, and two messages in the SAME conversation still see
|
||||||
|
// exactly one winner. The claim is taken synchronously in transaction order.
|
||||||
|
if !b.tryClaim(roomID, threadRoot) {
|
||||||
|
b.log.Debug("drop: conversation busy generating", "room", roomID, "thread", threadRoot, "sender", ev.Sender)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Snapshot the room history (excludes this trigger) under the claim, then run the
|
// Snapshot THIS conversation's history (excludes this trigger) under the claim, then
|
||||||
// slow generation in its own goroutine so this transaction's remaining events and
|
// run the slow generation in its own goroutine so this transaction's remaining events
|
||||||
// other rooms are not blocked by the xAI call. respond appends the trigger+answer
|
// and other rooms/threads are not blocked by the xAI call. respond appends the
|
||||||
// to the buffer itself, only on success (see sendReply), and releases the claim.
|
// trigger+answer to the same per-thread buffer on success (see sendReply) and releases
|
||||||
history := b.snapshotBuf(roomID)
|
// the claim.
|
||||||
|
history := b.snapshotBuf(roomID, threadRoot)
|
||||||
b.safego("respond", func() {
|
b.safego("respond", func() {
|
||||||
defer b.release(roomID)
|
defer b.release(roomID, threadRoot)
|
||||||
b.respond(ctx, roomID, isDM, ev, mc, history)
|
b.respond(ctx, roomID, threadRoot, isDM, ev, mc, history)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -350,7 +374,7 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) {
|
||||||
// never trip the per-user gate, while the global DAILY_USD_CEILING still applies.
|
// never trip the per-user gate, while the global DAILY_USD_CEILING still applies.
|
||||||
const unlimitedCap = 1 << 30
|
const unlimitedCap = 1 << 30
|
||||||
|
|
||||||
func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) {
|
func (b *Bot) respond(ctx context.Context, roomID, threadRoot string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) {
|
||||||
started := time.Now()
|
started := time.Now()
|
||||||
// One telemetry row per request, populated as the flow decides its outcome and
|
// One telemetry row per request, populated as the flow decides its outcome and
|
||||||
// emitted once via defer — so every exit (deny, error, empty, paid silence, success)
|
// emitted once via defer — so every exit (deny, error, empty, paid silence, success)
|
||||||
|
|
@ -438,7 +462,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens)
|
msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens)
|
||||||
res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID))
|
res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID, threadRoot))
|
||||||
|
|
||||||
// Record what the routing + generation actually did, whatever the outcome.
|
// Record what the routing + generation actually did, whatever the outcome.
|
||||||
rl.Route = res.route
|
rl.Route = res.route
|
||||||
|
|
@ -503,7 +527,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event,
|
||||||
}
|
}
|
||||||
b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route,
|
b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route,
|
||||||
"usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens)
|
"usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens)
|
||||||
if err := b.sendReply(ctx, roomID, ev, mc, text); err != nil {
|
if err := b.sendReply(ctx, roomID, threadRoot, ev, mc, text); err != nil {
|
||||||
// Paid silence (§8.1): the spend is real (USD is kept — refunding it would
|
// Paid silence (§8.1): the spend is real (USD is kept — refunding it would
|
||||||
// under-count the ceiling), but the reply never landed. Refund the request SLOT
|
// under-count the ceiling), but the reply never landed. Refund the request SLOT
|
||||||
// so the user can retry, and react ⚠️ so the failure isn't silent.
|
// so the user can retry, and react ⚠️ so the failure isn't silent.
|
||||||
|
|
@ -535,15 +559,21 @@ func (b *Bot) estimateUSD(model string) float64 {
|
||||||
|
|
||||||
// convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when
|
// convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when
|
||||||
// GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header
|
// GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header
|
||||||
// only pins a conversation to the same backend to raise the hit rate (docs.x.ai), so
|
// only pins a conversation to the same backend to raise the hit rate (docs.x.ai). The
|
||||||
// a stable per-room id is the right unit — every turn in a room shares the system
|
// right unit is the (room,thread) pair, not the room: once conversations are threaded,
|
||||||
// prompt and history prefix. It carries no PII (the room id is opaque) and is hashed
|
// each thread has its OWN system+history prefix, so pinning all of a room's threads to
|
||||||
// to keep it compact and non-identifying.
|
// one id would thrash the cache between divergent prefixes. The main timeline keys on
|
||||||
func (b *Bot) convID(roomID string) string {
|
// roomID alone (threadRoot ""), preserving the previous value for the flat case. Carries
|
||||||
|
// no PII (ids are opaque) and is hashed to stay compact and non-identifying.
|
||||||
|
func (b *Bot) convID(roomID, threadRoot string) string {
|
||||||
if !b.cfg.GrokPromptCache {
|
if !b.cfg.GrokPromptCache {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
return fmt.Sprintf("vojo-%08x", hashString(roomID))
|
key := roomID
|
||||||
|
if threadRoot != "" {
|
||||||
|
key = roomID + "|" + threadRoot
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("vojo-%08x", hashString(key))
|
||||||
}
|
}
|
||||||
|
|
||||||
// computeUSD prices a call from the API-returned token usage (authoritative
|
// computeUSD prices a call from the API-returned token usage (authoritative
|
||||||
|
|
@ -606,23 +636,24 @@ func (b *Bot) reactEncryptedOnce(ctx context.Context, roomID, eventID string) bo
|
||||||
// conversation buffer so the next turn has context. It RETURNS the send error so the
|
// conversation buffer so the next turn has context. It RETURNS the send error so the
|
||||||
// caller can handle paid silence (§8.1): a billed answer that failed to deliver must
|
// caller can handle paid silence (§8.1): a billed answer that failed to deliver must
|
||||||
// refund the slot and react, not vanish.
|
// refund the slot and react, not vanish.
|
||||||
func (b *Bot) sendReply(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
|
func (b *Bot) sendReply(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||||
if err := b.sendMessage(ctx, roomID, trigger, triggerMC, body); err != nil {
|
if err := b.sendMessage(ctx, roomID, threadRoot, trigger, triggerMC, body); err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
// Record the user trigger AND the assistant answer together, only AFTER the answer
|
// Record the user trigger AND the assistant answer together, only AFTER the answer
|
||||||
// was sent, so a failed or empty generation never leaves a dangling user turn (a
|
// was sent, so a failed or empty generation never leaves a dangling user turn (a
|
||||||
// question with no reply) in the buffer — which would skew later completions.
|
// question with no reply) in the buffer — which would skew later completions. Both go
|
||||||
// Single-flight guarantees no other turn for this room interleaves between the two.
|
// to THIS conversation's buffer (roomID,threadRoot). Per-(room,thread) single-flight
|
||||||
b.appendBuf(roomID, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
|
// guarantees no other turn for this conversation interleaves between the two.
|
||||||
b.appendBuf(roomID, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
|
b.appendBuf(roomID, threadRoot, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false})
|
||||||
|
b.appendBuf(roomID, threadRoot, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true})
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendMessage builds and sends an m.notice reply and tracks our own event id. Returns
|
// sendMessage builds and sends an m.notice reply and tracks our own event id. Returns
|
||||||
// the send error (nil on success) so the caller can detect a failed delivery.
|
// the send error (nil on success) so the caller can detect a failed delivery.
|
||||||
func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error {
|
func (b *Bot) sendMessage(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error {
|
||||||
content := buildNoticeContent(trigger.EventID, trigger.Sender, triggerMC.RelatesTo, body)
|
content := buildNoticeContent(trigger.EventID, trigger.Sender, threadRoot, body)
|
||||||
id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content)
|
id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.log.Error("send failed", "room", roomID, "err", err)
|
b.log.Error("send failed", "room", roomID, "err", err)
|
||||||
|
|
@ -633,11 +664,16 @@ func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, tr
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// startTypingKeepalive starts the typing indicator and keeps it alive for the whole
|
// startTypingKeepalive shows the room-level "typing…" indicator for the whole generation
|
||||||
// generation (the CS-API server-side typing notification expires after the 30s we
|
// and keeps it alive (the CS-API notification expires after the 30s we pass, so we refresh
|
||||||
// pass, so we refresh every 20s). The returned stop clears the indicator and is safe
|
// every 20s). The returned stop is safe to call once via defer. Typing is ROOM-scoped in
|
||||||
// to call once via defer. Typing is best-effort UX — failures are non-fatal.
|
// Matrix — there is no per-thread typing — so with per-(room,thread) concurrency several
|
||||||
|
// generations can run for one room at once. A per-room refcount keeps the indicator on
|
||||||
|
// until the LAST of them finishes, rather than letting whichever finishes first clear it
|
||||||
|
// out from under the others. Best-effort UX — failures are non-fatal.
|
||||||
func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
|
func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
|
||||||
|
b.typingAcquire(roomID)
|
||||||
|
|
||||||
b.setTyping(ctx, roomID, true)
|
b.setTyping(ctx, roomID, true)
|
||||||
done := make(chan struct{})
|
done := make(chan struct{})
|
||||||
go func() {
|
go func() {
|
||||||
|
|
@ -658,11 +694,40 @@ func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() {
|
||||||
return func() {
|
return func() {
|
||||||
once.Do(func() {
|
once.Do(func() {
|
||||||
close(done)
|
close(done)
|
||||||
b.setTyping(ctx, roomID, false)
|
// Only the last in-flight generation for the room clears the indicator; the
|
||||||
|
// others merely stop their own keepalive loop (a forgetRoom mid-flight can drop
|
||||||
|
// the counter to <=0, which the guard treats as "last" — still correct).
|
||||||
|
if b.typingRelease(roomID) {
|
||||||
|
b.setTyping(ctx, roomID, false)
|
||||||
|
}
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// typingAcquire registers one in-flight generation against the room-level typing indicator.
|
||||||
|
// The increment is the only state change; the caller (re)asserts the indicator on regardless,
|
||||||
|
// since re-sending typing=true is idempotent and refreshes the server-side 30s timeout.
|
||||||
|
func (b *Bot) typingAcquire(roomID string) {
|
||||||
|
b.mu.Lock()
|
||||||
|
b.typingRefs[roomID]++
|
||||||
|
b.mu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
|
// typingRelease drops one in-flight generation and reports whether it was the LAST one for
|
||||||
|
// the room (refcount fell to <=0), so the caller clears the indicator only then. A forgetRoom
|
||||||
|
// that already deleted the key leaves a missing entry: the decrement reads 0 and lands at -1
|
||||||
|
// (<=0), so the guard still treats it as "last" and the negative entry is deleted — no leak.
|
||||||
|
func (b *Bot) typingRelease(roomID string) (last bool) {
|
||||||
|
b.mu.Lock()
|
||||||
|
defer b.mu.Unlock()
|
||||||
|
b.typingRefs[roomID]--
|
||||||
|
last = b.typingRefs[roomID] <= 0
|
||||||
|
if last {
|
||||||
|
delete(b.typingRefs, roomID)
|
||||||
|
}
|
||||||
|
return last
|
||||||
|
}
|
||||||
|
|
||||||
// setTyping sets/clears the bot's typing indicator (best-effort UX; failures are
|
// setTyping sets/clears the bot's typing indicator (best-effort UX; failures are
|
||||||
// non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive.
|
// non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive.
|
||||||
func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
|
func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
|
||||||
|
|
@ -672,13 +737,17 @@ func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop
|
// buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop
|
||||||
// skip catches our own output. Thread-aware (F27): a trigger from a thread gets a
|
// skip catches our own output. threadRoot decides where the answer lands: when non-empty
|
||||||
// thread relation so the answer lands in the thread, not the main timeline.
|
// the answer carries an m.thread relation rooted there (F27) — either replying inside an
|
||||||
func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body string) map[string]any {
|
// existing thread or auto-rooting a NEW DM conversation on the trigger. The caller's
|
||||||
|
// resolveThreadRoot makes that choice and is DM-gated, so a group answer never gets a
|
||||||
|
// thread relation it didn't already have. When empty the answer is a plain top-level
|
||||||
|
// reply (groups, and DMs with conversations off).
|
||||||
|
func buildNoticeContent(replyTo, sender, threadRoot, body string) map[string]any {
|
||||||
relates := map[string]any{}
|
relates := map[string]any{}
|
||||||
if triggerRelates != nil && triggerRelates.RelType == "m.thread" && triggerRelates.EventID != "" {
|
if threadRoot != "" {
|
||||||
relates["rel_type"] = "m.thread"
|
relates["rel_type"] = "m.thread"
|
||||||
relates["event_id"] = triggerRelates.EventID
|
relates["event_id"] = threadRoot
|
||||||
relates["is_falling_back"] = true
|
relates["is_falling_back"] = true
|
||||||
relates["m.in_reply_to"] = map[string]any{"event_id": replyTo}
|
relates["m.in_reply_to"] = map[string]any{"event_id": replyTo}
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -701,24 +770,55 @@ func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body
|
||||||
return content
|
return content
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- per-room single-flight ----------------------------------------------------
|
// --- per-(room,thread) single-flight ---------------------------------------------
|
||||||
|
|
||||||
// tryClaim marks a room as generating and returns true if the caller won the claim
|
// resolveThreadRoot decides which conversation a trigger belongs to, returning the thread
|
||||||
// (no generation was already in flight). The loser must drop its message.
|
// root event id, or "" for the main timeline. Order: (1) a trigger already inside a thread
|
||||||
func (b *Bot) tryClaim(roomID string) bool {
|
// continues that thread; (2) in a 1:1 DM, a top-level message roots a NEW conversation on
|
||||||
|
// itself (ChatGPT-style "new chat"); (3) otherwise — EVERY group message — the main timeline
|
||||||
|
// (""). This is the single place auto-threading is decided and it is hard-gated on isDM, so
|
||||||
|
// a group is NEVER auto-threaded (the group gate is structural, not a flag). buildNoticeContent
|
||||||
|
// emits the matching m.thread relation for the same value, so the conversation we serialize on
|
||||||
|
// is always the one the answer lands in.
|
||||||
|
func (b *Bot) resolveThreadRoot(isDM bool, ev *Event, mc *MessageContent) string {
|
||||||
|
if mc.RelatesTo != nil && mc.RelatesTo.RelType == "m.thread" && mc.RelatesTo.EventID != "" {
|
||||||
|
return mc.RelatesTo.EventID
|
||||||
|
}
|
||||||
|
if isDM {
|
||||||
|
return ev.EventID
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// tryClaim marks a (room,thread) conversation as generating and returns true if the caller
|
||||||
|
// won the claim (nothing was already in flight for that exact conversation). The loser
|
||||||
|
// drops its message. Different threads of one room claim independently — a slow answer in
|
||||||
|
// one conversation never blocks another. The check-and-set is atomic under b.mu (one map,
|
||||||
|
// no per-thread mutex), so there is no lazy-lock TOCTOU.
|
||||||
|
func (b *Bot) tryClaim(roomID, threadRoot string) bool {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
if b.inflight[roomID] {
|
m := b.inflight[roomID]
|
||||||
|
if m == nil {
|
||||||
|
m = make(map[string]bool)
|
||||||
|
b.inflight[roomID] = m
|
||||||
|
}
|
||||||
|
if m[threadRoot] {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
b.inflight[roomID] = true
|
m[threadRoot] = true
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) release(roomID string) {
|
func (b *Bot) release(roomID, threadRoot string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
delete(b.inflight, roomID)
|
if m := b.inflight[roomID]; m != nil {
|
||||||
|
delete(m, threadRoot)
|
||||||
|
if len(m) == 0 {
|
||||||
|
delete(b.inflight, roomID)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- per-room metadata helpers (all guarded by b.mu; probes run outside it) -----
|
// --- per-room metadata helpers (all guarded by b.mu; probes run outside it) -----
|
||||||
|
|
@ -752,8 +852,9 @@ func (b *Bot) forgetRoom(roomID string) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
delete(b.meta, roomID)
|
delete(b.meta, roomID)
|
||||||
delete(b.buf, roomID)
|
delete(b.buf, roomID) // drops the room's whole per-thread subtree in one delete
|
||||||
delete(b.inflight, roomID)
|
delete(b.inflight, roomID) // (nested maps keyed by roomID keep forgetRoom O(1))
|
||||||
|
delete(b.typingRefs, roomID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ensureEncryption returns whether the room is encrypted, probing the CS-API once
|
// ensureEncryption returns whether the room is encrypted, probing the CS-API once
|
||||||
|
|
@ -824,28 +925,69 @@ func (b *Bot) ensureCounts(ctx context.Context, roomID string) (countsKnown, isD
|
||||||
return false, false, false
|
return false, false, false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) snapshotBuf(roomID string) []bufferedMsg {
|
// convBuf is one conversation's (one thread's) rolling context window plus a last-touch
|
||||||
|
// stamp used for LRU eviction. Without eviction, a long-lived control DM that spawns many
|
||||||
|
// ChatGPT-style conversations would accumulate one buffer per conversation for the whole
|
||||||
|
// process lifetime (forgetRoom only frees them on room leave/ban).
|
||||||
|
type convBuf struct {
|
||||||
|
msgs []bufferedMsg
|
||||||
|
touched uint64 // b.bufSeq at last append; higher = more recent
|
||||||
|
}
|
||||||
|
|
||||||
|
// maxConvBuffersPerRoom bounds how many conversation buffers a single room retains. A
|
||||||
|
// human DM rarely keeps this many live conversations at once; an evicted cold conversation
|
||||||
|
// just rebuilds its window from scratch on its next turn (same as after a restart), so the
|
||||||
|
// only cost of eviction is a one-turn cold start, never a wrong answer.
|
||||||
|
const maxConvBuffersPerRoom = 64
|
||||||
|
|
||||||
|
func (b *Bot) snapshotBuf(roomID, threadRoot string) []bufferedMsg {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
src := b.buf[roomID]
|
// A never-seen conversation has no buffer (nil inner map / nil convBuf) → nil history,
|
||||||
if len(src) == 0 {
|
// exactly what a fresh "new chat" wants (context = system + trigger).
|
||||||
|
cb := b.buf[roomID][threadRoot]
|
||||||
|
if cb == nil || len(cb.msgs) == 0 {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
out := make([]bufferedMsg, len(src))
|
out := make([]bufferedMsg, len(cb.msgs))
|
||||||
copy(out, src)
|
copy(out, cb.msgs)
|
||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
func (b *Bot) appendBuf(roomID string, msg bufferedMsg) {
|
func (b *Bot) appendBuf(roomID, threadRoot string, msg bufferedMsg) {
|
||||||
b.mu.Lock()
|
b.mu.Lock()
|
||||||
defer b.mu.Unlock()
|
defer b.mu.Unlock()
|
||||||
limit := b.cfg.MaxCtxEvent * 2
|
limit := b.cfg.MaxCtxEvent * 2
|
||||||
if limit < 8 {
|
if limit < 8 {
|
||||||
limit = 8
|
limit = 8
|
||||||
}
|
}
|
||||||
buf := append(b.buf[roomID], msg)
|
m := b.buf[roomID]
|
||||||
if len(buf) > limit {
|
if m == nil {
|
||||||
buf = buf[len(buf)-limit:]
|
m = make(map[string]*convBuf)
|
||||||
|
b.buf[roomID] = m
|
||||||
|
}
|
||||||
|
cb := m[threadRoot]
|
||||||
|
if cb == nil {
|
||||||
|
cb = &convBuf{}
|
||||||
|
m[threadRoot] = cb
|
||||||
|
}
|
||||||
|
cb.msgs = append(cb.msgs, msg)
|
||||||
|
if len(cb.msgs) > limit {
|
||||||
|
cb.msgs = cb.msgs[len(cb.msgs)-limit:]
|
||||||
|
}
|
||||||
|
b.bufSeq++
|
||||||
|
cb.touched = b.bufSeq
|
||||||
|
|
||||||
|
// LRU-evict the least-recently-touched conversation once the room exceeds the cap. The
|
||||||
|
// just-touched conversation has the highest `touched`, so it is never the victim.
|
||||||
|
if len(m) > maxConvBuffersPerRoom {
|
||||||
|
var victim string
|
||||||
|
var oldest uint64
|
||||||
|
for k, v := range m {
|
||||||
|
if victim == "" || v.touched < oldest {
|
||||||
|
victim, oldest = k, v.touched
|
||||||
|
}
|
||||||
|
}
|
||||||
|
delete(m, victim)
|
||||||
}
|
}
|
||||||
b.buf[roomID] = buf
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
158
apps/ai-bot/buffers_test.go
Normal file
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"
|
"testing"
|
||||||
)
|
)
|
||||||
|
|
||||||
// TestSingleFlightClaim documents the per-room single-flight invariant the async
|
// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the
|
||||||
// refactor relies on: at most one generation per room at a time, the claim is
|
// async refactor relies on: at most one generation per conversation at a time, the claim
|
||||||
// independent per room, and a release re-arms the room. handleEvent takes this claim
|
// is independent per (room,thread), and a release re-arms only that conversation.
|
||||||
// synchronously in transaction order, so the FIRST message for a room wins and later
|
// handleEvent takes this claim synchronously in transaction order, so the FIRST message
|
||||||
// ones are dropped until release (never the reverse).
|
// for a conversation wins and later ones are dropped until release (never the reverse).
|
||||||
func TestSingleFlightClaim(t *testing.T) {
|
func TestSingleFlightClaim(t *testing.T) {
|
||||||
b := &Bot{inflight: make(map[string]bool)}
|
b := &Bot{inflight: make(map[string]map[string]bool)}
|
||||||
|
|
||||||
if !b.tryClaim("!a") {
|
if !b.tryClaim("!a", "") {
|
||||||
t.Fatal("first claim on !a should win")
|
t.Fatal("first claim on (!a, main) should win")
|
||||||
}
|
}
|
||||||
if b.tryClaim("!a") {
|
if b.tryClaim("!a", "") {
|
||||||
t.Fatal("second claim on !a must fail while in flight")
|
t.Fatal("second claim on (!a, main) must fail while in flight")
|
||||||
}
|
}
|
||||||
if !b.tryClaim("!b") {
|
// A DIFFERENT thread in the SAME room must claim independently — the whole point of
|
||||||
|
// per-(room,thread) single-flight: a slow answer in one conversation cannot block
|
||||||
|
// another conversation in the same room.
|
||||||
|
if !b.tryClaim("!a", "$root1") {
|
||||||
|
t.Fatal("a different thread in the same room must claim independently")
|
||||||
|
}
|
||||||
|
if b.tryClaim("!a", "$root1") {
|
||||||
|
t.Fatal("second claim on (!a, $root1) must fail while in flight")
|
||||||
|
}
|
||||||
|
if !b.tryClaim("!b", "") {
|
||||||
t.Fatal("a different room must claim independently")
|
t.Fatal("a different room must claim independently")
|
||||||
}
|
}
|
||||||
b.release("!a")
|
b.release("!a", "")
|
||||||
if !b.tryClaim("!a") {
|
if !b.tryClaim("!a", "") {
|
||||||
t.Fatal("after release !a must be claimable again")
|
t.Fatal("after release (!a, main) must be claimable again")
|
||||||
|
}
|
||||||
|
// Releasing the main timeline must NOT free the thread's claim.
|
||||||
|
if b.tryClaim("!a", "$root1") {
|
||||||
|
t.Fatal("releasing (!a, main) must not free (!a, $root1)")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
|
// TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same
|
||||||
// room and asserts EXACTLY ONE wins the claim — the property that prevents two
|
// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent
|
||||||
// concurrent generations (double xAI spend) for one room. Run under -race.
|
// generations (double xAI spend) for one conversation. It also races two DIFFERENT
|
||||||
|
// threads of one room together and asserts each has its own single winner, proving the
|
||||||
|
// claim is independent per (room,thread), not per room. Run under -race.
|
||||||
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
|
func TestSingleFlightClaimExactlyOneWinner(t *testing.T) {
|
||||||
b := &Bot{inflight: make(map[string]bool)}
|
b := &Bot{inflight: make(map[string]map[string]bool)}
|
||||||
const n = 64
|
const n = 64
|
||||||
var wins int64
|
var sameWins, threadAWins, threadBWins int64
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
wg.Add(n)
|
wg.Add(n * 3)
|
||||||
for i := 0; i < n; i++ {
|
for i := 0; i < n; i++ {
|
||||||
go func() {
|
go func() {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
if b.tryClaim("!room") {
|
if b.tryClaim("!room", "$same") {
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
wins++
|
sameWins++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if b.tryClaim("!room", "$a") {
|
||||||
|
mu.Lock()
|
||||||
|
threadAWins++
|
||||||
|
mu.Unlock()
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
go func() {
|
||||||
|
defer wg.Done()
|
||||||
|
if b.tryClaim("!room", "$b") {
|
||||||
|
mu.Lock()
|
||||||
|
threadBWins++
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
if wins != 1 {
|
if sameWins != 1 {
|
||||||
t.Fatalf("exactly one goroutine must win the claim, got %d", wins)
|
t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins)
|
||||||
|
}
|
||||||
|
if threadAWins != 1 {
|
||||||
|
t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins)
|
||||||
|
}
|
||||||
|
if threadBWins != 1 {
|
||||||
|
t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ func TestMarkdownAdversarialNoPanicNoInjection(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
|
func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
|
||||||
c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "Here is **bold**.")
|
c := buildNoticeContent("$evt", "@u:vojo.chat", "", "Here is **bold**.")
|
||||||
if c["format"] != matrixHTMLFormat {
|
if c["format"] != matrixHTMLFormat {
|
||||||
t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat)
|
t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat)
|
||||||
}
|
}
|
||||||
|
|
@ -135,7 +135,7 @@ func TestBuildNoticeContentAttachesFormatted(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) {
|
func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) {
|
||||||
c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "no markdown here")
|
c := buildNoticeContent("$evt", "@u:vojo.chat", "", "no markdown here")
|
||||||
if _, ok := c["format"]; ok {
|
if _, ok := c["format"]; ok {
|
||||||
t.Fatalf("format must be absent for plain text")
|
t.Fatalf("format must be absent for plain text")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
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",
|
"mxid": "@ai:vojo.chat",
|
||||||
"name": "Vojo AI",
|
"name": "Vojo AI",
|
||||||
"experience": {
|
"experience": {
|
||||||
"type": "matrix-widget",
|
"type": "ai-chat"
|
||||||
"url": "https://widgets.vojo.chat/vojo-ai/index.html",
|
|
||||||
"capabilities": ["vojo.add_to_chat"]
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
|
|
|
||||||
|
|
@ -15,9 +15,28 @@ Synapse pushes a transaction → the bot **acks 200 instantly, then processes as
|
||||||
([appservice.go](../../apps/ai-bot/appservice.go)), so a slow model call never blocks other
|
([appservice.go](../../apps/ai-bot/appservice.go)), so a slow model call never blocks other
|
||||||
rooms or the homeserver. `handleMessage` ([bot.go](../../apps/ai-bot/bot.go)) gates in order:
|
rooms or the homeserver. `handleMessage` ([bot.go](../../apps/ai-bot/bot.go)) gates in order:
|
||||||
durable+in-memory dedup → encrypted-room skip → decode / edit / own-message / notice →
|
durable+in-memory dedup → encrypted-room skip → decode / edit / own-message / notice →
|
||||||
foreign-server leave → DM-or-mention → media react → **per-room single-flight** → spawn
|
foreign-server leave → DM-or-mention → media react → resolve conversation (thread) →
|
||||||
`respond`. `respond` = `Reserve(estimate)` → `generate()` → `Settle(actual)` → `sendReply`;
|
**per-(room,thread) single-flight** → spawn `respond`. `respond` = `Reserve(estimate)` →
|
||||||
**any failure produces an emoji react, never silence.**
|
`generate()` → `Settle(actual)` → `sendReply`; **any failure produces an emoji react, never silence.**
|
||||||
|
|
||||||
|
## Conversations (threads) — ChatGPT-style multi-chat
|
||||||
|
|
||||||
|
In a 1:1 DM a top-level message **roots a new thread** (a fresh conversation) and the bot answers
|
||||||
|
inside it ([bot.go](../../apps/ai-bot/bot.go) `resolveThreadRoot`); a message already in a thread
|
||||||
|
continues it (F27). **Groups are never auto-threaded** — the gate is structural (`isDM`), not a
|
||||||
|
flag, so the threading feature can never change group behavior. Auto-threading in DMs is **always
|
||||||
|
on** (the old `THREAD_CONVERSATIONS` env flag was removed — it only created a host/backend
|
||||||
|
mismatch footgun). Context and single-flight are keyed per-`(room, thread)` so conversations
|
||||||
|
neither share history nor block each other; typing is room-level (Matrix has no per-thread typing)
|
||||||
|
via a refcount; per-thread context buffers are LRU-bounded (`maxConvBuffersPerRoom`).
|
||||||
|
|
||||||
|
**Host pairing:** the cinny host shows the conversation surface for a bot only when its
|
||||||
|
`config.json` preset has `experience.type: "ai-chat"` (today only `@ai`). That surface is a
|
||||||
|
**fully isolated, native in-client chat** (`features/bots/BotConversations` + `AiChatHeader` +
|
||||||
|
`AiChatMenu`, reusing the generic `ThreadDrawer`/`RoomInput`) — it shares **no** runtime with the
|
||||||
|
bridge widget pipeline (no `BotShell`/iframe, no `show-chat` toggle; there is no `vojo-ai` widget
|
||||||
|
any more). Bridges keep `experience.type: "matrix-widget"` (iframe + show-chat fallback). Because
|
||||||
|
the backend now always threads DMs, any DM message @ai answers lands in a thread the host can open.
|
||||||
|
|
||||||
## Cascade (flag-gated "operator cascade", every layer default OFF)
|
## Cascade (flag-gated "operator cascade", every layer default OFF)
|
||||||
|
|
||||||
|
|
@ -51,7 +70,7 @@ adapters [provider_xai.go](../../apps/ai-bot/provider_xai.go) /
|
||||||
a panic releases via a deferred guard.
|
a panic releases via a deferred guard.
|
||||||
- Caps: `DAILY_USD_CEILING` (global $), `PER_USER_DAILY_CAP` (requests/user), `PER_USER_DAILY_USD`
|
- Caps: `DAILY_USD_CEILING` (global $), `PER_USER_DAILY_CAP` (requests/user), `PER_USER_DAILY_USD`
|
||||||
(optional $/user). **at-most-once** dedup is durable (`SeenEvent`/`MarkTxn`); generation is
|
(optional $/user). **at-most-once** dedup is durable (`SeenEvent`/`MarkTxn`); generation is
|
||||||
per-room single-flight.
|
per-(room,thread) single-flight.
|
||||||
- One overall **per-request deadline** bounds the whole cascade (no per-stage 3×60s accretion).
|
- One overall **per-request deadline** bounds the whole cascade (no per-stage 3×60s accretion).
|
||||||
- **Telemetry:** one `request_log` row per engaged request (route, per-component $, latency,
|
- **Telemetry:** one `request_log` row per engaged request (route, per-component $, latency,
|
||||||
degrade reasons), written async + isolated (its failure never drops a reply), `TELEMETRY_ENABLED`
|
degrade reasons), written async + isolated (its failure never drops a reply), `TELEMETRY_ENABLED`
|
||||||
|
|
|
||||||
|
|
@ -942,6 +942,17 @@
|
||||||
"show_widget": "Show robot",
|
"show_widget": "Show robot",
|
||||||
"retry_widget": "Retry robot",
|
"retry_widget": "Retry robot",
|
||||||
"more_options": "More",
|
"more_options": "More",
|
||||||
|
"conversations": {
|
||||||
|
"title": "Chats",
|
||||||
|
"new_chat": "New chat",
|
||||||
|
"empty": "No conversations yet.",
|
||||||
|
"start_first": "Start your first chat",
|
||||||
|
"untitled": "Untitled chat",
|
||||||
|
"back": "Back to chats",
|
||||||
|
"new_chat_hint": "Ask anything to start a new conversation.",
|
||||||
|
"composer_placeholder": "Message Vojo AI…",
|
||||||
|
"send": "Send"
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
|
"telegram": "Connect Telegram to Vojo: private chats and groups appear in the chat list, and replies from the Vojo app are sent as normal Telegram messages.",
|
||||||
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",
|
"discord": "Connect Discord to Vojo: DMs and servers appear in the chat list, and replies from the Vojo app are sent as normal Discord messages. Sign-in uses a QR code from the Discord mobile app.",
|
||||||
|
|
|
||||||
|
|
@ -960,6 +960,17 @@
|
||||||
"show_widget": "Показать робота",
|
"show_widget": "Показать робота",
|
||||||
"retry_widget": "Повторить",
|
"retry_widget": "Повторить",
|
||||||
"more_options": "Ещё",
|
"more_options": "Ещё",
|
||||||
|
"conversations": {
|
||||||
|
"title": "Чаты",
|
||||||
|
"new_chat": "Новый чат",
|
||||||
|
"empty": "Пока нет бесед.",
|
||||||
|
"start_first": "Начать первый чат",
|
||||||
|
"untitled": "Без названия",
|
||||||
|
"back": "К списку чатов",
|
||||||
|
"new_chat_hint": "Спросите что угодно, чтобы начать новую беседу.",
|
||||||
|
"composer_placeholder": "Сообщение для Vojo AI…",
|
||||||
|
"send": "Отправить"
|
||||||
|
},
|
||||||
"description": {
|
"description": {
|
||||||
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
"telegram": "Подключите Telegram к Vojo: личные чаты и группы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Telegram как обычные сообщения.",
|
||||||
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
|
"discord": "Подключите Discord к Vojo: личные чаты и серверы появятся в списке чатов, а ответы из приложения Vojo будут отправляться в Discord как обычные сообщения. Вход — через QR-код из мобильного Discord.",
|
||||||
|
|
|
||||||
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 { Theme } from '../../hooks/useTheme';
|
||||||
import { openExternalUrl } from '../../utils/capacitor';
|
import { openExternalUrl } from '../../utils/capacitor';
|
||||||
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
|
import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog';
|
import { BOT_CAP_ADD_TO_CHAT, isWidgetExperience, type BotPreset } from './catalog';
|
||||||
import {
|
import {
|
||||||
BotWidgetDriver,
|
BotWidgetDriver,
|
||||||
sanitizeBotWidgetMessageEvent,
|
sanitizeBotWidgetMessageEvent,
|
||||||
|
|
@ -61,7 +61,8 @@ const getBotWidgetUrl = (
|
||||||
language: string,
|
language: string,
|
||||||
widgetId: string
|
widgetId: string
|
||||||
): string => {
|
): string => {
|
||||||
if (!preset.experience) throw new Error('Bot widget experience is not configured');
|
if (!isWidgetExperience(preset.experience))
|
||||||
|
throw new Error('Bot widget experience is not configured');
|
||||||
|
|
||||||
const url = new URL(preset.experience.url, window.location.origin);
|
const url = new URL(preset.experience.url, window.location.origin);
|
||||||
url.searchParams.set('widgetId', widgetId);
|
url.searchParams.set('widgetId', widgetId);
|
||||||
|
|
@ -306,7 +307,9 @@ export class BotWidgetEmbed {
|
||||||
// capability from the trusted config (`preset.experience.capabilities`),
|
// capability from the trusted config (`preset.experience.capabilities`),
|
||||||
// never from the widget message; the invitee is `preset.mxid`, host-set.
|
// never from the widget message; the invitee is `preset.mxid`, host-set.
|
||||||
if (msg.action === 'add-to-chat') {
|
if (msg.action === 'add-to-chat') {
|
||||||
if (!this.options.preset.experience?.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) return;
|
const { experience } = this.options.preset;
|
||||||
|
if (!isWidgetExperience(experience) || !experience.capabilities.includes(BOT_CAP_ADD_TO_CHAT))
|
||||||
|
return;
|
||||||
this.options.onAddToChat?.();
|
this.options.onAddToChat?.();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,14 @@ import { useMemo } from 'react';
|
||||||
import { useClientConfig } from '../../hooks/useClientConfig';
|
import { useClientConfig } from '../../hooks/useClientConfig';
|
||||||
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig';
|
||||||
|
|
||||||
export type BotExperience = {
|
// A bot's experience is a discriminated union on `type`:
|
||||||
|
// - 'matrix-widget' — a sandboxed iframe widget (the bridge bots). Carries the widget url, the
|
||||||
|
// command prefix, and the elevated-capability allowlist.
|
||||||
|
// - 'ai-chat' — the native, fully in-client ChatGPT-style conversation surface (AiBotChat). No
|
||||||
|
// widget, so no url/capabilities to carry or validate. Only first-party assistant bots use it.
|
||||||
|
// The two experiences share NO runtime pipeline: 'ai-chat' never touches BotShell / the widget
|
||||||
|
// embed / the show-chat toggle, and 'matrix-widget' never touches the conversation surface.
|
||||||
|
export type BotWidgetExperience = {
|
||||||
type: 'matrix-widget';
|
type: 'matrix-widget';
|
||||||
url: string;
|
url: string;
|
||||||
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
|
/** Command prefix the widget prepends to outbound commands (e.g. `!tg`).
|
||||||
|
|
@ -15,6 +22,22 @@ export type BotExperience = {
|
||||||
capabilities: string[];
|
capabilities: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type AiChatExperience = {
|
||||||
|
type: 'ai-chat';
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BotExperience = BotWidgetExperience | AiChatExperience;
|
||||||
|
|
||||||
|
/** True when the bot is rendered through the sandboxed iframe widget pipeline (bridges). */
|
||||||
|
export const isWidgetExperience = (
|
||||||
|
experience: BotExperience | undefined
|
||||||
|
): experience is BotWidgetExperience => experience?.type === 'matrix-widget';
|
||||||
|
|
||||||
|
/** True when the bot uses the native in-client conversation surface (no widget). */
|
||||||
|
export const isAiChatExperience = (
|
||||||
|
experience: BotExperience | undefined
|
||||||
|
): experience is AiChatExperience => experience?.type === 'ai-chat';
|
||||||
|
|
||||||
/** The only elevated side-channel verb today: lets the widget ask the host to
|
/** The only elevated side-channel verb today: lets the widget ask the host to
|
||||||
* invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The
|
* invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The
|
||||||
* widget never supplies the room or the mxid. Privilege originates from the
|
* widget never supplies the room or the mxid. Privilege originates from the
|
||||||
|
|
@ -94,6 +117,8 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset =>
|
||||||
|
|
||||||
const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
|
const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => {
|
||||||
const type = experience?.type;
|
const type = experience?.type;
|
||||||
|
// Native AI chat: no widget url / capabilities to validate, rendered fully in-client.
|
||||||
|
if (type === 'ai-chat') return { type: 'ai-chat' };
|
||||||
const url = experience?.url?.trim();
|
const url = experience?.url?.trim();
|
||||||
if (type !== 'matrix-widget' || !url) return undefined;
|
if (type !== 'matrix-widget' || !url) return undefined;
|
||||||
if (url.startsWith('//')) return undefined;
|
if (url.startsWith('//')) return undefined;
|
||||||
|
|
|
||||||
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 { useMatrixClient } from '../../hooks/useMatrixClient';
|
||||||
import { Theme, useTheme } from '../../hooks/useTheme';
|
import { Theme, useTheme } from '../../hooks/useTheme';
|
||||||
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
import type { MatrixToRoom } from '../../plugins/matrix-to';
|
||||||
import type { BotPreset } from './catalog';
|
import { isWidgetExperience, type BotPreset } from './catalog';
|
||||||
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
import { BotWidgetEmbed } from './BotWidgetEmbed';
|
||||||
|
|
||||||
type UseBotWidgetEmbedOptions = {
|
type UseBotWidgetEmbedOptions = {
|
||||||
|
|
@ -69,7 +69,7 @@ export const useBotWidgetEmbed = ({
|
||||||
// every time `useBotPresets` returns a fresh memo, e.g. after a runtime
|
// every time `useBotPresets` returns a fresh memo, e.g. after a runtime
|
||||||
// config refresh.
|
// config refresh.
|
||||||
const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset;
|
const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset;
|
||||||
const experienceUrl = experience?.url;
|
const experienceUrl = isWidgetExperience(experience) ? experience.url : undefined;
|
||||||
const experienceType = experience?.type;
|
const experienceType = experience?.type;
|
||||||
const { roomId } = room;
|
const { roomId } = room;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -199,9 +199,14 @@ interface RoomInputProps {
|
||||||
// composers leave this undefined so messages land in the main timeline
|
// composers leave this undefined so messages land in the main timeline
|
||||||
// unchanged. The drawer composer passes threadId={rootId}.
|
// unchanged. The drawer composer passes threadId={rootId}.
|
||||||
threadId?: string;
|
threadId?: string;
|
||||||
|
// Optional: fired with the real server event id once a text message send
|
||||||
|
// resolves. The AI bot's "new chat" composer uses it to navigate into the
|
||||||
|
// freshly-rooted thread (the just-sent top-level event IS the thread root).
|
||||||
|
// Channel / DM / thread composers omit it and stay fire-and-forget.
|
||||||
|
onSend?: (eventId: string) => void;
|
||||||
}
|
}
|
||||||
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => {
|
({ editor, fileDropContainerRef, roomId, room, threadId, onSend }, ref) => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const useAuthentication = useMediaAuthentication();
|
const useAuthentication = useMediaAuthentication();
|
||||||
|
|
@ -492,7 +497,10 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
content['m.relates_to'].is_falling_back = false;
|
content['m.relates_to'].is_falling_back = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
|
const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent);
|
||||||
|
if (onSend) {
|
||||||
|
pending.then((res) => onSend(res.event_id)).catch(() => undefined);
|
||||||
|
}
|
||||||
resetEditor(editor);
|
resetEditor(editor);
|
||||||
resetEditorHistory(editor);
|
resetEditorHistory(editor);
|
||||||
setReplyDraft(undefined);
|
setReplyDraft(undefined);
|
||||||
|
|
@ -501,6 +509,7 @@ export const RoomInput = forwardRef<HTMLDivElement, RoomInputProps>(
|
||||||
mx,
|
mx,
|
||||||
roomId,
|
roomId,
|
||||||
threadId,
|
threadId,
|
||||||
|
onSend,
|
||||||
editor,
|
editor,
|
||||||
replyDraft,
|
replyDraft,
|
||||||
sendTypingStatus,
|
sendTypingStatus,
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import { style } from '@vanilla-extract/css';
|
import { keyframes, style } from '@vanilla-extract/css';
|
||||||
import { color, config, toRem } from 'folds';
|
import { color, config, toRem } from 'folds';
|
||||||
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe';
|
||||||
|
|
||||||
|
|
@ -169,6 +169,16 @@ export const ThreadDrawerContent = style({
|
||||||
paddingBottom: config.space.S400,
|
paddingBottom: config.space.S400,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Assistant transcript variant: the channels `<Message>` hover action bar (which the S700 top
|
||||||
|
// pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be
|
||||||
|
// dead space under the header. Tighten it to a small, comfortable gap.
|
||||||
|
export const ThreadDrawerContentAssistant = style([
|
||||||
|
ThreadDrawerContent,
|
||||||
|
{
|
||||||
|
paddingTop: config.space.S400,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
export const ThreadDivider = style({
|
export const ThreadDivider = style({
|
||||||
height: 1,
|
height: 1,
|
||||||
backgroundColor: color.Surface.ContainerLine,
|
backgroundColor: color.Surface.ContainerLine,
|
||||||
|
|
@ -250,3 +260,67 @@ export const ThreadErrorState = style({
|
||||||
alignItems: 'center',
|
alignItems: 'center',
|
||||||
gap: config.space.S300,
|
gap: config.space.S300,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// --- Assistant (ChatGPT-style) transcript -----------------------------------------
|
||||||
|
// Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface).
|
||||||
|
// The bot's turn is full-width plain text; the user's turn is a right-aligned bubble.
|
||||||
|
|
||||||
|
// Bot reply: full-width, no avatar/name/timestamp. Just the rendered body on the surface.
|
||||||
|
export const AssistantBotRow = style({
|
||||||
|
width: '100%',
|
||||||
|
padding: `0 ${config.space.S400}`,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// User turn: right-aligned row holding a bubble.
|
||||||
|
export const AssistantUserRow = style({
|
||||||
|
display: 'flex',
|
||||||
|
justifyContent: 'flex-end',
|
||||||
|
padding: `0 ${config.space.S400}`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const AssistantUserBubble = style({
|
||||||
|
maxWidth: '85%',
|
||||||
|
minWidth: 0,
|
||||||
|
padding: `${config.space.S200} ${config.space.S400}`,
|
||||||
|
borderRadius: config.radii.R400,
|
||||||
|
// Match the input-form tone (RoomView.css `ChatComposer .Editor` = Surface.Container, the dark
|
||||||
|
// card the user types into) rather than the brand lavender, so a sent bubble reads as the same
|
||||||
|
// surface as the composer it came from.
|
||||||
|
backgroundColor: color.Surface.Container,
|
||||||
|
color: color.Surface.OnContainer,
|
||||||
|
});
|
||||||
|
|
||||||
|
// "Bot is typing" dots, shown after the last turn while the bot composes a reply.
|
||||||
|
const typingBlink = keyframes({
|
||||||
|
'0%, 80%, 100%': { opacity: 0.2, transform: 'translateY(0)' },
|
||||||
|
'40%': { opacity: 1, transform: `translateY(-${toRem(2)})` },
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TypingDots = style({
|
||||||
|
display: 'inline-flex',
|
||||||
|
alignItems: 'center',
|
||||||
|
gap: toRem(4),
|
||||||
|
padding: `${config.space.S200} 0`,
|
||||||
|
});
|
||||||
|
|
||||||
|
export const TypingDot = style({
|
||||||
|
width: toRem(6),
|
||||||
|
height: toRem(6),
|
||||||
|
borderRadius: '50%',
|
||||||
|
backgroundColor: color.Surface.OnContainer,
|
||||||
|
opacity: 0.2,
|
||||||
|
animationName: typingBlink,
|
||||||
|
animationDuration: '1.2s',
|
||||||
|
animationIterationCount: 'infinite',
|
||||||
|
selectors: {
|
||||||
|
'&:nth-child(2)': { animationDelay: '0.2s' },
|
||||||
|
'&:nth-child(3)': { animationDelay: '0.4s' },
|
||||||
|
},
|
||||||
|
'@media': {
|
||||||
|
'(prefers-reduced-motion: reduce)': {
|
||||||
|
animationName: 'none',
|
||||||
|
opacity: 0.5,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, co
|
||||||
import {
|
import {
|
||||||
Direction,
|
Direction,
|
||||||
EventTimeline,
|
EventTimeline,
|
||||||
|
EventTimelineSet,
|
||||||
EventType,
|
EventType,
|
||||||
MatrixEvent,
|
MatrixEvent,
|
||||||
MatrixEventEvent,
|
MatrixEventEvent,
|
||||||
|
|
@ -61,6 +62,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler';
|
||||||
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler';
|
||||||
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
import { useChannelsMode } from '../../hooks/useChannelsMode';
|
||||||
import { useIsOneOnOne } from '../../hooks/useRoom';
|
import { useIsOneOnOne } from '../../hooks/useRoom';
|
||||||
|
import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers';
|
||||||
import { useTheme } from '../../hooks/useTheme';
|
import { useTheme } from '../../hooks/useTheme';
|
||||||
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
import { useImagePackRooms } from '../../hooks/useImagePackRooms';
|
||||||
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag';
|
||||||
|
|
@ -121,6 +123,19 @@ type ThreadDrawerProps = {
|
||||||
// sitting beside the timeline. Layout is otherwise identical so the
|
// sitting beside the timeline. Layout is otherwise identical so the
|
||||||
// M5 mobile-stack work doesn't need to fork the component.
|
// M5 mobile-stack work doesn't need to fork the component.
|
||||||
variant: 'desktop' | 'mobile';
|
variant: 'desktop' | 'mobile';
|
||||||
|
// When true, suppress the drawer's own thread header (the «ТРЕД / в
|
||||||
|
// #<room>» caption + ✕ close). The bot conversation surface mounts an
|
||||||
|
// outer hero + back-row that owns the chrome, so the channels-oriented
|
||||||
|
// header would be foreign there. Defaults to false → channels usage is
|
||||||
|
// unchanged. The caller is then responsible for a back affordance
|
||||||
|
// (the ✕'s `close()` navigation lives in the header we drop here).
|
||||||
|
hideHeader?: boolean;
|
||||||
|
// 'assistant' renders a ChatGPT-style transcript instead of the channel chrome: the bot's
|
||||||
|
// turns are full-width plain text (no avatar / name / timestamp / hover-menu / reactions),
|
||||||
|
// the user's turns are right-aligned bubbles, and a typing-dots indicator shows while the
|
||||||
|
// other member (the bot) is typing. Used by the AI bot conversation surface. Defaults to
|
||||||
|
// 'default' (the channels Message rendering), so existing callers are unchanged.
|
||||||
|
messageStyle?: 'default' | 'assistant';
|
||||||
};
|
};
|
||||||
|
|
||||||
// M4a: thread events render through the shared `<Message>` component
|
// M4a: thread events render through the shared `<Message>` component
|
||||||
|
|
@ -135,7 +150,15 @@ type ThreadDrawerProps = {
|
||||||
// empty). The component is intentionally light on chrome compared to
|
// empty). The component is intentionally light on chrome compared to
|
||||||
// the full Message renderer in the timeline: M2 is MVP and rich
|
// the full Message renderer in the timeline: M2 is MVP and rich
|
||||||
// reactions / hover-menus / rail-dot inside the drawer is M9 territory.
|
// reactions / hover-menus / rail-dot inside the drawer is M9 territory.
|
||||||
export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDrawerProps) {
|
export function ThreadDrawer({
|
||||||
|
room,
|
||||||
|
rootId,
|
||||||
|
parentRoomPath,
|
||||||
|
variant,
|
||||||
|
hideHeader = false,
|
||||||
|
messageStyle = 'default',
|
||||||
|
}: ThreadDrawerProps) {
|
||||||
|
const assistantStyle = messageStyle === 'assistant';
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const mx = useMatrixClient();
|
const mx = useMatrixClient();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
@ -341,6 +364,10 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
const isOneOnOne = useIsOneOnOne();
|
const isOneOnOne = useIsOneOnOne();
|
||||||
const channelsMode = useChannelsMode();
|
const channelsMode = useChannelsMode();
|
||||||
const isBridged = channelsMode && isBridgedRoom(room);
|
const isBridged = channelsMode && isBridgedRoom(room);
|
||||||
|
// Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any
|
||||||
|
// non-self typing receipt means the bot is composing a reply. Drives the typing-dots row.
|
||||||
|
const typingReceipts = useRoomTypingMember(room.roomId);
|
||||||
|
const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId());
|
||||||
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools');
|
||||||
|
|
||||||
const roomToParents = useAtomValue(roomToParentsAtom);
|
const roomToParents = useAtomValue(roomToParentsAtom);
|
||||||
|
|
@ -898,6 +925,19 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, [repliesCount, myUserId, tryMarkThreadRead]);
|
}, [repliesCount, myUserId, tryMarkThreadRead]);
|
||||||
|
|
||||||
|
// Assistant typing-dots follow. The "bot is typing" row renders INSIDE the scroll, after the
|
||||||
|
// last reply. When the bot starts composing while we're parked at the bottom, those dots add
|
||||||
|
// height below the fold, the bottom sentinel scrolls out, and the IntersectionObserver flips
|
||||||
|
// isAtBottomRef false — which would then make the growth effect suppress the bot's (non-own)
|
||||||
|
// reply. So snap to the new bottom when the dots appear (pre-paint, so the sentinel never
|
||||||
|
// leaves view). Only when already at-bottom — a user reading older replies is never yanked down.
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
if (!botTyping) return;
|
||||||
|
const host = scrollHostRef.current;
|
||||||
|
if (!host || !isAtBottomRef.current) return;
|
||||||
|
host.scrollTop = host.scrollHeight;
|
||||||
|
}, [botTyping]);
|
||||||
|
|
||||||
// Stay at-bottom when the drawer scroll container resizes — Android
|
// Stay at-bottom when the drawer scroll container resizes — Android
|
||||||
// soft-keyboard open/close shrinks the viewport, and Capacitor's
|
// soft-keyboard open/close shrinks the viewport, and Capacitor's
|
||||||
// default `windowSoftInputMode=adjustResize` shrinks the WebView
|
// default `windowSoftInputMode=adjustResize` shrinks the WebView
|
||||||
|
|
@ -954,6 +994,69 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
navigate(parentRoomPath, { replace: true });
|
navigate(parentRoomPath, { replace: true });
|
||||||
}, [navigate, parentRoomPath]);
|
}, [navigate, parentRoomPath]);
|
||||||
|
|
||||||
|
// ChatGPT-style row: the bot's reply is full-width plain text (no avatar / name / timestamp /
|
||||||
|
// hover-menu / reactions); the user's turn is a right-aligned bubble. Body is rendered through
|
||||||
|
// the shared RenderMessageContent (markdown/html/media) so links, code, and edits behave like
|
||||||
|
// anywhere else — only the surrounding Message chrome is dropped.
|
||||||
|
const renderAssistantEvent = (
|
||||||
|
mEvent: MatrixEvent,
|
||||||
|
eventId: string,
|
||||||
|
timelineSet: EventTimelineSet
|
||||||
|
) => {
|
||||||
|
const isOwn = mEvent.getSender() === mx.getUserId();
|
||||||
|
const senderId = mEvent.getSender() ?? '';
|
||||||
|
const senderDisplayName =
|
||||||
|
getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId;
|
||||||
|
const eventType = mEvent.getType();
|
||||||
|
|
||||||
|
const body = (() => {
|
||||||
|
if (mEvent.isRedacted()) {
|
||||||
|
return <RedactedContent reason={mEvent.getUnsigned().redacted_because?.content?.reason} />;
|
||||||
|
}
|
||||||
|
if (eventType === MessageEvent.RoomMessage) {
|
||||||
|
const editedEvent = getEditedEvent(eventId, mEvent, timelineSet);
|
||||||
|
const getContent = (() =>
|
||||||
|
editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback;
|
||||||
|
return (
|
||||||
|
<RenderMessageContent
|
||||||
|
displayName={senderDisplayName}
|
||||||
|
msgType={mEvent.getContent().msgtype ?? ''}
|
||||||
|
ts={mEvent.getTs()}
|
||||||
|
edited={!!editedEvent}
|
||||||
|
getContent={getContent}
|
||||||
|
mediaAutoLoad={mediaAutoLoad}
|
||||||
|
urlPreview={showUrlPreview}
|
||||||
|
htmlReactParserOptions={htmlReactParserOptions}
|
||||||
|
linkifyOpts={linkifyOpts}
|
||||||
|
outlineAttachment
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (eventType === MessageEvent.RoomMessageEncrypted) {
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageNotDecryptedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Text>
|
||||||
|
<MessageUnsupportedContent />
|
||||||
|
</Text>
|
||||||
|
);
|
||||||
|
})();
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={eventId}
|
||||||
|
data-message-id={eventId}
|
||||||
|
className={isOwn ? css.AssistantUserRow : css.AssistantBotRow}
|
||||||
|
>
|
||||||
|
{isOwn ? <div className={css.AssistantUserBubble}>{body}</div> : body}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const renderThreadEvent = (mEvent: MatrixEvent) => {
|
const renderThreadEvent = (mEvent: MatrixEvent) => {
|
||||||
const eventId = mEvent.getId();
|
const eventId = mEvent.getId();
|
||||||
if (!eventId) return null;
|
if (!eventId) return null;
|
||||||
|
|
@ -973,6 +1076,11 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
|
const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId;
|
||||||
const timelineSet =
|
const timelineSet =
|
||||||
isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
|
isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet();
|
||||||
|
// Assistant transcript: strip all the channel chrome and render bot = full-width text,
|
||||||
|
// user = right-aligned bubble. Reactions/edits-as-aggregations machinery below is skipped.
|
||||||
|
if (assistantStyle) {
|
||||||
|
return renderAssistantEvent(mEvent, eventId, timelineSet);
|
||||||
|
}
|
||||||
const reactionRelations = getEventReactions(timelineSet, eventId);
|
const reactionRelations = getEventReactions(timelineSet, eventId);
|
||||||
const reactionList = reactionRelations?.getSortedAnnotationsByKey();
|
const reactionList = reactionRelations?.getSortedAnnotationsByKey();
|
||||||
const hasReactions = reactionList && reactionList.length > 0;
|
const hasReactions = reactionList && reactionList.length > 0;
|
||||||
|
|
@ -1164,26 +1272,29 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={css.ThreadDrawerContent}>
|
<div className={assistantStyle ? css.ThreadDrawerContentAssistant : css.ThreadDrawerContent}>
|
||||||
{renderThreadEvent(rootEvent)}
|
{renderThreadEvent(rootEvent)}
|
||||||
{(() => {
|
{/* Channel-style root↔replies divider / counter. Skipped in assistant mode, which
|
||||||
// Counter prefers the larger of materialized `thread.length`
|
renders a continuous ChatGPT-style transcript with no "N replies" affordance. */}
|
||||||
// (replyCount + pending) and loaded `replies.length` — covers
|
{!assistantStyle &&
|
||||||
// the cold-load window where the SDK can synthesize an empty
|
(() => {
|
||||||
// Thread (length=0) shortly before the relations fetch lands
|
// Counter prefers the larger of materialized `thread.length`
|
||||||
// a populated `replies` list (`??` would have collapsed to 0
|
// (replyCount + pending) and loaded `replies.length` — covers
|
||||||
// and silently shown the divider despite present replies).
|
// the cold-load window where the SDK can synthesize an empty
|
||||||
const counterCount = Math.max(thread?.length ?? 0, replies.length);
|
// Thread (length=0) shortly before the relations fetch lands
|
||||||
if (counterCount === 0) return <div className={css.ThreadDivider} />;
|
// a populated `replies` list (`??` would have collapsed to 0
|
||||||
return (
|
// and silently shown the divider despite present replies).
|
||||||
<div className={css.ThreadCounterRow} aria-hidden>
|
const counterCount = Math.max(thread?.length ?? 0, replies.length);
|
||||||
<span className={css.ThreadCounterDot} />
|
if (counterCount === 0) return <div className={css.ThreadDivider} />;
|
||||||
<span className={css.ThreadCounterText}>
|
return (
|
||||||
{t('Room.thread_summary_count', { count: counterCount })}
|
<div className={css.ThreadCounterRow} aria-hidden>
|
||||||
</span>
|
<span className={css.ThreadCounterDot} />
|
||||||
</div>
|
<span className={css.ThreadCounterText}>
|
||||||
);
|
{t('Room.thread_summary_count', { count: counterCount })}
|
||||||
})()}
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
{coldLoadError && (
|
{coldLoadError && (
|
||||||
<div className={css.ThreadErrorState}>
|
<div className={css.ThreadErrorState}>
|
||||||
<Text size="T300" align="Center" priority="400">
|
<Text size="T300" align="Center" priority="400">
|
||||||
|
|
@ -1214,7 +1325,8 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{thread &&
|
{!assistantStyle &&
|
||||||
|
thread &&
|
||||||
!coldLoadError &&
|
!coldLoadError &&
|
||||||
!coldLoadFetching &&
|
!coldLoadFetching &&
|
||||||
!paginating &&
|
!paginating &&
|
||||||
|
|
@ -1238,6 +1350,15 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
</Box>
|
</Box>
|
||||||
)}
|
)}
|
||||||
{replies.map((reply) => renderThreadEvent(reply))}
|
{replies.map((reply) => renderThreadEvent(reply))}
|
||||||
|
{botTyping && (
|
||||||
|
<div className={css.AssistantBotRow}>
|
||||||
|
<div className={css.TypingDots} aria-label={t('Room.is_typing')}>
|
||||||
|
<span className={css.TypingDot} />
|
||||||
|
<span className={css.TypingDot} />
|
||||||
|
<span className={css.TypingDot} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div ref={setBottomSentinel} />
|
<div ref={setBottomSentinel} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
@ -1248,30 +1369,32 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
|
||||||
ref={fileDropContainerRef}
|
ref={fileDropContainerRef}
|
||||||
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
|
className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile}
|
||||||
role="region"
|
role="region"
|
||||||
aria-labelledby={headerId}
|
aria-labelledby={hideHeader ? undefined : headerId}
|
||||||
>
|
>
|
||||||
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
{!hideHeader && (
|
||||||
<Box grow="Yes" alignItems="Center" gap="200">
|
<Header className={css.ThreadDrawerHeader} variant="Background" size="600">
|
||||||
<Box grow="Yes" direction="Column" id={headerId}>
|
<Box grow="Yes" alignItems="Center" gap="200">
|
||||||
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
<Box grow="Yes" direction="Column" id={headerId}>
|
||||||
{t('Room.thread_caption')}
|
<Text className={css.ThreadDrawerHeaderCaption} as="span" size="T200">
|
||||||
</Text>
|
{t('Room.thread_caption')}
|
||||||
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
</Text>
|
||||||
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
|
<Text className={css.ThreadDrawerHeaderSubtitle} as="span" size="T200" truncate>
|
||||||
</Text>
|
{t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })}
|
||||||
|
</Text>
|
||||||
|
</Box>
|
||||||
|
<Box shrink="No" alignItems="Center">
|
||||||
|
<IconButton
|
||||||
|
ref={closeBtnRef}
|
||||||
|
variant="Background"
|
||||||
|
onClick={close}
|
||||||
|
aria-label={t('Room.thread_close')}
|
||||||
|
>
|
||||||
|
<Icon src={Icons.Cross} />
|
||||||
|
</IconButton>
|
||||||
|
</Box>
|
||||||
</Box>
|
</Box>
|
||||||
<Box shrink="No" alignItems="Center">
|
</Header>
|
||||||
<IconButton
|
)}
|
||||||
ref={closeBtnRef}
|
|
||||||
variant="Background"
|
|
||||||
onClick={close}
|
|
||||||
aria-label={t('Room.thread_close')}
|
|
||||||
>
|
|
||||||
<Icon src={Icons.Cross} />
|
|
||||||
</IconButton>
|
|
||||||
</Box>
|
|
||||||
</Box>
|
|
||||||
</Header>
|
|
||||||
<div className={css.ThreadDrawerScroll}>
|
<div className={css.ThreadDrawerScroll}>
|
||||||
<Scroll
|
<Scroll
|
||||||
ref={scrollHostRef}
|
ref={scrollHostRef}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,11 @@ export type BotConfig = {
|
||||||
* to i18n key `Bots.description.<id>` when absent. */
|
* to i18n key `Bots.description.<id>` when absent. */
|
||||||
description?: string;
|
description?: string;
|
||||||
experience?: {
|
experience?: {
|
||||||
|
/** `'matrix-widget'` (bridge bots — sandboxed iframe) or `'ai-chat'` (first-party
|
||||||
|
* assistant bots — native in-client conversation surface, no widget). See
|
||||||
|
* catalog.ts `normalizeBotExperience`. */
|
||||||
type?: string;
|
type?: string;
|
||||||
|
/** Widget iframe URL. Required for `'matrix-widget'`; absent for `'ai-chat'`. */
|
||||||
url?: string;
|
url?: string;
|
||||||
commandPrefix?: string;
|
commandPrefix?: string;
|
||||||
/** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).
|
/** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`).
|
||||||
|
|
|
||||||
|
|
@ -328,6 +328,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize)
|
||||||
>
|
>
|
||||||
{mobile ? null : <Route index element={<WelcomePage />} />}
|
{mobile ? null : <Route index element={<WelcomePage />} />}
|
||||||
<Route path=":botId" element={routeSuspense(<BotExperienceHost />)} />
|
<Route path=":botId" element={routeSuspense(<BotExperienceHost />)} />
|
||||||
|
{/* A single conversation (m.thread root) inside an assistant bot's control
|
||||||
|
DM. Same element as :botId — BotExperienceHost reads :rootId to open the
|
||||||
|
reused ThreadDrawer; without it the conversation list renders. */}
|
||||||
|
<Route path=":botId/thread/:rootId" element={routeSuspense(<BotExperienceHost />)} />
|
||||||
</Route>
|
</Route>
|
||||||
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
<Route path="/bots/*" element={<Navigate to={BOTS_PATH} replace />} />
|
||||||
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
|
{/* Channels segment. /channels/* is reserved before SPACE_PATH so the
|
||||||
|
|
|
||||||
|
|
@ -4,8 +4,14 @@ import { Icon, Icons } from 'folds';
|
||||||
import { useTranslation } from 'react-i18next';
|
import { useTranslation } from 'react-i18next';
|
||||||
import { useAtomValue } from 'jotai';
|
import { useAtomValue } from 'jotai';
|
||||||
import type { Room as MatrixRoom } from 'matrix-js-sdk';
|
import type { Room as MatrixRoom } from 'matrix-js-sdk';
|
||||||
import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog';
|
import {
|
||||||
|
findBotPresetById,
|
||||||
|
isAiChatExperience,
|
||||||
|
useBotPresets,
|
||||||
|
type BotPreset,
|
||||||
|
} from '../../../features/bots/catalog';
|
||||||
import { BotChatFallback } from '../../../features/bots/BotChatFallback';
|
import { BotChatFallback } from '../../../features/bots/BotChatFallback';
|
||||||
|
import { BotConversations } from '../../../features/bots/BotConversations';
|
||||||
import { BotShell } from '../../../features/bots/BotShell';
|
import { BotShell } from '../../../features/bots/BotShell';
|
||||||
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
|
import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState';
|
||||||
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
import { useBotRoom } from '../../../features/bots/useBotRoom';
|
||||||
|
|
@ -17,13 +23,23 @@ import { BotRoomProvider } from './BotRoomProvider';
|
||||||
import { BotStatePage } from './BotStatePage';
|
import { BotStatePage } from './BotStatePage';
|
||||||
import { BotUnsafeRoom } from './BotUnsafeRoom';
|
import { BotUnsafeRoom } from './BotUnsafeRoom';
|
||||||
|
|
||||||
// Branches between BotShell (widget mode, no Cinny header) and the standard
|
// Picks the bot's experience. Two fully separate worlds, decided by `experience.type`:
|
||||||
// Room layout (chat fallback) based on the per-room showChat atom. Lives
|
// - 'ai-chat' → the native conversation surface (BotConversations). Self-contained: its own
|
||||||
// inside BotRoomProvider so both arms see the same room context and so an
|
// header/menu, NO widget, NO show-chat toggle. The showChat atom and the BotShell/chat-fallback
|
||||||
// atom write from BotShellMenu (via «Показать чат») is observed here
|
// pipeline below are never reached for it.
|
||||||
// without prop-drilling.
|
// - everything else (bridges) → the sandboxed widget (BotShell) with a "Show chat" fallback to
|
||||||
|
// the standard Room layout, toggled by the per-room showChat atom (set from BotShellMenu).
|
||||||
|
// Lives inside BotRoomProvider so every arm sees the same room context.
|
||||||
function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) {
|
function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) {
|
||||||
|
// /bots/:botId/thread/:rootId carries the open conversation's thread root; bare
|
||||||
|
// /bots/:botId leaves it undefined (the new-chat landing).
|
||||||
|
const { rootId } = useParams();
|
||||||
|
// Read unconditionally (rules of hooks); only the bridge path below consumes it.
|
||||||
const showChat = useAtomValue(botShowChatAtomFamily(room.roomId));
|
const showChat = useAtomValue(botShowChatAtomFamily(room.roomId));
|
||||||
|
|
||||||
|
if (isAiChatExperience(preset.experience)) {
|
||||||
|
return <BotConversations preset={preset} room={room} rootId={rootId} />;
|
||||||
|
}
|
||||||
if (showChat) {
|
if (showChat) {
|
||||||
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
|
return <Room renderRoomView={({ eventId }) => <BotChatFallback eventId={eventId} />} />;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import { generatePath, Path } from 'react-router-dom';
|
import { generatePath, Path } from 'react-router-dom';
|
||||||
import {
|
import {
|
||||||
BOTS_BOT_PATH,
|
BOTS_BOT_PATH,
|
||||||
|
BOTS_BOT_THREAD_PATH,
|
||||||
BOTS_PATH,
|
BOTS_PATH,
|
||||||
CHANNELS_PATH,
|
CHANNELS_PATH,
|
||||||
CHANNELS_ROOM_EVENT_PATH,
|
CHANNELS_ROOM_EVENT_PATH,
|
||||||
|
|
@ -170,6 +171,11 @@ export const getSettingsPath = (page?: string): string => {
|
||||||
export const getBotsPath = (): string => BOTS_PATH;
|
export const getBotsPath = (): string => BOTS_PATH;
|
||||||
export const getBotPath = (botId: string): string =>
|
export const getBotPath = (botId: string): string =>
|
||||||
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) });
|
||||||
|
export const getBotThreadPath = (botId: string, rootId: string): string =>
|
||||||
|
generatePath(BOTS_BOT_THREAD_PATH, {
|
||||||
|
botId: encodeURIComponent(botId),
|
||||||
|
rootId: encodeURIComponent(rootId),
|
||||||
|
});
|
||||||
|
|
||||||
export const getChannelsPath = (): string => CHANNELS_PATH;
|
export const getChannelsPath = (): string => CHANNELS_PATH;
|
||||||
export const getChannelsSpacePath = (spaceIdOrAlias: string): string => {
|
export const getChannelsSpacePath = (spaceIdOrAlias: string): string => {
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,10 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart';
|
||||||
|
|
||||||
export const BOTS_PATH = '/bots/';
|
export const BOTS_PATH = '/bots/';
|
||||||
export const BOTS_BOT_PATH = '/bots/:botId/';
|
export const BOTS_BOT_PATH = '/bots/:botId/';
|
||||||
|
// A single conversation (m.thread root) inside an assistant bot's control DM. Mirrors
|
||||||
|
// CHANNELS_THREAD_PATH so the reused ThreadDrawer opens off a URL the same way it does in
|
||||||
|
// channels, but scoped under the bot route (no channelsMode, no space/room segments).
|
||||||
|
export const BOTS_BOT_THREAD_PATH = '/bots/:botId/thread/:rootId/';
|
||||||
|
|
||||||
export const CHANNELS_PATH = '/channels/';
|
export const CHANNELS_PATH = '/channels/';
|
||||||
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
|
export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/';
|
||||||
|
|
@ -95,8 +99,7 @@ export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/';
|
||||||
// optional eventId does not collide with the sibling `thread/:rootId/`
|
// optional eventId does not collide with the sibling `thread/:rootId/`
|
||||||
// sub-route. Search/inbox/mention/push permalinks land here so the timeline
|
// sub-route. Search/inbox/mention/push permalinks land here so the timeline
|
||||||
// can scroll to the cited event without dropping the user out of /channels/.
|
// can scroll to the cited event without dropping the user out of /channels/.
|
||||||
export const CHANNELS_ROOM_EVENT_PATH =
|
export const CHANNELS_ROOM_EVENT_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
|
||||||
'/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/';
|
|
||||||
export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
|
export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/';
|
||||||
|
|
||||||
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
export const SPACE_SETTINGS_PATH = '/space-settings/';
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue