diff --git a/.vscode/tasks.json b/.vscode/tasks.json index b564d15f..ee931bc4 100644 --- a/.vscode/tasks.json +++ b/.vscode/tasks.json @@ -16,7 +16,7 @@ { "label": "Deploy widgets", "type": "shell", - "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; (cd apps/widget-vojo-ai && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/vojo-ai/) & PID4=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; wait $PID4 || FAIL=1; exit $FAIL", + "command": "(cd apps/widget-telegram && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/telegram/) & PID1=$!; (cd apps/widget-discord && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/discord/) & PID2=$!; (cd apps/widget-whatsapp && npm run build && rsync -avz --delete dist/ vojo-superuser@187.127.77.124:~/vojo/widgets/whatsapp/) & PID3=$!; FAIL=0; wait $PID1 || FAIL=1; wait $PID2 || FAIL=1; wait $PID3 || FAIL=1; exit $FAIL", "group": "none", "presentation": { "reveal": "always", diff --git a/apps/ai-bot/bot.go b/apps/ai-bot/bot.go index 220e5879..60393076 100644 --- a/apps/ai-bot/bot.go +++ b/apps/ai-bot/bot.go @@ -53,12 +53,26 @@ type Bot struct { // off-lock, so several goroutines touch this shared state at once. mu is held only // for short map operations and is NEVER held across a network or xAI call — that // head-of-line hold was the root cause of the multi-minute silence. - mu sync.Mutex - seen *lruSet // event ids already handled (dedup within a session; self-locking) - botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking) - meta map[string]*roomMeta - buf map[string][]bufferedMsg - inflight map[string]bool // roomID currently generating a reply (per-room single-flight) + mu sync.Mutex + seen *lruSet // event ids already handled (dedup within a session; self-locking) + botSent *lruSet // event ids the bot itself sent (reply-parent detection; self-locking) + meta map[string]*roomMeta + // buf and inflight are keyed by roomID THEN by thread root ("" = main timeline), so + // each conversation (a thread) keeps an isolated context window and an independent + // single-flight claim. Two messages in different threads of one room no longer block + // each other or pollute each other's history. roomMeta stays room-level (membership + // /encryption are room facts). forgetRoom drops a room's whole subtree in one delete. + buf map[string]map[string]*convBuf + inflight map[string]map[string]bool + // bufSeq is a monotonic counter stamped onto a convBuf on every append (guarded by mu); + // it orders conversations by last activity so appendBuf can LRU-evict the coldest one + // when a room exceeds maxConvBuffersPerRoom. + bufSeq uint64 + // typingRefs counts in-flight generations per ROOM that are currently showing the + // "typing…" indicator. Matrix typing notifications are room-scoped (no thread_id in + // the CS-API), so the indicator can't be split per thread: it goes on when the first + // generation in a room starts and off only when the last one finishes (refcount). + typingRefs map[string]int } func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) { @@ -80,8 +94,9 @@ func NewBot(ctx context.Context, cfg *Config, logger *slog.Logger) (*Bot, error) seen: newLRUSet(5000), botSent: newLRUSet(5000), meta: make(map[string]*roomMeta), - buf: make(map[string][]bufferedMsg), - inflight: make(map[string]bool), + buf: make(map[string]map[string]*convBuf), + inflight: make(map[string]map[string]bool), + typingRefs: make(map[string]int), } // Build the cascade backends only for enabled layers (config already fail-fast @@ -330,19 +345,28 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) { // the language-free "I'm busy" signal. The claim is taken here, synchronously and // in transaction order, so the FIRST message for a room wins and later ones are // dropped until release — never the reverse. - if !b.tryClaim(roomID) { - b.log.Debug("drop: room busy generating", "room", roomID, "sender", ev.Sender) + // Resolve which conversation this turn belongs to: an existing thread (continue it), + // a freshly rooted DM thread (auto-thread, DM-only), or the main timeline (""). + // Groups never auto-thread; see resolveThreadRoot. + threadRoot := b.resolveThreadRoot(isDM, ev, mc) + + // Per-(room,thread) single-flight: a slow answer in one conversation no longer blocks + // another thread in the same room, and two messages in the SAME conversation still see + // exactly one winner. The claim is taken synchronously in transaction order. + if !b.tryClaim(roomID, threadRoot) { + b.log.Debug("drop: conversation busy generating", "room", roomID, "thread", threadRoot, "sender", ev.Sender) return } - // Snapshot the room history (excludes this trigger) under the claim, then run the - // slow generation in its own goroutine so this transaction's remaining events and - // other rooms are not blocked by the xAI call. respond appends the trigger+answer - // to the buffer itself, only on success (see sendReply), and releases the claim. - history := b.snapshotBuf(roomID) + // Snapshot THIS conversation's history (excludes this trigger) under the claim, then + // run the slow generation in its own goroutine so this transaction's remaining events + // and other rooms/threads are not blocked by the xAI call. respond appends the + // trigger+answer to the same per-thread buffer on success (see sendReply) and releases + // the claim. + history := b.snapshotBuf(roomID, threadRoot) b.safego("respond", func() { - defer b.release(roomID) - b.respond(ctx, roomID, isDM, ev, mc, history) + defer b.release(roomID, threadRoot) + b.respond(ctx, roomID, threadRoot, isDM, ev, mc, history) }) } @@ -350,7 +374,7 @@ func (b *Bot) handleMessage(ctx context.Context, ev *Event) { // never trip the per-user gate, while the global DAILY_USD_CEILING still applies. const unlimitedCap = 1 << 30 -func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) { +func (b *Bot) respond(ctx context.Context, roomID, threadRoot string, isDM bool, ev *Event, mc *MessageContent, history []bufferedMsg) { started := time.Now() // One telemetry row per request, populated as the flow decides its outcome and // emitted once via defer — so every exit (deny, error, empty, paid silence, success) @@ -438,7 +462,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, defer cancel() msgs := buildContext(b.cfg.SystemPrompt, history, isDM, mc.Body, b.cfg.MaxCtxEvent, maxPromptTokens) - res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID)) + res, err := b.generate(genCtx, mc.Body, msgs, b.convID(roomID, threadRoot)) // Record what the routing + generation actually did, whatever the outcome. rl.Route = res.route @@ -503,7 +527,7 @@ func (b *Bot) respond(ctx context.Context, roomID string, isDM bool, ev *Event, } b.log.Info("answered", "room", roomID, "sender", ev.Sender, "dm", isDM, "route", res.route, "usd", res.cost.Total(), "prompt_tokens", res.usage.PromptTokens, "completion_tokens", res.usage.CompletionTokens) - if err := b.sendReply(ctx, roomID, ev, mc, text); err != nil { + if err := b.sendReply(ctx, roomID, threadRoot, ev, mc, text); err != nil { // Paid silence (§8.1): the spend is real (USD is kept — refunding it would // under-count the ceiling), but the reply never landed. Refund the request SLOT // so the user can retry, and react ⚠️ so the failure isn't silent. @@ -535,15 +559,21 @@ func (b *Bot) estimateUSD(model string) float64 { // convID returns the prompt-cache routing hint sent as x-grok-conv-id, or "" when // GROK_PROMPT_CACHE is off. Grok caches prompt prefixes automatically; the header -// only pins a conversation to the same backend to raise the hit rate (docs.x.ai), so -// a stable per-room id is the right unit — every turn in a room shares the system -// prompt and history prefix. It carries no PII (the room id is opaque) and is hashed -// to keep it compact and non-identifying. -func (b *Bot) convID(roomID string) string { +// only pins a conversation to the same backend to raise the hit rate (docs.x.ai). The +// right unit is the (room,thread) pair, not the room: once conversations are threaded, +// each thread has its OWN system+history prefix, so pinning all of a room's threads to +// one id would thrash the cache between divergent prefixes. The main timeline keys on +// roomID alone (threadRoot ""), preserving the previous value for the flat case. Carries +// no PII (ids are opaque) and is hashed to stay compact and non-identifying. +func (b *Bot) convID(roomID, threadRoot string) string { if !b.cfg.GrokPromptCache { return "" } - return fmt.Sprintf("vojo-%08x", hashString(roomID)) + key := roomID + if threadRoot != "" { + key = roomID + "|" + threadRoot + } + return fmt.Sprintf("vojo-%08x", hashString(key)) } // computeUSD prices a call from the API-returned token usage (authoritative @@ -606,23 +636,24 @@ func (b *Bot) reactEncryptedOnce(ctx context.Context, roomID, eventID string) bo // conversation buffer so the next turn has context. It RETURNS the send error so the // caller can handle paid silence (§8.1): a billed answer that failed to deliver must // refund the slot and react, not vanish. -func (b *Bot) sendReply(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error { - if err := b.sendMessage(ctx, roomID, trigger, triggerMC, body); err != nil { +func (b *Bot) sendReply(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error { + if err := b.sendMessage(ctx, roomID, threadRoot, trigger, triggerMC, body); err != nil { return err } // Record the user trigger AND the assistant answer together, only AFTER the answer // was sent, so a failed or empty generation never leaves a dangling user turn (a - // question with no reply) in the buffer — which would skew later completions. - // Single-flight guarantees no other turn for this room interleaves between the two. - b.appendBuf(roomID, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false}) - b.appendBuf(roomID, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true}) + // question with no reply) in the buffer — which would skew later completions. Both go + // to THIS conversation's buffer (roomID,threadRoot). Per-(room,thread) single-flight + // guarantees no other turn for this conversation interleaves between the two. + b.appendBuf(roomID, threadRoot, bufferedMsg{sender: trigger.Sender, body: triggerMC.Body, isBot: false}) + b.appendBuf(roomID, threadRoot, bufferedMsg{sender: b.cfg.BotMXID, body: body, isBot: true}) return nil } // sendMessage builds and sends an m.notice reply and tracks our own event id. Returns // the send error (nil on success) so the caller can detect a failed delivery. -func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, triggerMC *MessageContent, body string) error { - content := buildNoticeContent(trigger.EventID, trigger.Sender, triggerMC.RelatesTo, body) +func (b *Bot) sendMessage(ctx context.Context, roomID, threadRoot string, trigger *Event, triggerMC *MessageContent, body string) error { + content := buildNoticeContent(trigger.EventID, trigger.Sender, threadRoot, body) id, err := b.mx.SendEvent(ctx, roomID, "m.room.message", content) if err != nil { b.log.Error("send failed", "room", roomID, "err", err) @@ -633,11 +664,16 @@ func (b *Bot) sendMessage(ctx context.Context, roomID string, trigger *Event, tr return nil } -// startTypingKeepalive starts the typing indicator and keeps it alive for the whole -// generation (the CS-API server-side typing notification expires after the 30s we -// pass, so we refresh every 20s). The returned stop clears the indicator and is safe -// to call once via defer. Typing is best-effort UX — failures are non-fatal. +// startTypingKeepalive shows the room-level "typing…" indicator for the whole generation +// and keeps it alive (the CS-API notification expires after the 30s we pass, so we refresh +// every 20s). The returned stop is safe to call once via defer. Typing is ROOM-scoped in +// Matrix — there is no per-thread typing — so with per-(room,thread) concurrency several +// generations can run for one room at once. A per-room refcount keeps the indicator on +// until the LAST of them finishes, rather than letting whichever finishes first clear it +// out from under the others. Best-effort UX — failures are non-fatal. func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() { + b.typingAcquire(roomID) + b.setTyping(ctx, roomID, true) done := make(chan struct{}) go func() { @@ -658,11 +694,40 @@ func (b *Bot) startTypingKeepalive(ctx context.Context, roomID string) func() { return func() { once.Do(func() { close(done) - b.setTyping(ctx, roomID, false) + // Only the last in-flight generation for the room clears the indicator; the + // others merely stop their own keepalive loop (a forgetRoom mid-flight can drop + // the counter to <=0, which the guard treats as "last" — still correct). + if b.typingRelease(roomID) { + b.setTyping(ctx, roomID, false) + } }) } } +// typingAcquire registers one in-flight generation against the room-level typing indicator. +// The increment is the only state change; the caller (re)asserts the indicator on regardless, +// since re-sending typing=true is idempotent and refreshes the server-side 30s timeout. +func (b *Bot) typingAcquire(roomID string) { + b.mu.Lock() + b.typingRefs[roomID]++ + b.mu.Unlock() +} + +// typingRelease drops one in-flight generation and reports whether it was the LAST one for +// the room (refcount fell to <=0), so the caller clears the indicator only then. A forgetRoom +// that already deleted the key leaves a missing entry: the decrement reads 0 and lands at -1 +// (<=0), so the guard still treats it as "last" and the negative entry is deleted — no leak. +func (b *Bot) typingRelease(roomID string) (last bool) { + b.mu.Lock() + defer b.mu.Unlock() + b.typingRefs[roomID]-- + last = b.typingRefs[roomID] <= 0 + if last { + delete(b.typingRefs, roomID) + } + return last +} + // setTyping sets/clears the bot's typing indicator (best-effort UX; failures are // non-fatal). The 30s server-side timeout is refreshed by startTypingKeepalive. func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) { @@ -672,13 +737,17 @@ func (b *Bot) setTyping(ctx context.Context, roomID string, typing bool) { } // buildNoticeContent builds the reply. m.notice (not m.text) so the anti-loop -// skip catches our own output. Thread-aware (F27): a trigger from a thread gets a -// thread relation so the answer lands in the thread, not the main timeline. -func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body string) map[string]any { +// skip catches our own output. threadRoot decides where the answer lands: when non-empty +// the answer carries an m.thread relation rooted there (F27) — either replying inside an +// existing thread or auto-rooting a NEW DM conversation on the trigger. The caller's +// resolveThreadRoot makes that choice and is DM-gated, so a group answer never gets a +// thread relation it didn't already have. When empty the answer is a plain top-level +// reply (groups, and DMs with conversations off). +func buildNoticeContent(replyTo, sender, threadRoot, body string) map[string]any { relates := map[string]any{} - if triggerRelates != nil && triggerRelates.RelType == "m.thread" && triggerRelates.EventID != "" { + if threadRoot != "" { relates["rel_type"] = "m.thread" - relates["event_id"] = triggerRelates.EventID + relates["event_id"] = threadRoot relates["is_falling_back"] = true relates["m.in_reply_to"] = map[string]any{"event_id": replyTo} } else { @@ -701,24 +770,55 @@ func buildNoticeContent(replyTo, sender string, triggerRelates *RelatesTo, body return content } -// --- per-room single-flight ---------------------------------------------------- +// --- per-(room,thread) single-flight --------------------------------------------- -// tryClaim marks a room as generating and returns true if the caller won the claim -// (no generation was already in flight). The loser must drop its message. -func (b *Bot) tryClaim(roomID string) bool { +// resolveThreadRoot decides which conversation a trigger belongs to, returning the thread +// root event id, or "" for the main timeline. Order: (1) a trigger already inside a thread +// continues that thread; (2) in a 1:1 DM, a top-level message roots a NEW conversation on +// itself (ChatGPT-style "new chat"); (3) otherwise — EVERY group message — the main timeline +// (""). This is the single place auto-threading is decided and it is hard-gated on isDM, so +// a group is NEVER auto-threaded (the group gate is structural, not a flag). buildNoticeContent +// emits the matching m.thread relation for the same value, so the conversation we serialize on +// is always the one the answer lands in. +func (b *Bot) resolveThreadRoot(isDM bool, ev *Event, mc *MessageContent) string { + if mc.RelatesTo != nil && mc.RelatesTo.RelType == "m.thread" && mc.RelatesTo.EventID != "" { + return mc.RelatesTo.EventID + } + if isDM { + return ev.EventID + } + return "" +} + +// tryClaim marks a (room,thread) conversation as generating and returns true if the caller +// won the claim (nothing was already in flight for that exact conversation). The loser +// drops its message. Different threads of one room claim independently — a slow answer in +// one conversation never blocks another. The check-and-set is atomic under b.mu (one map, +// no per-thread mutex), so there is no lazy-lock TOCTOU. +func (b *Bot) tryClaim(roomID, threadRoot string) bool { b.mu.Lock() defer b.mu.Unlock() - if b.inflight[roomID] { + m := b.inflight[roomID] + if m == nil { + m = make(map[string]bool) + b.inflight[roomID] = m + } + if m[threadRoot] { return false } - b.inflight[roomID] = true + m[threadRoot] = true return true } -func (b *Bot) release(roomID string) { +func (b *Bot) release(roomID, threadRoot string) { b.mu.Lock() defer b.mu.Unlock() - delete(b.inflight, roomID) + if m := b.inflight[roomID]; m != nil { + delete(m, threadRoot) + if len(m) == 0 { + delete(b.inflight, roomID) + } + } } // --- per-room metadata helpers (all guarded by b.mu; probes run outside it) ----- @@ -752,8 +852,9 @@ func (b *Bot) forgetRoom(roomID string) { b.mu.Lock() defer b.mu.Unlock() delete(b.meta, roomID) - delete(b.buf, roomID) - delete(b.inflight, roomID) + delete(b.buf, roomID) // drops the room's whole per-thread subtree in one delete + delete(b.inflight, roomID) // (nested maps keyed by roomID keep forgetRoom O(1)) + delete(b.typingRefs, roomID) } // ensureEncryption returns whether the room is encrypted, probing the CS-API once @@ -824,28 +925,69 @@ func (b *Bot) ensureCounts(ctx context.Context, roomID string) (countsKnown, isD return false, false, false } -func (b *Bot) snapshotBuf(roomID string) []bufferedMsg { +// convBuf is one conversation's (one thread's) rolling context window plus a last-touch +// stamp used for LRU eviction. Without eviction, a long-lived control DM that spawns many +// ChatGPT-style conversations would accumulate one buffer per conversation for the whole +// process lifetime (forgetRoom only frees them on room leave/ban). +type convBuf struct { + msgs []bufferedMsg + touched uint64 // b.bufSeq at last append; higher = more recent +} + +// maxConvBuffersPerRoom bounds how many conversation buffers a single room retains. A +// human DM rarely keeps this many live conversations at once; an evicted cold conversation +// just rebuilds its window from scratch on its next turn (same as after a restart), so the +// only cost of eviction is a one-turn cold start, never a wrong answer. +const maxConvBuffersPerRoom = 64 + +func (b *Bot) snapshotBuf(roomID, threadRoot string) []bufferedMsg { b.mu.Lock() defer b.mu.Unlock() - src := b.buf[roomID] - if len(src) == 0 { + // A never-seen conversation has no buffer (nil inner map / nil convBuf) → nil history, + // exactly what a fresh "new chat" wants (context = system + trigger). + cb := b.buf[roomID][threadRoot] + if cb == nil || len(cb.msgs) == 0 { return nil } - out := make([]bufferedMsg, len(src)) - copy(out, src) + out := make([]bufferedMsg, len(cb.msgs)) + copy(out, cb.msgs) return out } -func (b *Bot) appendBuf(roomID string, msg bufferedMsg) { +func (b *Bot) appendBuf(roomID, threadRoot string, msg bufferedMsg) { b.mu.Lock() defer b.mu.Unlock() limit := b.cfg.MaxCtxEvent * 2 if limit < 8 { limit = 8 } - buf := append(b.buf[roomID], msg) - if len(buf) > limit { - buf = buf[len(buf)-limit:] + m := b.buf[roomID] + if m == nil { + m = make(map[string]*convBuf) + b.buf[roomID] = m + } + cb := m[threadRoot] + if cb == nil { + cb = &convBuf{} + m[threadRoot] = cb + } + cb.msgs = append(cb.msgs, msg) + if len(cb.msgs) > limit { + cb.msgs = cb.msgs[len(cb.msgs)-limit:] + } + b.bufSeq++ + cb.touched = b.bufSeq + + // LRU-evict the least-recently-touched conversation once the room exceeds the cap. The + // just-touched conversation has the highest `touched`, so it is never the victim. + if len(m) > maxConvBuffersPerRoom { + var victim string + var oldest uint64 + for k, v := range m { + if victim == "" || v.touched < oldest { + victim, oldest = k, v.touched + } + } + delete(m, victim) } - b.buf[roomID] = buf } diff --git a/apps/ai-bot/buffers_test.go b/apps/ai-bot/buffers_test.go new file mode 100644 index 00000000..09e651fd --- /dev/null +++ b/apps/ai-bot/buffers_test.go @@ -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"]) + } +} diff --git a/apps/ai-bot/concurrency_test.go b/apps/ai-bot/concurrency_test.go index dde04dbe..46f10cc5 100644 --- a/apps/ai-bot/concurrency_test.go +++ b/apps/ai-bot/concurrency_test.go @@ -5,52 +5,89 @@ import ( "testing" ) -// TestSingleFlightClaim documents the per-room single-flight invariant the async -// refactor relies on: at most one generation per room at a time, the claim is -// independent per room, and a release re-arms the room. handleEvent takes this claim -// synchronously in transaction order, so the FIRST message for a room wins and later -// ones are dropped until release (never the reverse). +// TestSingleFlightClaim documents the per-(room,thread) single-flight invariant the +// async refactor relies on: at most one generation per conversation at a time, the claim +// is independent per (room,thread), and a release re-arms only that conversation. +// handleEvent takes this claim synchronously in transaction order, so the FIRST message +// for a conversation wins and later ones are dropped until release (never the reverse). func TestSingleFlightClaim(t *testing.T) { - b := &Bot{inflight: make(map[string]bool)} + b := &Bot{inflight: make(map[string]map[string]bool)} - if !b.tryClaim("!a") { - t.Fatal("first claim on !a should win") + if !b.tryClaim("!a", "") { + t.Fatal("first claim on (!a, main) should win") } - if b.tryClaim("!a") { - t.Fatal("second claim on !a must fail while in flight") + if b.tryClaim("!a", "") { + t.Fatal("second claim on (!a, main) must fail while in flight") } - if !b.tryClaim("!b") { + // A DIFFERENT thread in the SAME room must claim independently — the whole point of + // per-(room,thread) single-flight: a slow answer in one conversation cannot block + // another conversation in the same room. + if !b.tryClaim("!a", "$root1") { + t.Fatal("a different thread in the same room must claim independently") + } + if b.tryClaim("!a", "$root1") { + t.Fatal("second claim on (!a, $root1) must fail while in flight") + } + if !b.tryClaim("!b", "") { t.Fatal("a different room must claim independently") } - b.release("!a") - if !b.tryClaim("!a") { - t.Fatal("after release !a must be claimable again") + b.release("!a", "") + if !b.tryClaim("!a", "") { + t.Fatal("after release (!a, main) must be claimable again") + } + // Releasing the main timeline must NOT free the thread's claim. + if b.tryClaim("!a", "$root1") { + t.Fatal("releasing (!a, main) must not free (!a, $root1)") } } // TestSingleFlightClaimExactlyOneWinner runs many goroutines racing for the same -// room and asserts EXACTLY ONE wins the claim — the property that prevents two -// concurrent generations (double xAI spend) for one room. Run under -race. +// conversation and asserts EXACTLY ONE wins — the property that prevents two concurrent +// generations (double xAI spend) for one conversation. It also races two DIFFERENT +// threads of one room together and asserts each has its own single winner, proving the +// claim is independent per (room,thread), not per room. Run under -race. func TestSingleFlightClaimExactlyOneWinner(t *testing.T) { - b := &Bot{inflight: make(map[string]bool)} + b := &Bot{inflight: make(map[string]map[string]bool)} const n = 64 - var wins int64 + var sameWins, threadAWins, threadBWins int64 var mu sync.Mutex var wg sync.WaitGroup - wg.Add(n) + wg.Add(n * 3) for i := 0; i < n; i++ { go func() { defer wg.Done() - if b.tryClaim("!room") { + if b.tryClaim("!room", "$same") { mu.Lock() - wins++ + sameWins++ + mu.Unlock() + } + }() + go func() { + defer wg.Done() + if b.tryClaim("!room", "$a") { + mu.Lock() + threadAWins++ + mu.Unlock() + } + }() + go func() { + defer wg.Done() + if b.tryClaim("!room", "$b") { + mu.Lock() + threadBWins++ mu.Unlock() } }() } wg.Wait() - if wins != 1 { - t.Fatalf("exactly one goroutine must win the claim, got %d", wins) + if sameWins != 1 { + t.Fatalf("exactly one goroutine must win (!room, $same), got %d", sameWins) + } + if threadAWins != 1 { + t.Fatalf("exactly one goroutine must win (!room, $a), got %d", threadAWins) + } + if threadBWins != 1 { + t.Fatalf("exactly one goroutine must win (!room, $b), got %d", threadBWins) } } diff --git a/apps/ai-bot/markdown_test.go b/apps/ai-bot/markdown_test.go index 6a7b4cd7..8faba2b1 100644 --- a/apps/ai-bot/markdown_test.go +++ b/apps/ai-bot/markdown_test.go @@ -121,7 +121,7 @@ func TestMarkdownAdversarialNoPanicNoInjection(t *testing.T) { } func TestBuildNoticeContentAttachesFormatted(t *testing.T) { - c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "Here is **bold**.") + c := buildNoticeContent("$evt", "@u:vojo.chat", "", "Here is **bold**.") if c["format"] != matrixHTMLFormat { t.Fatalf("format = %v, want %v", c["format"], matrixHTMLFormat) } @@ -135,7 +135,7 @@ func TestBuildNoticeContentAttachesFormatted(t *testing.T) { } func TestBuildNoticeContentSkipsFormattedForPlain(t *testing.T) { - c := buildNoticeContent("$evt", "@u:vojo.chat", nil, "no markdown here") + c := buildNoticeContent("$evt", "@u:vojo.chat", "", "no markdown here") if _, ok := c["format"]; ok { t.Fatalf("format must be absent for plain text") } diff --git a/apps/ai-bot/threads_test.go b/apps/ai-bot/threads_test.go new file mode 100644 index 00000000..90cafa42 --- /dev/null +++ b/apps/ai-bot/threads_test.go @@ -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"]) + } +} diff --git a/apps/widget-vojo-ai/.gitignore b/apps/widget-vojo-ai/.gitignore deleted file mode 100644 index 205a6bfb..00000000 --- a/apps/widget-vojo-ai/.gitignore +++ /dev/null @@ -1,4 +0,0 @@ -node_modules/ -dist/ -.vite/ -*.local diff --git a/apps/widget-vojo-ai/index.html b/apps/widget-vojo-ai/index.html deleted file mode 100644 index 723f3c37..00000000 --- a/apps/widget-vojo-ai/index.html +++ /dev/null @@ -1,12 +0,0 @@ - - - - - - Vojo AI - - -
- - - diff --git a/apps/widget-vojo-ai/package-lock.json b/apps/widget-vojo-ai/package-lock.json deleted file mode 100644 index 8a5e7e60..00000000 --- a/apps/widget-vojo-ai/package-lock.json +++ /dev/null @@ -1,1995 +0,0 @@ -{ - "name": "@vojo/widget-vojo-ai", - "version": "0.0.1", - "lockfileVersion": 3, - "requires": true, - "packages": { - "": { - "name": "@vojo/widget-vojo-ai", - "version": "0.0.1", - "dependencies": { - "preact": "10.22.1" - }, - "devDependencies": { - "@preact/preset-vite": "2.9.0", - "typescript": "5.4.5", - "vite": "5.4.19" - } - }, - "node_modules/@babel/code-frame": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", - "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-validator-identifier": "^7.29.7", - "js-tokens": "^4.0.0", - "picocolors": "^1.1.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/compat-data": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", - "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/core": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", - "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-compilation-targets": "^7.29.7", - "@babel/helper-module-transforms": "^7.29.7", - "@babel/helpers": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/remapping": "^2.3.5", - "convert-source-map": "^2.0.0", - "debug": "^4.1.0", - "gensync": "^1.0.0-beta.2", - "json5": "^2.2.3", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/babel" - } - }, - "node_modules/@babel/generator": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", - "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7", - "@jridgewell/gen-mapping": "^0.3.12", - "@jridgewell/trace-mapping": "^0.3.28", - "jsesc": "^3.0.2" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-annotate-as-pure": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-annotate-as-pure/-/helper-annotate-as-pure-7.29.7.tgz", - "integrity": "sha512-OoK6239jHPuSQOoS0kfTVKn0b/rVTk0seKq4Gd2UMLtmOVLjDC0ki3e+c90Trqv2gMfvJFqkiljrr568+qddiw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-compilation-targets": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", - "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/compat-data": "^7.29.7", - "@babel/helper-validator-option": "^7.29.7", - "browserslist": "^4.24.0", - "lru-cache": "^5.1.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-globals": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", - "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-imports": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", - "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/traverse": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-module-transforms": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", - "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7", - "@babel/traverse": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0" - } - }, - "node_modules/@babel/helper-plugin-utils": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-plugin-utils/-/helper-plugin-utils-7.29.7.tgz", - "integrity": "sha512-G7sHYigPY17oO5SYWnfD/0MTBwVR781S/JI643e/JhUYgVgWE/61SoW3NH9KWUKyKq5LVh3npif99Wkt6j86Jw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-string-parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", - "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-identifier": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", - "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helper-validator-option": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", - "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/helpers": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", - "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/parser": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", - "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/types": "^7.29.7" - }, - "bin": { - "parser": "bin/babel-parser.js" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@babel/plugin-syntax-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-syntax-jsx/-/plugin-syntax-jsx-7.29.7.tgz", - "integrity": "sha512-TSu8+mHCoEaaCDEZ0I3+6mvTBYR4PCxQwf2z9/r5Tbztv6NaLR3B9thGTTxX2WGuGHJqRiAbKPeGTJ5XWXVg6A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-plugin-utils": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx/-/plugin-transform-react-jsx-7.29.7.tgz", - "integrity": "sha512-WsZulLVBUHXVj2cUcPVx6UE21TpalB6bHbSFErKT0Ib++ax24jjXe73FqlWvdylFOjiuPHYi6VCcgRad1ItN+A==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-annotate-as-pure": "^7.29.7", - "@babel/helper-module-imports": "^7.29.7", - "@babel/helper-plugin-utils": "^7.29.7", - "@babel/plugin-syntax-jsx": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/plugin-transform-react-jsx-development": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-react-jsx-development/-/plugin-transform-react-jsx-development-7.29.7.tgz", - "integrity": "sha512-Xfy3UVMF04+ypnFbkhvfqtmvwfe92qwQdbGZVonhE+6v35GzlofmOnA1szaZqzb9xYWr0nl1e5EMmzi0DNON1g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/plugin-transform-react-jsx": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "node_modules/@babel/template": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", - "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/types": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/traverse": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", - "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.29.7", - "@babel/generator": "^7.29.7", - "@babel/helper-globals": "^7.29.7", - "@babel/parser": "^7.29.7", - "@babel/template": "^7.29.7", - "@babel/types": "^7.29.7", - "debug": "^4.3.1" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@babel/types": { - "version": "7.29.7", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", - "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/helper-string-parser": "^7.29.7", - "@babel/helper-validator-identifier": "^7.29.7" - }, - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/@esbuild/aix-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.21.5.tgz", - "integrity": "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "aix" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.21.5.tgz", - "integrity": "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.21.5.tgz", - "integrity": "sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/android-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.21.5.tgz", - "integrity": "sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.21.5.tgz", - "integrity": "sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/darwin-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.21.5.tgz", - "integrity": "sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.21.5.tgz", - "integrity": "sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/freebsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.21.5.tgz", - "integrity": "sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.21.5.tgz", - "integrity": "sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.21.5.tgz", - "integrity": "sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.21.5.tgz", - "integrity": "sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-loong64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.21.5.tgz", - "integrity": "sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-mips64el": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.21.5.tgz", - "integrity": "sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==", - "cpu": [ - "mips64el" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-ppc64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.21.5.tgz", - "integrity": "sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-riscv64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.21.5.tgz", - "integrity": "sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-s390x": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.21.5.tgz", - "integrity": "sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/linux-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.21.5.tgz", - "integrity": "sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/netbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.21.5.tgz", - "integrity": "sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "netbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/openbsd-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.21.5.tgz", - "integrity": "sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/sunos-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.21.5.tgz", - "integrity": "sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "sunos" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-arm64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.21.5.tgz", - "integrity": "sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-ia32": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.21.5.tgz", - "integrity": "sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@esbuild/win32-x64": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.21.5.tgz", - "integrity": "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ], - "engines": { - "node": ">=12" - } - }, - "node_modules/@jridgewell/gen-mapping": { - "version": "0.3.13", - "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", - "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.5.0", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/remapping": { - "version": "2.3.5", - "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", - "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/gen-mapping": "^0.3.5", - "@jridgewell/trace-mapping": "^0.3.24" - } - }, - "node_modules/@jridgewell/resolve-uri": { - "version": "3.1.2", - "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", - "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/@jridgewell/sourcemap-codec": { - "version": "1.5.5", - "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", - "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", - "dev": true, - "license": "MIT" - }, - "node_modules/@jridgewell/trace-mapping": { - "version": "0.3.31", - "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", - "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/resolve-uri": "^3.1.0", - "@jridgewell/sourcemap-codec": "^1.4.14" - } - }, - "node_modules/@preact/preset-vite": { - "version": "2.9.0", - "resolved": "https://registry.npmjs.org/@preact/preset-vite/-/preset-vite-2.9.0.tgz", - "integrity": "sha512-B9yVT7AkR6owrt84K3pLNyaKSvlioKdw65VqE/zMiR6HMovPekpsrwBNs5DJhBFEd5cvLMtCjHNHZ9P7Oblveg==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/code-frame": "^7.22.13", - "@babel/plugin-transform-react-jsx": "^7.22.15", - "@babel/plugin-transform-react-jsx-development": "^7.22.5", - "@prefresh/vite": "^2.4.1", - "@rollup/pluginutils": "^4.1.1", - "babel-plugin-transform-hook-names": "^1.0.2", - "debug": "^4.3.4", - "kolorist": "^1.8.0", - "magic-string": "0.30.5", - "node-html-parser": "^6.1.10", - "resolve": "^1.22.8", - "source-map": "^0.7.4", - "stack-trace": "^1.0.0-pre2" - }, - "peerDependencies": { - "@babel/core": "7.x", - "vite": "2.x || 3.x || 4.x || 5.x" - } - }, - "node_modules/@prefresh/babel-plugin": { - "version": "0.5.3", - "resolved": "https://registry.npmjs.org/@prefresh/babel-plugin/-/babel-plugin-0.5.3.tgz", - "integrity": "sha512-57LX2SHs4BX2s1IwCjNzTE2OJeEepRCNf1VTEpbNcUyHfMO68eeOWGDIt4ob9aYlW6PEWZ1SuwNikuoIXANDtQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/@prefresh/core": { - "version": "1.5.10", - "resolved": "https://registry.npmjs.org/@prefresh/core/-/core-1.5.10.tgz", - "integrity": "sha512-7yPTFbG56sutaFu8krp3B4a200KOFUvrtlllKWRuLjsYXo9UUucHOZRcer+gtgMkFTpv6ob8TGcTwA32bSwa1w==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "preact": "^10.0.0 || ^11.0.0-0" - } - }, - "node_modules/@prefresh/utils": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/@prefresh/utils/-/utils-1.2.1.tgz", - "integrity": "sha512-vq/sIuN5nYfYzvyayXI4C2QkprfNaHUQ9ZX+3xLD8nL3rWyzpxOm1+K7RtMbhd+66QcaISViK7amjnheQ/4WZw==", - "dev": true, - "license": "MIT" - }, - "node_modules/@prefresh/vite": { - "version": "2.4.12", - "resolved": "https://registry.npmjs.org/@prefresh/vite/-/vite-2.4.12.tgz", - "integrity": "sha512-FY1fzXpUjiuosznMV0YM7XAOPZjB5FIdWS0W24+XnlxYkt9hNAwwsiKYn+cuTEoMtD/ZVazS5QVssBr9YhpCQA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@babel/core": "^7.22.1", - "@prefresh/babel-plugin": "^0.5.2", - "@prefresh/core": "^1.5.0", - "@prefresh/utils": "^1.2.0", - "@rollup/pluginutils": "^4.2.1" - }, - "peerDependencies": { - "preact": "^10.4.0 || ^11.0.0-0", - "vite": ">=2.0.0" - } - }, - "node_modules/@rollup/pluginutils": { - "version": "4.2.1", - "resolved": "https://registry.npmjs.org/@rollup/pluginutils/-/pluginutils-4.2.1.tgz", - "integrity": "sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==", - "dev": true, - "license": "MIT", - "dependencies": { - "estree-walker": "^2.0.1", - "picomatch": "^2.2.2" - }, - "engines": { - "node": ">= 8.0.0" - } - }, - "node_modules/@rollup/rollup-android-arm-eabi": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm-eabi/-/rollup-android-arm-eabi-4.60.4.tgz", - "integrity": "sha512-F5QXMSiFebS9hKZj02XhWLLnRpJ3B3AROP0tWbFBSj+6kCbg5m9j5JoHKd4mmSVy5mS/IMQloYgYxCuJC0fxEQ==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-android-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-android-arm64/-/rollup-android-arm64-4.60.4.tgz", - "integrity": "sha512-GxxTKApUpzRhof7poWvCJHRF51C67u1R7D6DiluBE8wKU1u5GWE8t+v81JvJYtbawoBFX1hLv5Ei4eVjkWokaw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "android" - ] - }, - "node_modules/@rollup/rollup-darwin-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-arm64/-/rollup-darwin-arm64-4.60.4.tgz", - "integrity": "sha512-tua0TaJxMOB1R0V0RS1jFZ/RpURFDJIOR2A6jWwQeawuFyS4gBW+rntLRaQd0EQ4bd6Vp44Z2rXW+YYDBsj6IA==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-darwin-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-darwin-x64/-/rollup-darwin-x64-4.60.4.tgz", - "integrity": "sha512-CSKq7MsP+5PFIcydhAiR1K0UhEI1A2jWXVKHPCBZ151yOutENwvnPocgVHkivu2kviURtCEB6zUQw0vs8RrhMg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ] - }, - "node_modules/@rollup/rollup-freebsd-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-arm64/-/rollup-freebsd-arm64-4.60.4.tgz", - "integrity": "sha512-+O8OkVdyvXMtJEciu2wS/pzm1IxntEEQx3z5TAVy4l32G0etZn+RsA48ARRrFm6Ri8fvqPQfgrvNxSjKAbnd3g==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-freebsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-freebsd-x64/-/rollup-freebsd-x64-4.60.4.tgz", - "integrity": "sha512-Iw3oMskH3AfNuhU0MSN7vNbdi4me/NiYo2azqPz/Le16zHSa+3RRmliCMWWQmh4lcndccU40xcJuTYJZxNo/lw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "freebsd" - ] - }, - "node_modules/@rollup/rollup-linux-arm-gnueabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-gnueabihf/-/rollup-linux-arm-gnueabihf-4.60.4.tgz", - "integrity": "sha512-EIPRXTVQpHyF8WOo219AD2yEltPehLTcTMz2fn6JsatLYSzQf00hj3rulF+yauOlF9/FtM2WpkT/hJh/KJFGhA==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm-musleabihf": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm-musleabihf/-/rollup-linux-arm-musleabihf-4.60.4.tgz", - "integrity": "sha512-J3Yh9PzzF1Ovah2At+lHiGQdsYgArxBbXv/zHfSyaiFQEqvNv7DcW98pCrmdjCZBrqBiKrKKe2V+aaSGWuBe/w==", - "cpu": [ - "arm" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-gnu/-/rollup-linux-arm64-gnu-4.60.4.tgz", - "integrity": "sha512-BFDEZMYfUvLn37ONE1yMBojPxnMlTFsdyNoqncT0qFq1mAfllL+ATMMJd8TeuVMiX84s1KbcxcZbXInmcO2mRg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-arm64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-arm64-musl/-/rollup-linux-arm64-musl-4.60.4.tgz", - "integrity": "sha512-pc9EYOSlOgdQ2uPl1o9PF6/kLSgaUosia7gOuS8mB69IxJvlclko1MECXysjs5ryez1/5zjYqx3+xYU0TU6R1A==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-gnu/-/rollup-linux-loong64-gnu-4.60.4.tgz", - "integrity": "sha512-NxnomyxYerDh5n4iLrNa+sH+Z+U4BMEE46V2PgQ/hoB909i8gV1M5wPojWg9fk1jWpO3IQnOs20K4wyZuFLEFQ==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-loong64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-loong64-musl/-/rollup-linux-loong64-musl-4.60.4.tgz", - "integrity": "sha512-nbJnQ8a3z1mtmrwImCYhc6BGpThAyYVRQxw9uKSKG4wR6aAYno9sVjJ0zaZcW9BPJX1GbrDPf+SvdWjgTuDmnw==", - "cpu": [ - "loong64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-gnu/-/rollup-linux-ppc64-gnu-4.60.4.tgz", - "integrity": "sha512-2EU6acNrQLd8tYvo/LXW535wupT3m6fo7HKo6lr7ktQoItxTyOL1ZCR/GfGCuXl2vR+zmfI6eRXkSemafv+iVg==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-ppc64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-ppc64-musl/-/rollup-linux-ppc64-musl-4.60.4.tgz", - "integrity": "sha512-WeBtoMuaMxiiIrO2IYP3xs6GMWkJP2C0EoT8beTLkUPmzV1i/UcOSVw1d5r9KBODtHKilG5yFxsGRnBbK3wJ4A==", - "cpu": [ - "ppc64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-gnu/-/rollup-linux-riscv64-gnu-4.60.4.tgz", - "integrity": "sha512-FJHFfqpKUI3A10WrWKiFbBZ7yVbGT4q4B5o1qKFFojqpaYoh9LrQgqWCmmcxQzVSXYtyB5bzkXrYzlHTs21MYA==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-riscv64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-riscv64-musl/-/rollup-linux-riscv64-musl-4.60.4.tgz", - "integrity": "sha512-mcEl6CUT5IAUmQf1m9FYSmVqCJlpQ8r8eyftFUHG8i9OhY7BkBXSUdnLH5DOf0wCOjcP9v/QO93zpmF1SptCCw==", - "cpu": [ - "riscv64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-s390x-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-s390x-gnu/-/rollup-linux-s390x-gnu-4.60.4.tgz", - "integrity": "sha512-ynt3JxVd2w2buzoKDWIyiV1pJW93xlQic1THVLXilz429oijRpSHivZAgp65KBu+cMcgf1eVVjdnTLvPxgCuoQ==", - "cpu": [ - "s390x" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-gnu/-/rollup-linux-x64-gnu-4.60.4.tgz", - "integrity": "sha512-Boiz5+MsaROEWDf+GGEwF8VMHGhlUoQMtIPjOgA5fv4osupqTVnJteQNKJwUcnUog2G55jYXH7KZFFiJe0TEzQ==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-linux-x64-musl": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-linux-x64-musl/-/rollup-linux-x64-musl-4.60.4.tgz", - "integrity": "sha512-+qfSY27qIrFfI/Hom04KYFw3GKZSGU4lXus51wsb5EuySfFlWRwjkKWoE9emgRw/ukoT4Udsj4W/+xxG8VbPKg==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "linux" - ] - }, - "node_modules/@rollup/rollup-openbsd-x64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openbsd-x64/-/rollup-openbsd-x64-4.60.4.tgz", - "integrity": "sha512-VpTfOPHgVXEBeeR8hZ2O0F3aSso+JDWqTWmTmzcQKted54IAdUVbxE+j/MVxUsKa8L20HJhv3vUezVPoquqWjA==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openbsd" - ] - }, - "node_modules/@rollup/rollup-openharmony-arm64": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-openharmony-arm64/-/rollup-openharmony-arm64-4.60.4.tgz", - "integrity": "sha512-IPOsh5aRYuLv/nkU51X10Bf75Bsf6+gZdx1X+QP5QM6lIJFHHqbHLG0uJn/hWthzo13UAc2umiUorqZy3axoZg==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "openharmony" - ] - }, - "node_modules/@rollup/rollup-win32-arm64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-arm64-msvc/-/rollup-win32-arm64-msvc-4.60.4.tgz", - "integrity": "sha512-4QzE9E81OohJ/HKzHhsqU+zcYYojVOXlFMs1DdyMT6qXl/niOH7AVElmmEdUNHHS/oRkc++d5k6Vy85zFs0DEw==", - "cpu": [ - "arm64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-ia32-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-ia32-msvc/-/rollup-win32-ia32-msvc-4.60.4.tgz", - "integrity": "sha512-zTPgT1YuHHcd+Tmx7h8aml0FWFVelV5N54oHow9SLj+GfoDy/huQ+UV396N/C7KpMDMiPspRktzM1/0r1usYEA==", - "cpu": [ - "ia32" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-gnu": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-gnu/-/rollup-win32-x64-gnu-4.60.4.tgz", - "integrity": "sha512-DRS4G7mi9lJxqEDezIkKCaUIKCrLUUDCUaCsTPCi/rtqaC6D/jjwslMQyiDU50Ka0JKpeXeRBFBAXwArY52vBw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@rollup/rollup-win32-x64-msvc": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/@rollup/rollup-win32-x64-msvc/-/rollup-win32-x64-msvc-4.60.4.tgz", - "integrity": "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw==", - "cpu": [ - "x64" - ], - "dev": true, - "license": "MIT", - "optional": true, - "os": [ - "win32" - ] - }, - "node_modules/@types/estree": { - "version": "1.0.8", - "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.8.tgz", - "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", - "dev": true, - "license": "MIT" - }, - "node_modules/babel-plugin-transform-hook-names": { - "version": "1.0.2", - "resolved": "https://registry.npmjs.org/babel-plugin-transform-hook-names/-/babel-plugin-transform-hook-names-1.0.2.tgz", - "integrity": "sha512-5gafyjyyBTTdX/tQQ0hRgu4AhNHG/hqWi0ZZmg2xvs2FgRkJXzDNKBZCyoYqgFkovfDrgM8OoKg8karoUvWeCw==", - "dev": true, - "license": "MIT", - "peerDependencies": { - "@babel/core": "^7.12.10" - } - }, - "node_modules/baseline-browser-mapping": { - "version": "2.10.33", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.33.tgz", - "integrity": "sha512-bA6+tcSLpz2tIEdDXZPpPTIuxBcC4+w6SieaYyfigIa4h8GlFxbA17v22Vx3JUtuZQj9SgOsnbK+aTBzyDyEuw==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "baseline-browser-mapping": "dist/cli.cjs" - }, - "engines": { - "node": ">=6.0.0" - } - }, - "node_modules/boolbase": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/boolbase/-/boolbase-1.0.0.tgz", - "integrity": "sha512-JZOSA7Mo9sNGB8+UjSgzdLtokWAky1zbztM3WRLCbZ70/3cTANmQmOdR7y2g+J0e2WXywy1yS468tY+IruqEww==", - "dev": true, - "license": "ISC" - }, - "node_modules/browserslist": { - "version": "4.28.2", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", - "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "baseline-browser-mapping": "^2.10.12", - "caniuse-lite": "^1.0.30001782", - "electron-to-chromium": "^1.5.328", - "node-releases": "^2.0.36", - "update-browserslist-db": "^1.2.3" - }, - "bin": { - "browserslist": "cli.js" - }, - "engines": { - "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" - } - }, - "node_modules/caniuse-lite": { - "version": "1.0.30001793", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001793.tgz", - "integrity": "sha512-iwSsYWaCOoh26cV8NwNRViHlrfUvYsHDfRVcbtmw0Kg6PJIZZXwMkj1442FYLBGkeUf1juAsU3DTfxW579mrPA==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/caniuse-lite" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "CC-BY-4.0" - }, - "node_modules/convert-source-map": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", - "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", - "dev": true, - "license": "MIT" - }, - "node_modules/css-select": { - "version": "5.2.2", - "resolved": "https://registry.npmjs.org/css-select/-/css-select-5.2.2.tgz", - "integrity": "sha512-TizTzUddG/xYLA3NXodFM0fSbNizXjOKhqiQQwvhlspadZokn1KDy0NZFS0wuEubIYAV5/c1/lAr0TaaFXEXzw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0", - "css-what": "^6.1.0", - "domhandler": "^5.0.2", - "domutils": "^3.0.1", - "nth-check": "^2.0.1" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/css-what": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/css-what/-/css-what-6.2.2.tgz", - "integrity": "sha512-u/O3vwbptzhMs3L1fQE82ZSLHQQfto5gyZzwteVIEyeaY5Fc7R4dapF/BvRoSYFeqfBk4m0V1Vafq5Pjv25wvA==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">= 6" - }, - "funding": { - "url": "https://github.com/sponsors/fb55" - } - }, - "node_modules/debug": { - "version": "4.4.3", - "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", - "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "ms": "^2.1.3" - }, - "engines": { - "node": ">=6.0" - }, - "peerDependenciesMeta": { - "supports-color": { - "optional": true - } - } - }, - "node_modules/dom-serializer": { - "version": "2.0.0", - "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-2.0.0.tgz", - "integrity": "sha512-wIkAryiqt/nV5EQKqQpo3SToSOV9J0DnbJqwK7Wv/Trc92zIAYZ4FlMu+JPFW1DfGFt81ZTCGgDEabffXeLyJg==", - "dev": true, - "license": "MIT", - "dependencies": { - "domelementtype": "^2.3.0", - "domhandler": "^5.0.2", - "entities": "^4.2.0" - }, - "funding": { - "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" - } - }, - "node_modules/domelementtype": { - "version": "2.3.0", - "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-2.3.0.tgz", - "integrity": "sha512-OLETBj6w0OsagBwdXnPdN0cnMfF9opN69co+7ZrbfPGrdpPVNBUj02spi6B1N7wChLQiPn4CSH/zJvXw56gmHw==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/fb55" - } - ], - "license": "BSD-2-Clause" - }, - "node_modules/domhandler": { - "version": "5.0.3", - "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-5.0.3.tgz", - "integrity": "sha512-cgwlv/1iFQiFnU96XXgROh8xTeetsnJiDsTc7TYCLFd9+/WNkIqPTxiM/8pSd8VIrhXGTf1Ny1q1hquVqDJB5w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "domelementtype": "^2.3.0" - }, - "engines": { - "node": ">= 4" - }, - "funding": { - "url": "https://github.com/fb55/domhandler?sponsor=1" - } - }, - "node_modules/domutils": { - "version": "3.2.2", - "resolved": "https://registry.npmjs.org/domutils/-/domutils-3.2.2.tgz", - "integrity": "sha512-6kZKyUajlDuqlHKVX1w7gyslj9MPIXzIFiz/rGu35uC1wMi+kMhQwGhl4lt9unC9Vb9INnY9Z3/ZA3+FhASLaw==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "dom-serializer": "^2.0.0", - "domelementtype": "^2.3.0", - "domhandler": "^5.0.3" - }, - "funding": { - "url": "https://github.com/fb55/domutils?sponsor=1" - } - }, - "node_modules/electron-to-chromium": { - "version": "1.5.364", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.364.tgz", - "integrity": "sha512-G/dYE3+AYhyHwzTwg8UbnXf7zqMERYh7l2jJ3QujhFsH8agSYwtnGAR2aZ7f0AakIKJXd5En/Hre4igIUrdlYw==", - "dev": true, - "license": "ISC" - }, - "node_modules/entities": { - "version": "4.5.0", - "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", - "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", - "dev": true, - "license": "BSD-2-Clause", - "engines": { - "node": ">=0.12" - }, - "funding": { - "url": "https://github.com/fb55/entities?sponsor=1" - } - }, - "node_modules/es-errors": { - "version": "1.3.0", - "resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz", - "integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/esbuild": { - "version": "0.21.5", - "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.21.5.tgz", - "integrity": "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "bin": { - "esbuild": "bin/esbuild" - }, - "engines": { - "node": ">=12" - }, - "optionalDependencies": { - "@esbuild/aix-ppc64": "0.21.5", - "@esbuild/android-arm": "0.21.5", - "@esbuild/android-arm64": "0.21.5", - "@esbuild/android-x64": "0.21.5", - "@esbuild/darwin-arm64": "0.21.5", - "@esbuild/darwin-x64": "0.21.5", - "@esbuild/freebsd-arm64": "0.21.5", - "@esbuild/freebsd-x64": "0.21.5", - "@esbuild/linux-arm": "0.21.5", - "@esbuild/linux-arm64": "0.21.5", - "@esbuild/linux-ia32": "0.21.5", - "@esbuild/linux-loong64": "0.21.5", - "@esbuild/linux-mips64el": "0.21.5", - "@esbuild/linux-ppc64": "0.21.5", - "@esbuild/linux-riscv64": "0.21.5", - "@esbuild/linux-s390x": "0.21.5", - "@esbuild/linux-x64": "0.21.5", - "@esbuild/netbsd-x64": "0.21.5", - "@esbuild/openbsd-x64": "0.21.5", - "@esbuild/sunos-x64": "0.21.5", - "@esbuild/win32-arm64": "0.21.5", - "@esbuild/win32-ia32": "0.21.5", - "@esbuild/win32-x64": "0.21.5" - } - }, - "node_modules/escalade": { - "version": "3.2.0", - "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", - "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6" - } - }, - "node_modules/estree-walker": { - "version": "2.0.2", - "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-2.0.2.tgz", - "integrity": "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==", - "dev": true, - "license": "MIT" - }, - "node_modules/fsevents": { - "version": "2.3.3", - "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", - "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", - "dev": true, - "hasInstallScript": true, - "license": "MIT", - "optional": true, - "os": [ - "darwin" - ], - "engines": { - "node": "^8.16.0 || ^10.6.0 || >=11.0.0" - } - }, - "node_modules/function-bind": { - "version": "1.1.2", - "resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz", - "integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==", - "dev": true, - "license": "MIT", - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/gensync": { - "version": "1.0.0-beta.2", - "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", - "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=6.9.0" - } - }, - "node_modules/hasown": { - "version": "2.0.4", - "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.4.tgz", - "integrity": "sha512-T2UbfbBEF32wiepXIsMlTW9+dDYC6wMh/t/vYA4tuOMKqWz/n3vr1NFSxQiyP+zk2mXsoMA/i/7qV6LKut1t1A==", - "dev": true, - "license": "MIT", - "dependencies": { - "function-bind": "^1.1.2" - }, - "engines": { - "node": ">= 0.4" - } - }, - "node_modules/he": { - "version": "1.2.0", - "resolved": "https://registry.npmjs.org/he/-/he-1.2.0.tgz", - "integrity": "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==", - "dev": true, - "license": "MIT", - "bin": { - "he": "bin/he" - } - }, - "node_modules/is-core-module": { - "version": "2.16.2", - "resolved": "https://registry.npmjs.org/is-core-module/-/is-core-module-2.16.2.tgz", - "integrity": "sha512-evOr8xfXKxE6qSR0hSXL2r3sd7ALj8+7jQEUvPYcm5sgZFdJ+AYzT6yNmJenvIYQBgIGwfwz08sL8zoL7yq2BA==", - "dev": true, - "license": "MIT", - "dependencies": { - "hasown": "^2.0.3" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/js-tokens": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", - "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "dev": true, - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/json5": { - "version": "2.2.3", - "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", - "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", - "dev": true, - "license": "MIT", - "bin": { - "json5": "lib/cli.js" - }, - "engines": { - "node": ">=6" - } - }, - "node_modules/kolorist": { - "version": "1.8.0", - "resolved": "https://registry.npmjs.org/kolorist/-/kolorist-1.8.0.tgz", - "integrity": "sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==", - "dev": true, - "license": "MIT" - }, - "node_modules/lru-cache": { - "version": "5.1.1", - "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", - "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", - "dev": true, - "license": "ISC", - "dependencies": { - "yallist": "^3.0.2" - } - }, - "node_modules/magic-string": { - "version": "0.30.5", - "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.5.tgz", - "integrity": "sha512-7xlpfBaQaP/T6Vh8MO/EqXSW5En6INHEvEXQiuff7Gku0PWjU3uf6w/j9o7O+SpB5fOAkrI5HeoNgwjEO0pFsA==", - "dev": true, - "license": "MIT", - "dependencies": { - "@jridgewell/sourcemap-codec": "^1.4.15" - }, - "engines": { - "node": ">=12" - } - }, - "node_modules/ms": { - "version": "2.1.3", - "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", - "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", - "dev": true, - "license": "MIT" - }, - "node_modules/nanoid": { - "version": "3.3.12", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", - "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", - "dev": true, - "funding": [ - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "bin": { - "nanoid": "bin/nanoid.cjs" - }, - "engines": { - "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" - } - }, - "node_modules/node-html-parser": { - "version": "6.1.13", - "resolved": "https://registry.npmjs.org/node-html-parser/-/node-html-parser-6.1.13.tgz", - "integrity": "sha512-qIsTMOY4C/dAa5Q5vsobRpOOvPfC4pB61UVW2uSwZNUp0QU/jCekTal1vMmbO0DgdHeLUJpv/ARmDqErVxA3Sg==", - "dev": true, - "license": "MIT", - "dependencies": { - "css-select": "^5.1.0", - "he": "1.2.0" - } - }, - "node_modules/node-releases": { - "version": "2.0.46", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.46.tgz", - "integrity": "sha512-GYVXHE2KnrzAfsAjl4uP++evGFCrAU1jta4ubEjIG7YWt/64Gqv66a30yKwWczVjA6j3bM4nBwH7Pk1JmDHaxQ==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=18" - } - }, - "node_modules/nth-check": { - "version": "2.1.1", - "resolved": "https://registry.npmjs.org/nth-check/-/nth-check-2.1.1.tgz", - "integrity": "sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==", - "dev": true, - "license": "BSD-2-Clause", - "dependencies": { - "boolbase": "^1.0.0" - }, - "funding": { - "url": "https://github.com/fb55/nth-check?sponsor=1" - } - }, - "node_modules/path-parse": { - "version": "1.0.7", - "resolved": "https://registry.npmjs.org/path-parse/-/path-parse-1.0.7.tgz", - "integrity": "sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==", - "dev": true, - "license": "MIT" - }, - "node_modules/picocolors": { - "version": "1.1.1", - "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", - "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", - "dev": true, - "license": "ISC" - }, - "node_modules/picomatch": { - "version": "2.3.2", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.2.tgz", - "integrity": "sha512-V7+vQEJ06Z+c5tSye8S+nHUfI51xoXIXjHQ99cQtKUkQqqO1kO/KCJUfZXuB47h/YBlDhah2H3hdUGXn8ie0oA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=8.6" - }, - "funding": { - "url": "https://github.com/sponsors/jonschlinkert" - } - }, - "node_modules/postcss": { - "version": "8.5.15", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", - "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/postcss/" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/postcss" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "nanoid": "^3.3.12", - "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" - }, - "engines": { - "node": "^10 || ^12 || >=14" - } - }, - "node_modules/preact": { - "version": "10.22.1", - "resolved": "https://registry.npmjs.org/preact/-/preact-10.22.1.tgz", - "integrity": "sha512-jRYbDDgMpIb5LHq3hkI0bbl+l/TQ9UnkdQ0ww+lp+4MMOdqaUYdFc5qeyP+IV8FAd/2Em7drVPeKdQxsiWCf/A==", - "license": "MIT", - "funding": { - "type": "opencollective", - "url": "https://opencollective.com/preact" - } - }, - "node_modules/resolve": { - "version": "1.22.12", - "resolved": "https://registry.npmjs.org/resolve/-/resolve-1.22.12.tgz", - "integrity": "sha512-TyeJ1zif53BPfHootBGwPRYT1RUt6oGWsaQr8UyZW/eAm9bKoijtvruSDEmZHm92CwS9nj7/fWttqPCgzep8CA==", - "dev": true, - "license": "MIT", - "dependencies": { - "es-errors": "^1.3.0", - "is-core-module": "^2.16.1", - "path-parse": "^1.0.7", - "supports-preserve-symlinks-flag": "^1.0.0" - }, - "bin": { - "resolve": "bin/resolve" - }, - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/rollup": { - "version": "4.60.4", - "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.60.4.tgz", - "integrity": "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g==", - "dev": true, - "license": "MIT", - "dependencies": { - "@types/estree": "1.0.8" - }, - "bin": { - "rollup": "dist/bin/rollup" - }, - "engines": { - "node": ">=18.0.0", - "npm": ">=8.0.0" - }, - "optionalDependencies": { - "@rollup/rollup-android-arm-eabi": "4.60.4", - "@rollup/rollup-android-arm64": "4.60.4", - "@rollup/rollup-darwin-arm64": "4.60.4", - "@rollup/rollup-darwin-x64": "4.60.4", - "@rollup/rollup-freebsd-arm64": "4.60.4", - "@rollup/rollup-freebsd-x64": "4.60.4", - "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", - "@rollup/rollup-linux-arm-musleabihf": "4.60.4", - "@rollup/rollup-linux-arm64-gnu": "4.60.4", - "@rollup/rollup-linux-arm64-musl": "4.60.4", - "@rollup/rollup-linux-loong64-gnu": "4.60.4", - "@rollup/rollup-linux-loong64-musl": "4.60.4", - "@rollup/rollup-linux-ppc64-gnu": "4.60.4", - "@rollup/rollup-linux-ppc64-musl": "4.60.4", - "@rollup/rollup-linux-riscv64-gnu": "4.60.4", - "@rollup/rollup-linux-riscv64-musl": "4.60.4", - "@rollup/rollup-linux-s390x-gnu": "4.60.4", - "@rollup/rollup-linux-x64-gnu": "4.60.4", - "@rollup/rollup-linux-x64-musl": "4.60.4", - "@rollup/rollup-openbsd-x64": "4.60.4", - "@rollup/rollup-openharmony-arm64": "4.60.4", - "@rollup/rollup-win32-arm64-msvc": "4.60.4", - "@rollup/rollup-win32-ia32-msvc": "4.60.4", - "@rollup/rollup-win32-x64-gnu": "4.60.4", - "@rollup/rollup-win32-x64-msvc": "4.60.4", - "fsevents": "~2.3.2" - } - }, - "node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "license": "ISC", - "bin": { - "semver": "bin/semver.js" - } - }, - "node_modules/source-map": { - "version": "0.7.6", - "resolved": "https://registry.npmjs.org/source-map/-/source-map-0.7.6.tgz", - "integrity": "sha512-i5uvt8C3ikiWeNZSVZNWcfZPItFQOsYTUAOkcUPGd8DqDy1uOUikjt5dG+uRlwyvR108Fb9DOd4GvXfT0N2/uQ==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">= 12" - } - }, - "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", - "dev": true, - "license": "BSD-3-Clause", - "engines": { - "node": ">=0.10.0" - } - }, - "node_modules/stack-trace": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/stack-trace/-/stack-trace-1.0.0.tgz", - "integrity": "sha512-H6D7134xi6qONvh7ZHKgviXf+rd3vhGBSvebPZCaUkd8zvQ+7PtDw6CljPTe4cXWNf2IKZGNqw6VJXSb9IgBpA==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">=20.0.0" - } - }, - "node_modules/supports-preserve-symlinks-flag": { - "version": "1.0.0", - "resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz", - "integrity": "sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 0.4" - }, - "funding": { - "url": "https://github.com/sponsors/ljharb" - } - }, - "node_modules/typescript": { - "version": "5.4.5", - "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.5.tgz", - "integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==", - "dev": true, - "license": "Apache-2.0", - "bin": { - "tsc": "bin/tsc", - "tsserver": "bin/tsserver" - }, - "engines": { - "node": ">=14.17" - } - }, - "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", - "dev": true, - "funding": [ - { - "type": "opencollective", - "url": "https://opencollective.com/browserslist" - }, - { - "type": "tidelift", - "url": "https://tidelift.com/funding/github/npm/browserslist" - }, - { - "type": "github", - "url": "https://github.com/sponsors/ai" - } - ], - "license": "MIT", - "dependencies": { - "escalade": "^3.2.0", - "picocolors": "^1.1.1" - }, - "bin": { - "update-browserslist-db": "cli.js" - }, - "peerDependencies": { - "browserslist": ">= 4.21.0" - } - }, - "node_modules/vite": { - "version": "5.4.19", - "resolved": "https://registry.npmjs.org/vite/-/vite-5.4.19.tgz", - "integrity": "sha512-qO3aKv3HoQC8QKiNSTuUM1l9o/XX3+c+VTgLHbJWHZGeTPVAg2XwazI9UWzoxjIJCGCV2zU60uqMzjeLZuULqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "esbuild": "^0.21.3", - "postcss": "^8.4.43", - "rollup": "^4.20.0" - }, - "bin": { - "vite": "bin/vite.js" - }, - "engines": { - "node": "^18.0.0 || >=20.0.0" - }, - "funding": { - "url": "https://github.com/vitejs/vite?sponsor=1" - }, - "optionalDependencies": { - "fsevents": "~2.3.3" - }, - "peerDependencies": { - "@types/node": "^18.0.0 || >=20.0.0", - "less": "*", - "lightningcss": "^1.21.0", - "sass": "*", - "sass-embedded": "*", - "stylus": "*", - "sugarss": "*", - "terser": "^5.4.0" - }, - "peerDependenciesMeta": { - "@types/node": { - "optional": true - }, - "less": { - "optional": true - }, - "lightningcss": { - "optional": true - }, - "sass": { - "optional": true - }, - "sass-embedded": { - "optional": true - }, - "stylus": { - "optional": true - }, - "sugarss": { - "optional": true - }, - "terser": { - "optional": true - } - } - }, - "node_modules/yallist": { - "version": "3.1.1", - "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", - "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", - "dev": true, - "license": "ISC" - } - } -} diff --git a/apps/widget-vojo-ai/package.json b/apps/widget-vojo-ai/package.json deleted file mode 100644 index 451d8e1b..00000000 --- a/apps/widget-vojo-ai/package.json +++ /dev/null @@ -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" - } -} diff --git a/apps/widget-vojo-ai/src/App.tsx b/apps/widget-vojo-ai/src/App.tsx deleted file mode 100644 index 04ade517..00000000 --- a/apps/widget-vojo-ai/src/App.tsx +++ /dev/null @@ -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 = () => ( - -); - -// Shield + check — leads the «Конфиденциальность и данные» card (mirrors the -// Telegram widget's info-card-opens-modal pattern). -const ShieldIcon = () => ( - -); - -// 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 ( - - ); -}; - -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 ( -
-
-
- {canAddToChat && ( - - )} - -
-
- - {aboutOpen && setAboutOpen(false)} />} -
- ); -} diff --git a/apps/widget-vojo-ai/src/bootstrap.ts b/apps/widget-vojo-ai/src/bootstrap.ts deleted file mode 100644 index 412595b4..00000000 --- a/apps/widget-vojo-ai/src/bootstrap.ts +++ /dev/null @@ -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'), - }, - }; -}; diff --git a/apps/widget-vojo-ai/src/i18n/en.ts b/apps/widget-vojo-ai/src/i18n/en.ts deleted file mode 100644 index de43432f..00000000 --- a/apps/widget-vojo-ai/src/i18n/en.ts +++ /dev/null @@ -1,26 +0,0 @@ -// English fallback. Mirror the RU key set; `Record` enforces -// that every RU key has an EN counterpart at compile time. - -import type { StringKey } from './ru'; - -export const EN: Record = { - '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}.', -}; diff --git a/apps/widget-vojo-ai/src/i18n/index.ts b/apps/widget-vojo-ai/src/i18n/index.ts deleted file mode 100644 index 686c299a..00000000 --- a/apps/widget-vojo-ai/src/i18n/index.ts +++ /dev/null @@ -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 => { - if (!vars) return s; - return s.replace(/\{(\w+)\}/g, (_, k) => vars[k] ?? `{${k}}`); -}; - -const pickDict = (clientLanguage: string | undefined): Record => { - const lang = ( - clientLanguage || - (typeof navigator !== 'undefined' ? navigator.language : '') || - 'ru' - ).toLowerCase(); - return lang.startsWith('en') ? EN : RU; -}; - -export type T = (key: StringKey, vars?: Record) => string; - -export const createT = (clientLanguage?: string): T => { - const dict = pickDict(clientLanguage); - return (key, vars) => interpolate(dict[key], vars); -}; - -export type { StringKey }; diff --git a/apps/widget-vojo-ai/src/i18n/ru.ts b/apps/widget-vojo-ai/src/i18n/ru.ts deleted file mode 100644 index ef0187ee..00000000 --- a/apps/widget-vojo-ai/src/i18n/ru.ts +++ /dev/null @@ -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; diff --git a/apps/widget-vojo-ai/src/main.tsx b/apps/widget-vojo-ai/src/main.tsx deleted file mode 100644 index 48bd45c6..00000000 --- a/apps/widget-vojo-ai/src/main.tsx +++ /dev/null @@ -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( -
-
- {t('bootstrap.failed')} - {t('bootstrap.missing-params', { names: result.missing.join(', ') })}{' '} - {t('bootstrap.embedded-only', { route: '/bots/vojo-ai' })} -
-
, - 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(, root); -} diff --git a/apps/widget-vojo-ai/src/styles.css b/apps/widget-vojo-ai/src/styles.css deleted file mode 100644 index a437e494..00000000 --- a/apps/widget-vojo-ai/src/styles.css +++ /dev/null @@ -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 - * + )) + )} + + + +
+ {rootId ? ( + + ) : ( + + )} +
+ + + + ); +} diff --git a/src/app/features/bots/BotWidgetEmbed.ts b/src/app/features/bots/BotWidgetEmbed.ts index 13acc0ba..55c17632 100644 --- a/src/app/features/bots/BotWidgetEmbed.ts +++ b/src/app/features/bots/BotWidgetEmbed.ts @@ -19,7 +19,7 @@ import { import { Theme } from '../../hooks/useTheme'; import { openExternalUrl } from '../../utils/capacitor'; import { parseMatrixToRoom, type MatrixToRoom } from '../../plugins/matrix-to'; -import { BOT_CAP_ADD_TO_CHAT, type BotPreset } from './catalog'; +import { BOT_CAP_ADD_TO_CHAT, isWidgetExperience, type BotPreset } from './catalog'; import { BotWidgetDriver, sanitizeBotWidgetMessageEvent, @@ -61,7 +61,8 @@ const getBotWidgetUrl = ( language: string, widgetId: string ): string => { - if (!preset.experience) throw new Error('Bot widget experience is not configured'); + if (!isWidgetExperience(preset.experience)) + throw new Error('Bot widget experience is not configured'); const url = new URL(preset.experience.url, window.location.origin); url.searchParams.set('widgetId', widgetId); @@ -306,7 +307,9 @@ export class BotWidgetEmbed { // capability from the trusted config (`preset.experience.capabilities`), // never from the widget message; the invitee is `preset.mxid`, host-set. if (msg.action === 'add-to-chat') { - if (!this.options.preset.experience?.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) return; + const { experience } = this.options.preset; + if (!isWidgetExperience(experience) || !experience.capabilities.includes(BOT_CAP_ADD_TO_CHAT)) + return; this.options.onAddToChat?.(); return; } diff --git a/src/app/features/bots/catalog.ts b/src/app/features/bots/catalog.ts index 8664e305..3c91db7d 100644 --- a/src/app/features/bots/catalog.ts +++ b/src/app/features/bots/catalog.ts @@ -2,7 +2,14 @@ import { useMemo } from 'react'; import { useClientConfig } from '../../hooks/useClientConfig'; import type { BotConfig, ClientConfig } from '../../hooks/useClientConfig'; -export type BotExperience = { +// A bot's experience is a discriminated union on `type`: +// - 'matrix-widget' — a sandboxed iframe widget (the bridge bots). Carries the widget url, the +// command prefix, and the elevated-capability allowlist. +// - 'ai-chat' — the native, fully in-client ChatGPT-style conversation surface (AiBotChat). No +// widget, so no url/capabilities to carry or validate. Only first-party assistant bots use it. +// The two experiences share NO runtime pipeline: 'ai-chat' never touches BotShell / the widget +// embed / the show-chat toggle, and 'matrix-widget' never touches the conversation surface. +export type BotWidgetExperience = { type: 'matrix-widget'; url: string; /** Command prefix the widget prepends to outbound commands (e.g. `!tg`). @@ -15,6 +22,22 @@ export type BotExperience = { capabilities: string[]; }; +export type AiChatExperience = { + type: 'ai-chat'; +}; + +export type BotExperience = BotWidgetExperience | AiChatExperience; + +/** True when the bot is rendered through the sandboxed iframe widget pipeline (bridges). */ +export const isWidgetExperience = ( + experience: BotExperience | undefined +): experience is BotWidgetExperience => experience?.type === 'matrix-widget'; + +/** True when the bot uses the native in-client conversation surface (no widget). */ +export const isAiChatExperience = ( + experience: BotExperience | undefined +): experience is AiChatExperience => experience?.type === 'ai-chat'; + /** The only elevated side-channel verb today: lets the widget ask the host to * invite the bot (`preset.mxid`, host-substituted) into a user-picked room. The * widget never supplies the room or the mxid. Privilege originates from the @@ -94,6 +117,8 @@ const isValidBotPreset = (preset: BotConfig | undefined): preset is BotPreset => const normalizeBotExperience = (experience: BotConfig['experience']): BotExperience | undefined => { const type = experience?.type; + // Native AI chat: no widget url / capabilities to validate, rendered fully in-client. + if (type === 'ai-chat') return { type: 'ai-chat' }; const url = experience?.url?.trim(); if (type !== 'matrix-widget' || !url) return undefined; if (url.startsWith('//')) return undefined; diff --git a/src/app/features/bots/useBotConversations.ts b/src/app/features/bots/useBotConversations.ts new file mode 100644 index 00000000..101f4584 --- /dev/null +++ b/src/app/features/bots/useBotConversations.ts @@ -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((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]); +}; diff --git a/src/app/features/bots/useBotWidgetEmbed.ts b/src/app/features/bots/useBotWidgetEmbed.ts index ce7d7b52..c4ed5009 100644 --- a/src/app/features/bots/useBotWidgetEmbed.ts +++ b/src/app/features/bots/useBotWidgetEmbed.ts @@ -4,7 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useMatrixClient } from '../../hooks/useMatrixClient'; import { Theme, useTheme } from '../../hooks/useTheme'; import type { MatrixToRoom } from '../../plugins/matrix-to'; -import type { BotPreset } from './catalog'; +import { isWidgetExperience, type BotPreset } from './catalog'; import { BotWidgetEmbed } from './BotWidgetEmbed'; type UseBotWidgetEmbedOptions = { @@ -69,7 +69,7 @@ export const useBotWidgetEmbed = ({ // every time `useBotPresets` returns a fresh memo, e.g. after a runtime // config refresh. const { id: presetId, mxid: presetMxid, name: presetName, experience } = preset; - const experienceUrl = experience?.url; + const experienceUrl = isWidgetExperience(experience) ? experience.url : undefined; const experienceType = experience?.type; const { roomId } = room; diff --git a/src/app/features/room/RoomInput.tsx b/src/app/features/room/RoomInput.tsx index 6edabe15..16937dda 100644 --- a/src/app/features/room/RoomInput.tsx +++ b/src/app/features/room/RoomInput.tsx @@ -199,9 +199,14 @@ interface RoomInputProps { // composers leave this undefined so messages land in the main timeline // unchanged. The drawer composer passes threadId={rootId}. threadId?: string; + // Optional: fired with the real server event id once a text message send + // resolves. The AI bot's "new chat" composer uses it to navigate into the + // freshly-rooted thread (the just-sent top-level event IS the thread root). + // Channel / DM / thread composers omit it and stay fire-and-forget. + onSend?: (eventId: string) => void; } export const RoomInput = forwardRef( - ({ editor, fileDropContainerRef, roomId, room, threadId }, ref) => { + ({ editor, fileDropContainerRef, roomId, room, threadId, onSend }, ref) => { const { t } = useTranslation(); const mx = useMatrixClient(); const useAuthentication = useMediaAuthentication(); @@ -492,7 +497,10 @@ export const RoomInput = forwardRef( content['m.relates_to'].is_falling_back = false; } } - mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); + const pending = mx.sendMessage(roomId, threadId ?? null, content as RoomMessageEventContent); + if (onSend) { + pending.then((res) => onSend(res.event_id)).catch(() => undefined); + } resetEditor(editor); resetEditorHistory(editor); setReplyDraft(undefined); @@ -501,6 +509,7 @@ export const RoomInput = forwardRef( mx, roomId, threadId, + onSend, editor, replyDraft, sendTypingStatus, diff --git a/src/app/features/room/ThreadDrawer.css.ts b/src/app/features/room/ThreadDrawer.css.ts index 862c9ae9..cc375325 100644 --- a/src/app/features/room/ThreadDrawer.css.ts +++ b/src/app/features/room/ThreadDrawer.css.ts @@ -1,4 +1,4 @@ -import { style } from '@vanilla-extract/css'; +import { keyframes, style } from '@vanilla-extract/css'; import { color, config, toRem } from 'folds'; import { VOJO_HORSESHOE_GAP_PX, VOJO_HORSESHOE_RADIUS_PX } from '../../styles/horseshoe'; @@ -169,6 +169,16 @@ export const ThreadDrawerContent = style({ paddingBottom: config.space.S400, }); +// Assistant transcript variant: the channels `` hover action bar (which the S700 top +// pad above clears) never renders here (assistant rows are plain divs), so the 32px would just be +// dead space under the header. Tighten it to a small, comfortable gap. +export const ThreadDrawerContentAssistant = style([ + ThreadDrawerContent, + { + paddingTop: config.space.S400, + }, +]); + export const ThreadDivider = style({ height: 1, backgroundColor: color.Surface.ContainerLine, @@ -250,3 +260,67 @@ export const ThreadErrorState = style({ alignItems: 'center', gap: config.space.S300, }); + +// --- Assistant (ChatGPT-style) transcript ----------------------------------------- +// Used when ThreadDrawer is mounted with messageStyle="assistant" (the AI bot surface). +// The bot's turn is full-width plain text; the user's turn is a right-aligned bubble. + +// Bot reply: full-width, no avatar/name/timestamp. Just the rendered body on the surface. +export const AssistantBotRow = style({ + width: '100%', + padding: `0 ${config.space.S400}`, + color: color.Surface.OnContainer, +}); + +// User turn: right-aligned row holding a bubble. +export const AssistantUserRow = style({ + display: 'flex', + justifyContent: 'flex-end', + padding: `0 ${config.space.S400}`, +}); + +export const AssistantUserBubble = style({ + maxWidth: '85%', + minWidth: 0, + padding: `${config.space.S200} ${config.space.S400}`, + borderRadius: config.radii.R400, + // Match the input-form tone (RoomView.css `ChatComposer .Editor` = Surface.Container, the dark + // card the user types into) rather than the brand lavender, so a sent bubble reads as the same + // surface as the composer it came from. + backgroundColor: color.Surface.Container, + color: color.Surface.OnContainer, +}); + +// "Bot is typing" dots, shown after the last turn while the bot composes a reply. +const typingBlink = keyframes({ + '0%, 80%, 100%': { opacity: 0.2, transform: 'translateY(0)' }, + '40%': { opacity: 1, transform: `translateY(-${toRem(2)})` }, +}); + +export const TypingDots = style({ + display: 'inline-flex', + alignItems: 'center', + gap: toRem(4), + padding: `${config.space.S200} 0`, +}); + +export const TypingDot = style({ + width: toRem(6), + height: toRem(6), + borderRadius: '50%', + backgroundColor: color.Surface.OnContainer, + opacity: 0.2, + animationName: typingBlink, + animationDuration: '1.2s', + animationIterationCount: 'infinite', + selectors: { + '&:nth-child(2)': { animationDelay: '0.2s' }, + '&:nth-child(3)': { animationDelay: '0.4s' }, + }, + '@media': { + '(prefers-reduced-motion: reduce)': { + animationName: 'none', + opacity: 0.5, + }, + }, +}); diff --git a/src/app/features/room/ThreadDrawer.tsx b/src/app/features/room/ThreadDrawer.tsx index 6a792a8f..7834fb86 100644 --- a/src/app/features/room/ThreadDrawer.tsx +++ b/src/app/features/room/ThreadDrawer.tsx @@ -6,6 +6,7 @@ import { Box, Button, Header, Icon, IconButton, Icons, Scroll, Spinner, Text, co import { Direction, EventTimeline, + EventTimelineSet, EventType, MatrixEvent, MatrixEventEvent, @@ -61,6 +62,7 @@ import { useMentionClickHandler } from '../../hooks/useMentionClickHandler'; import { useSpoilerClickHandler } from '../../hooks/useSpoilerClickHandler'; import { useChannelsMode } from '../../hooks/useChannelsMode'; import { useIsOneOnOne } from '../../hooks/useRoom'; +import { useRoomTypingMember } from '../../hooks/useRoomTypingMembers'; import { useTheme } from '../../hooks/useTheme'; import { useImagePackRooms } from '../../hooks/useImagePackRooms'; import { useRoomCreatorsTag } from '../../hooks/useRoomCreatorsTag'; @@ -121,6 +123,19 @@ type ThreadDrawerProps = { // sitting beside the timeline. Layout is otherwise identical so the // M5 mobile-stack work doesn't need to fork the component. variant: 'desktop' | 'mobile'; + // When true, suppress the drawer's own thread header (the «ТРЕД / в + // #» 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 `` component @@ -135,7 +150,15 @@ type ThreadDrawerProps = { // empty). The component is intentionally light on chrome compared to // the full Message renderer in the timeline: M2 is MVP and rich // reactions / hover-menus / rail-dot inside the drawer is M9 territory. -export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDrawerProps) { +export function ThreadDrawer({ + room, + rootId, + parentRoomPath, + variant, + hideHeader = false, + messageStyle = 'default', +}: ThreadDrawerProps) { + const assistantStyle = messageStyle === 'assistant'; const { t } = useTranslation(); const mx = useMatrixClient(); const navigate = useNavigate(); @@ -341,6 +364,10 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr const isOneOnOne = useIsOneOnOne(); const channelsMode = useChannelsMode(); const isBridged = channelsMode && isBridgedRoom(room); + // Assistant-mode typing indicator: in a 1:1 bot DM the only other member is the bot, so any + // non-self typing receipt means the bot is composing a reply. Drives the typing-dots row. + const typingReceipts = useRoomTypingMember(room.roomId); + const botTyping = assistantStyle && typingReceipts.some((r) => r.userId !== mx.getUserId()); const [showDeveloperTools] = useSetting(settingsAtom, 'developerTools'); const roomToParents = useAtomValue(roomToParentsAtom); @@ -898,6 +925,19 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr // eslint-disable-next-line react-hooks/exhaustive-deps }, [repliesCount, myUserId, tryMarkThreadRead]); + // Assistant typing-dots follow. The "bot is typing" row renders INSIDE the scroll, after the + // last reply. When the bot starts composing while we're parked at the bottom, those dots add + // height below the fold, the bottom sentinel scrolls out, and the IntersectionObserver flips + // isAtBottomRef false — which would then make the growth effect suppress the bot's (non-own) + // reply. So snap to the new bottom when the dots appear (pre-paint, so the sentinel never + // leaves view). Only when already at-bottom — a user reading older replies is never yanked down. + useLayoutEffect(() => { + if (!botTyping) return; + const host = scrollHostRef.current; + if (!host || !isAtBottomRef.current) return; + host.scrollTop = host.scrollHeight; + }, [botTyping]); + // Stay at-bottom when the drawer scroll container resizes — Android // soft-keyboard open/close shrinks the viewport, and Capacitor's // default `windowSoftInputMode=adjustResize` shrinks the WebView @@ -954,6 +994,69 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr navigate(parentRoomPath, { replace: true }); }, [navigate, parentRoomPath]); + // ChatGPT-style row: the bot's reply is full-width plain text (no avatar / name / timestamp / + // hover-menu / reactions); the user's turn is a right-aligned bubble. Body is rendered through + // the shared RenderMessageContent (markdown/html/media) so links, code, and edits behave like + // anywhere else — only the surrounding Message chrome is dropped. + const renderAssistantEvent = ( + mEvent: MatrixEvent, + eventId: string, + timelineSet: EventTimelineSet + ) => { + const isOwn = mEvent.getSender() === mx.getUserId(); + const senderId = mEvent.getSender() ?? ''; + const senderDisplayName = + getMemberDisplayName(room, senderId) ?? getMxIdLocalPart(senderId) ?? senderId; + const eventType = mEvent.getType(); + + const body = (() => { + if (mEvent.isRedacted()) { + return ; + } + if (eventType === MessageEvent.RoomMessage) { + const editedEvent = getEditedEvent(eventId, mEvent, timelineSet); + const getContent = (() => + editedEvent?.getContent()['m.new_content'] ?? mEvent.getContent()) as GetContentCallback; + return ( + + ); + } + if (eventType === MessageEvent.RoomMessageEncrypted) { + return ( + + + + ); + } + return ( + + + + ); + })(); + + return ( +
+ {isOwn ?
{body}
: body} +
+ ); + }; + const renderThreadEvent = (mEvent: MatrixEvent) => { const eventId = mEvent.getId(); if (!eventId) return null; @@ -973,6 +1076,11 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr const isThreadReply = mEvent.threadRootId !== undefined && mEvent.threadRootId !== eventId; const timelineSet = isThreadReply && eventThread ? eventThread.timelineSet : room.getUnfilteredTimelineSet(); + // Assistant transcript: strip all the channel chrome and render bot = full-width text, + // user = right-aligned bubble. Reactions/edits-as-aggregations machinery below is skipped. + if (assistantStyle) { + return renderAssistantEvent(mEvent, eventId, timelineSet); + } const reactionRelations = getEventReactions(timelineSet, eventId); const reactionList = reactionRelations?.getSortedAnnotationsByKey(); const hasReactions = reactionList && reactionList.length > 0; @@ -1164,26 +1272,29 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr } return ( -
+
{renderThreadEvent(rootEvent)} - {(() => { - // Counter prefers the larger of materialized `thread.length` - // (replyCount + pending) and loaded `replies.length` — covers - // the cold-load window where the SDK can synthesize an empty - // Thread (length=0) shortly before the relations fetch lands - // a populated `replies` list (`??` would have collapsed to 0 - // and silently shown the divider despite present replies). - const counterCount = Math.max(thread?.length ?? 0, replies.length); - if (counterCount === 0) return
; - return ( -
- - - {t('Room.thread_summary_count', { count: counterCount })} - -
- ); - })()} + {/* Channel-style root↔replies divider / counter. Skipped in assistant mode, which + renders a continuous ChatGPT-style transcript with no "N replies" affordance. */} + {!assistantStyle && + (() => { + // Counter prefers the larger of materialized `thread.length` + // (replyCount + pending) and loaded `replies.length` — covers + // the cold-load window where the SDK can synthesize an empty + // Thread (length=0) shortly before the relations fetch lands + // a populated `replies` list (`??` would have collapsed to 0 + // and silently shown the divider despite present replies). + const counterCount = Math.max(thread?.length ?? 0, replies.length); + if (counterCount === 0) return
; + return ( +
+ + + {t('Room.thread_summary_count', { count: counterCount })} + +
+ ); + })()} {coldLoadError && (
@@ -1214,7 +1325,8 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr
)} - {thread && + {!assistantStyle && + thread && !coldLoadError && !coldLoadFetching && !paginating && @@ -1238,6 +1350,15 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr )} {replies.map((reply) => renderThreadEvent(reply))} + {botTyping && ( +
+
+ + + +
+
+ )}
); @@ -1248,30 +1369,32 @@ export function ThreadDrawer({ room, rootId, parentRoomPath, variant }: ThreadDr ref={fileDropContainerRef} className={isDesktopVariant ? css.ThreadDrawer : css.ThreadDrawerMobile} role="region" - aria-labelledby={headerId} + aria-labelledby={hideHeader ? undefined : headerId} > -
- - - - {t('Room.thread_caption')} - - - {t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })} - + {!hideHeader && ( +
+ + + + {t('Room.thread_caption')} + + + {t('Room.thread_in_channel_subtitle', { channel: room.name ?? '' })} + + + + + + + - - - - - - -
+
+ )}
` when absent. */ description?: string; experience?: { + /** `'matrix-widget'` (bridge bots — sandboxed iframe) or `'ai-chat'` (first-party + * assistant bots — native in-client conversation surface, no widget). See + * catalog.ts `normalizeBotExperience`. */ type?: string; + /** Widget iframe URL. Required for `'matrix-widget'`; absent for `'ai-chat'`. */ url?: string; commandPrefix?: string; /** Declarative opt-in to elevated host verbs (e.g. `vojo.add_to_chat`). diff --git a/src/app/pages/Router.tsx b/src/app/pages/Router.tsx index 2b45fe53..bf1b7f8f 100644 --- a/src/app/pages/Router.tsx +++ b/src/app/pages/Router.tsx @@ -328,6 +328,10 @@ export const createRouter = (clientConfig: ClientConfig, screenSize: ScreenSize) > {mobile ? null : } />} )} /> + {/* 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. */} + )} /> } /> {/* Channels segment. /channels/* is reserved before SPACE_PATH so the diff --git a/src/app/pages/client/bots/BotExperienceHost.tsx b/src/app/pages/client/bots/BotExperienceHost.tsx index 19bf4cd3..83171049 100644 --- a/src/app/pages/client/bots/BotExperienceHost.tsx +++ b/src/app/pages/client/bots/BotExperienceHost.tsx @@ -4,8 +4,14 @@ import { Icon, Icons } from 'folds'; import { useTranslation } from 'react-i18next'; import { useAtomValue } from 'jotai'; import type { Room as MatrixRoom } from 'matrix-js-sdk'; -import { findBotPresetById, useBotPresets, type BotPreset } from '../../../features/bots/catalog'; +import { + findBotPresetById, + isAiChatExperience, + useBotPresets, + type BotPreset, +} from '../../../features/bots/catalog'; import { BotChatFallback } from '../../../features/bots/BotChatFallback'; +import { BotConversations } from '../../../features/bots/BotConversations'; import { BotShell } from '../../../features/bots/BotShell'; import { botShowChatAtomFamily } from '../../../features/bots/botExperienceState'; import { useBotRoom } from '../../../features/bots/useBotRoom'; @@ -17,13 +23,23 @@ import { BotRoomProvider } from './BotRoomProvider'; import { BotStatePage } from './BotStatePage'; import { BotUnsafeRoom } from './BotUnsafeRoom'; -// Branches between BotShell (widget mode, no Cinny header) and the standard -// Room layout (chat fallback) based on the per-room showChat atom. Lives -// inside BotRoomProvider so both arms see the same room context and so an -// atom write from BotShellMenu (via «Показать чат») is observed here -// without prop-drilling. +// Picks the bot's experience. Two fully separate worlds, decided by `experience.type`: +// - 'ai-chat' → the native conversation surface (BotConversations). Self-contained: its own +// header/menu, NO widget, NO show-chat toggle. The showChat atom and the BotShell/chat-fallback +// pipeline below are never reached for it. +// - everything else (bridges) → the sandboxed widget (BotShell) with a "Show chat" fallback to +// the standard Room layout, toggled by the per-room showChat atom (set from BotShellMenu). +// Lives inside BotRoomProvider so every arm sees the same room context. function BotExperienceRoute({ preset, room }: { preset: BotPreset; room: MatrixRoom }) { + // /bots/:botId/thread/:rootId carries the open conversation's thread root; bare + // /bots/:botId leaves it undefined (the new-chat landing). + const { rootId } = useParams(); + // Read unconditionally (rules of hooks); only the bridge path below consumes it. const showChat = useAtomValue(botShowChatAtomFamily(room.roomId)); + + if (isAiChatExperience(preset.experience)) { + return ; + } if (showChat) { return } />; } diff --git a/src/app/pages/pathUtils.ts b/src/app/pages/pathUtils.ts index 4378eaa4..2436cf95 100644 --- a/src/app/pages/pathUtils.ts +++ b/src/app/pages/pathUtils.ts @@ -1,6 +1,7 @@ import { generatePath, Path } from 'react-router-dom'; import { BOTS_BOT_PATH, + BOTS_BOT_THREAD_PATH, BOTS_PATH, CHANNELS_PATH, CHANNELS_ROOM_EVENT_PATH, @@ -170,6 +171,11 @@ export const getSettingsPath = (page?: string): string => { export const getBotsPath = (): string => BOTS_PATH; export const getBotPath = (botId: string): string => generatePath(BOTS_BOT_PATH, { botId: encodeURIComponent(botId) }); +export const getBotThreadPath = (botId: string, rootId: string): string => + generatePath(BOTS_BOT_THREAD_PATH, { + botId: encodeURIComponent(botId), + rootId: encodeURIComponent(rootId), + }); export const getChannelsPath = (): string => CHANNELS_PATH; export const getChannelsSpacePath = (spaceIdOrAlias: string): string => { diff --git a/src/app/pages/paths.ts b/src/app/pages/paths.ts index 0450edb6..107a568f 100644 --- a/src/app/pages/paths.ts +++ b/src/app/pages/paths.ts @@ -87,6 +87,10 @@ export const USER_LINK_PATH = '/u/:userIdOrLocalPart'; export const BOTS_PATH = '/bots/'; export const BOTS_BOT_PATH = '/bots/:botId/'; +// A single conversation (m.thread root) inside an assistant bot's control DM. Mirrors +// CHANNELS_THREAD_PATH so the reused ThreadDrawer opens off a URL the same way it does in +// channels, but scoped under the bot route (no channelsMode, no space/room segments). +export const BOTS_BOT_THREAD_PATH = '/bots/:botId/thread/:rootId/'; export const CHANNELS_PATH = '/channels/'; export const CHANNELS_SPACE_PATH = '/channels/:spaceIdOrAlias/'; @@ -95,8 +99,7 @@ export const CHANNELS_ROOM_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/'; // optional eventId does not collide with the sibling `thread/:rootId/` // sub-route. Search/inbox/mention/push permalinks land here so the timeline // can scroll to the cited event without dropping the user out of /channels/. -export const CHANNELS_ROOM_EVENT_PATH = - '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/'; +export const CHANNELS_ROOM_EVENT_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/event/:eventId/'; export const CHANNELS_THREAD_PATH = '/channels/:spaceIdOrAlias/:roomIdOrAlias/thread/:rootId/'; export const SPACE_SETTINGS_PATH = '/space-settings/';