Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
588e750ede | ||
|
|
31e303c187 | ||
|
|
796c6390ac | ||
|
|
ecbf2617e4 | ||
|
|
9ef7d005fb | ||
|
|
842610b1af | ||
|
|
fca96396b9 | ||
|
|
ccf3fa260e | ||
|
|
1accfd9390 | ||
|
|
440ec41da8 | ||
|
|
1c8c0ca081 | ||
|
|
5f42c17caa | ||
|
|
eda5c752b7 | ||
|
|
25d5097edb | ||
|
|
d3e3b4764a |
No files matched your search
+3
-3
@@ -84,9 +84,9 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
|
|||||||
|
|
||||||
# === AI Analysis ===
|
# === AI Analysis ===
|
||||||
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
AI_ANALYSIS_ENABLED=false # Enable AI content moderation (default: false)
|
||||||
# AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
AI_LLM_API_KEY= # REQUIRED if AI_ANALYSIS_ENABLED=true. LLM API key
|
||||||
AI_LLM_BASE_URL=http://100.121.180.82:20128/api/v1 # LLM API base URL (omniroute on imrnes; /api/v1 exposes OpenAI-compatible chat+embeddings)
|
AI_LLM_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
|
||||||
AI_LLM_MODEL=text # LLM text model name (default: text)
|
AI_LLM_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5)
|
||||||
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
# AI_LLM_VISION_MODEL= # Vision model for image analysis (falls back to AI_LLM_MODEL)
|
||||||
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
# AI_LLM_EMBEDDING_MODEL= # Embedding model for semantic moderation cache (optional; enables near-duplicate text reuse to save LLM calls)
|
||||||
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
# AI_LLM_EMBEDDING_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
|
||||||
|
|||||||
@@ -0,0 +1,111 @@
|
|||||||
|
# AI Analysis Flow — Audit & Optimization (discord-gateway)
|
||||||
|
|
||||||
|
**Goal:** Analisis alur AI analysis end-to-end, temukan bug/inconsistency yang merusak kualitas verdict, lalu perbaiki root cause-nya.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
- `services/discord-gateway/src/modules/ai-moderation/**`
|
||||||
|
- Tidak menyentuh chatbot backend / frontend.
|
||||||
|
|
||||||
|
## Alur saat ini (hasil tracing)
|
||||||
|
```
|
||||||
|
message capture → aiAnalyzer.queueMessageAnalysis(messageId)
|
||||||
|
→ batchScheduler.scheduleConversationAnalysis(conversationKey) [debounce 250ms, CB gate]
|
||||||
|
→ messageStore.getPendingMessagesByConversation(≤200)
|
||||||
|
→ skipAgeRestrictedMessages
|
||||||
|
→ pickBatchWithinBudget(14000 tokens, 50/msg)
|
||||||
|
→ processBatch [Piscina worker, ≤4 threads]
|
||||||
|
→ ai-analysis-worker.processBatch
|
||||||
|
→ getConversationContextBefore(20 msgs) + attachments
|
||||||
|
→ attachment-upload race guard (pending upload → skip)
|
||||||
|
→ runModerationAnalysis
|
||||||
|
→ Phase 1: exact-hash cache (PG text_analysis_cache, per channel/thread)
|
||||||
|
→ Phase 2: semantic cache (embedTexts → Qdrant batch search; PG fallback)
|
||||||
|
→ split text-only vs media
|
||||||
|
→ runTextOnlyBatch: URL fetch + wiki search + glossary (paralel)
|
||||||
|
→ dedup short messages → sub-batches (60/sub-batch)
|
||||||
|
→ vision evidence utk URL images (hoisted, 15s cap per image)
|
||||||
|
→ callModerationLLM per sub-batch (stream:true, retries 3, JSON parse + correction retry)
|
||||||
|
→ runMediaBatch: download → vision per image (cache LRU→DB→live, lock) → 1 LLM call
|
||||||
|
→ setCachedTextModeration (PG + Qdrant upsert w/ embedding)
|
||||||
|
→ normalizeResult (confidence clamp, fallback analysis)
|
||||||
|
→ updateMessagesAIAnalysisBulk → broadcast + scheduleAutoDelete
|
||||||
|
→ recovery worker tiap 10s: pending keys → re-schedule; incomplete → individual fallback queue
|
||||||
|
→ individual fallback: 1 msg = 1 worker job (context + full LLM)
|
||||||
|
→ cache prune tiap 6 jam (PG expired + Qdrant expired points)
|
||||||
|
```
|
||||||
|
|
||||||
|
## Temuan audit (ranked)
|
||||||
|
|
||||||
|
### F1 — Cache hit menghapus status "warn" (BUG AKURASI)
|
||||||
|
`moderationOrchestrator.ts` Phase-2 semantic hit & PG-fallback memetakan status via
|
||||||
|
`parseQdrantVerdict`: storedStatus bukan "warn"/"flagged" → dipaksa "clean".
|
||||||
|
TAPI exact-hash lookup (`getCachedTextModeration`, textCacheStore.ts:288-295) lebih parah:
|
||||||
|
hanya menerima "clean"|"flagged" — **"warn" jatuh ke branch flags.length===0 ? clean : flagged**
|
||||||
|
→ warn dengan flags=["conflict_instigation"] dibaca sebagai FLAGGED.
|
||||||
|
Efek: auto-delete eligibility (butuh recommendedAction delete/escalate + severity list) salah baca;
|
||||||
|
dashboard menampilkan flagged padahal verdict asli warn. Root cause: type narrowing legacy
|
||||||
|
(`status: "clean" | "flagged"`) tidak diupdate ketika "warn" ditambahkan ke schema.
|
||||||
|
|
||||||
|
### F2 — Exact-cache key mengabaikan edit (BUG EVASION)
|
||||||
|
Key = sha256(content)+context. Pesan yang DIEDIT (`edited_content`) menghasilkan hash berbeda,
|
||||||
|
tapi verdict lama utk konten pre-edit tetap hidup; lebih penting: pesan edited="true" adalah sinyal
|
||||||
|
evasion di prompt, sedangkan cache bisa menyajikan verdict dari konten lama jika content sama.
|
||||||
|
(Minor, tapi konsistensi: `resolveIsEdited` ada di prompt, tidak ada di cache key.)
|
||||||
|
|
||||||
|
### F3 — `pickBatchWithinBudget` skip-bukan-break (LATENSI/KUALITAS)
|
||||||
|
Loop `if (usedTokens + msgTokens <= maxTokens) {push}` — pesan BESAR di tengah list dilewati
|
||||||
|
dan iterasi lanjut mencoba msg berikutnya. Efek: batch berisi "lubang" (msg pending tetap pending,
|
||||||
|
dianalisis di gelombang berikutnya = LLM call tambahan). Ini by-design tolerable, tapi ada bug halus:
|
||||||
|
pesan >budget tunggal tidak pernah masuk (scheduler sudah punya fallback slice(0,1), OK).
|
||||||
|
Keputusan: biarkan (bukan bug nyata), catat saja.
|
||||||
|
|
||||||
|
### F4 — `callModerationLLM` max_tokens 16384 hardcoded (COST)
|
||||||
|
Sub-batch 60 pesan × output ~150 token/pesan ≈ 9k token cukup; 16k aman. Biarkan.
|
||||||
|
|
||||||
|
### F5 — Dead code builder user-profile/reputation
|
||||||
|
`buildUserProfilesBlock`, `buildUserProfileRef`, `UserProfileEntry` di moderationBuilders.ts
|
||||||
|
tidak dipakai lagi sejak context minimization (hanya tests). `<user_history>` juga tak pernah
|
||||||
|
di-inject (rules masih menyebutnya — misleading bagi model). Bersihkan referensi prompt.
|
||||||
|
|
||||||
|
### F6 — rules.ts menyebut `<user_history>` yang tidak pernah ada di payload
|
||||||
|
Model diberi instruksi tentang blok yang tak pernah muncul → pemborosan token + potensi
|
||||||
|
kelakuan aneh ("menunggu" data yang tak ada). Hapus/ubah kalimat.
|
||||||
|
|
||||||
|
### F7 — system.ts "Blok Data" menyebut `<term_glossary> (SearXNG)` — STALE
|
||||||
|
Sumber sudah Wikipedia. Komentar kode & teks prompt menyebut SearXNG. Perbaiki teks (kecil).
|
||||||
|
|
||||||
|
### F8 — output.ts typo "secifik", baris tabel `-|-` rusak
|
||||||
|
Kualitas prompt: typo + markdown table broken (`||-`) di beberapa baris. Rapikan.
|
||||||
|
|
||||||
|
### F9 — llmCaller parse-error correction tail hanya di SYSTEM
|
||||||
|
Correction tail ditambahkan ke system prompt; provider caching fine, tapi preview invalid
|
||||||
|
content (800 char) ikut SYSTEM — ok. Skip.
|
||||||
|
|
||||||
|
### F10 — `getLlmSemaphore` race kecil saat config berubah di tengah flight
|
||||||
|
Non-issue praktis (config statis per proses). Skip.
|
||||||
|
|
||||||
|
## Keputusan perbaikan (yang dieksekusi sekarang)
|
||||||
|
1. **F1 (utama):** normalisasi status di SATU tempat — `normalizeStoredStatus()` di
|
||||||
|
textCacheStore.ts yang menerima clean/warn/flagged; pakai di getCachedTextModeration
|
||||||
|
DAN parseQdrantVerdict; perluas return types ke union penuh. Orchestrator tinggal pakai.
|
||||||
|
2. **F6+F7+F8:** bersihkan stale references di prompts (user_history, SearXNG, typo).
|
||||||
|
3. **F5:** hapus dead builders + test-nya (biome/tsc yang jaga).
|
||||||
|
4. Regression test untuk F1 (vitest): warn tersimpan → warn terbaca (exact + qdrant path).
|
||||||
|
|
||||||
|
## Files touched
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/textCacheStore.ts (F1)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/moderationOrchestrator.ts (type only)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/rules.ts (F6)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/system.ts (F7)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/prompts/output.ts (F8)
|
||||||
|
- services/discord-gateway/src/modules/ai-moderation/moderationBuilders.ts (F5)
|
||||||
|
- services/discord-gateway/tests/contextEnrichment.test.ts (F5 test cleanup + F1 regression test baru)
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
```
|
||||||
|
cd services/discord-gateway
|
||||||
|
npx tsc --noEmit
|
||||||
|
npx biome check --diagnostic-level=error .
|
||||||
|
npx vitest run
|
||||||
|
```
|
||||||
|
Semua harus hijau sebelum commit. Deploy via GHA (push main) — user konfirmasi belakangan.
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
# Spec: Optimasi AI Analysis GMW — Naikkan Cache Hit Tanpa Kehilangan Akurasi
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Repo: `~/GMW` (branch `main`) · Service: `services/discord-gateway`
|
||||||
|
|
||||||
|
## Latar & Evidence (audit 2026-08-24)
|
||||||
|
|
||||||
|
State produksi:
|
||||||
|
- Qdrant `gmw_text_moderation`: **1.550 poin, status green** (vectors size 2048, Cosine).
|
||||||
|
- PG `text_analysis_cache`: 1.634 row `user_moderation`, 277 `vision_llm`; **sum(hit_count) = 0** →
|
||||||
|
hit-rate tidak pernah terukur.
|
||||||
|
- Embedding aktif (`AI_LLM_EMBEDDING_MODEL` set, Nemotron-embed, dim 2048), `AI_LLM_EMBEDDING_MIN_SIMILARITY`
|
||||||
|
tidak diset di BWS → default **0.97** (sangat konservatif).
|
||||||
|
- Messages: 9.375 total; 643 status `error` (banyak retry), 49 pending.
|
||||||
|
|
||||||
|
Temuan audit alur (`moderationOrchestrator.ts` → `textCacheStore.ts` → `qdrantClient.ts`,
|
||||||
|
`textBatchProcessor.ts`, `urlFetcher.ts`, `wikipediaClient.ts`, `visionAnalyzer.ts`):
|
||||||
|
|
||||||
|
| # | Temuan | Dampak |
|
||||||
|
|---|--------|--------|
|
||||||
|
| F1 | Exact-hash cache key menyertakan context (channel/thread) → teks sama di channel lain selalu miss | Killer hit-rate #1 |
|
||||||
|
| F2 | Semantic tier TIDAK memfilter context (Qdrant payload tak punya context) — sudah global tapi hanya aman krn sim 0.97 ketat | Inkonsisten dgn exact tier |
|
||||||
|
| F3 | Phase-1 lookup loop `await getCachedTextModeration(key)` per pesan → N round-trip PgBouncer per batch (60 msg = 60 query serial) | Latensi + beban DB |
|
||||||
|
| F4 | Verdict actionable (flagged/warn) dan clean sama-sama boleh di-serve semantic; toleransi akurasi beda | Risiko akurasi |
|
||||||
|
| F5 | `hit_count` tidak pernah di-increment oleh reader manapun | Hit-rate tak terukur |
|
||||||
|
| F6 | `wikipediaSearch()` (blok `<web_searches>`) tanpa cache — re-fetch tiap batch utk query sama | Latensi + spam ke WP |
|
||||||
|
| F7 | `fetchUrlSafely()` tanpa cache — link sama di batch berikutnya di-download lagi penuh | Latensi + bandwidth |
|
||||||
|
| F8 | Vision cache key dari data-URL base64 hasil resize → attachment sama via jalur berbeda (URL vs embed) = key beda → re-download + re-vision | Duplikasi kerja vision |
|
||||||
|
|
||||||
|
Non-goals: mengubah pipeline enforcement (auto-mute/ban trust-store writes), mengubah prompt
|
||||||
|
kebijakan moderasi, mengubah model/embedding provider.
|
||||||
|
|
||||||
|
## Desain
|
||||||
|
|
||||||
|
Semua perubahan degrade gracefully — cache gagal → perilaku lama (LLM). Akurasi dilindungi
|
||||||
|
asimetris: **hemat boleh untuk verdict non-actionable, konservatif untuk yang memicu aksi.**
|
||||||
|
|
||||||
|
### D1 — Cache metrics (F5)
|
||||||
|
- `textCacheStore.getCachedTextModeration()`: saat hit valid, increment `hit_count`
|
||||||
|
(`UPDATE ... SET hit_count = hit_count + 1`) fire-and-forget (`.catch(()=>{})`), jangan blokir return.
|
||||||
|
- Log info periodik ringkas di orchestrator sudah ada ("User moderation cache applied") — cukup.
|
||||||
|
|
||||||
|
### D2 — Batched exact-cache lookup (F3)
|
||||||
|
- Fungsi baru `getCachedTextModerations(keys: string[]): Promise<Map<string, StoredModerationVerdict>>`
|
||||||
|
di `textCacheStore.ts`: **satu** `SELECT ... WHERE text = ANY($1)` (chunk 200 key/query),
|
||||||
|
parse + `normalizeStoredStatus` per row (reuse helper existing).
|
||||||
|
- Orchestrator fase-1: kumpulkan semua key unik → satu call batched → distribusi hasil.
|
||||||
|
- Semantik identik dengan loop lama (row expired/error-artifact tetap miss); hanya jumlah round-trip
|
||||||
|
yang turun N→1.
|
||||||
|
|
||||||
|
### D3 — Global exact reuse untuk verdict non-actionable (F1)
|
||||||
|
- Key scoped-context TETAP ditulis (kompatibel, invalidasi moderator tetap presisi).
|
||||||
|
- Reader tambahan: kalau key `<ctx>:<hash>` miss, coba key legacy global `text_mod:<hash>` (bare).
|
||||||
|
- Guard akurasi (WAJIB semua terpenuhi):
|
||||||
|
- `status === "clean"` DAN `flags.length === 0`;
|
||||||
|
- `confidence >= AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE` (default 0.85);
|
||||||
|
- `recommendedAction === "none"`;
|
||||||
|
- umur entry ≤ `AI_CACHE_GLOBAL_REUSE_MAX_AGE_H` (default 72h) — cek `analyzed_at`.
|
||||||
|
- Flag baru `policyVersion: "cached-global-clean-2026-08"` supaya terlacak di dashboard/log.
|
||||||
|
- Verdict flagged/warn TETAP context-scoped (tidak pernah lintas channel).
|
||||||
|
|
||||||
|
### D4 — Semantic dua-band similarity (F2+F4)
|
||||||
|
- Config baru: `AI_LLM_EMBEDDING_MIN_SIMILARITY_ACTIONABLE` default **0.97** (perilaku lama),
|
||||||
|
`AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN` default **0.92**, keduanya coerce number 0..1.
|
||||||
|
- Satu Qdrant batch search pakai threshold RENDAH (0.92). Per hit, klasifikasi ulang:
|
||||||
|
- verdict non-actionable (clean, no flags, action=none): terima jika `score >= CLEAN_BAND`;
|
||||||
|
- verdict actionable (warn/flagged atau flags ada / action != none): terima hanya jika
|
||||||
|
`score >= ACTIONABLE_BAND` (0.97 — persis gate lama);
|
||||||
|
- di antara dua band → buang hit, pesan lanjut ke LLM (fail-open ke akurasi).
|
||||||
|
- Legacy PG fallback path: filter serupa di `findSimilarTextModeration` via parameter band.
|
||||||
|
|
||||||
|
### D5 — Cache Wikipedia search (F6)
|
||||||
|
- `wikipediaClient.wikipediaSearch(query)`: cek `cacheGet(makeCacheKey("wikisearch", q))` dulu;
|
||||||
|
miss → fetch (timeout existing) → sukses & hasil non-kosong → `cacheSet(..., TTL 6h)`.
|
||||||
|
Hasil kosong TIDAK di-cache (biar retry nanti). Redis down → langsung fetch (no-op cache).
|
||||||
|
|
||||||
|
### D6 — Cache URL text fetch (F7)
|
||||||
|
- `urlFetcher.fetchUrlSafely(url)`: wrapper async memoize in-process LRU (max 500, TTL 30 menit)
|
||||||
|
untuk `type === "text"` saja (image tetap selalu fresh-download karena dipakai sbg bukti vision
|
||||||
|
+ buffer besar; error tidak di-cache).
|
||||||
|
- Import `LRUCache` dari `lru-cache` (sudah dep gateway).
|
||||||
|
|
||||||
|
### D7 — Unified vision cache key (F8)
|
||||||
|
- `makeImageCacheKey(imageUrl)` di `textCacheStore.ts`: sebelum hash, strip query Discord CDN
|
||||||
|
(`?ex=&is=&hm=` signed tokens, `format/width/height/size`) — regex `(\?[^#]*)$` dibuang bila host
|
||||||
|
CDN discord (`cdn.discordapp.com`, `media.discordapp.net`, `images-ext-*.discordapp.net`);
|
||||||
|
URL non-Discord: hash full URL seperti sekarang.
|
||||||
|
- Efek: attachment sama yang lolos lewat jalur embed vs inline vs re-fetch dgn token beda → SATU
|
||||||
|
entry cache → skip download+vision kedua kali. Data-URL base64 tetap di-hash apa adanya.
|
||||||
|
|
||||||
|
## File yang disentuh
|
||||||
|
|
||||||
|
1. `src/shared/config/index.ts` — 3 config baru (D3×2, D4×2 — total 4 nilai, 3 baris zod + deskripsi).
|
||||||
|
2. `src/modules/ai-moderation/textCacheStore.ts` — hit_count inc (D1), batched getter (D2),
|
||||||
|
global-reuse guard helper (D3), image-key normalize (D7).
|
||||||
|
3. `src/modules/ai-moderation/moderationOrchestrator.ts` — pakai batched getter (D2),
|
||||||
|
global bare-key fallback (D3), dua-band semantic accept (D4).
|
||||||
|
4. `src/modules/ai-moderation/qdrantClient.ts` — `searchQdrantBatch` menerima threshold rendah
|
||||||
|
(sudah parametrik — mungkin tanpa perubahan; verifikasi).
|
||||||
|
5. `src/modules/ai-moderation/wikipediaClient.ts` — cache layer (D5).
|
||||||
|
6. `src/modules/ai-moderation/urlFetcher.ts` — LRU text-fetch memoize (D6).
|
||||||
|
|
||||||
|
## Schema/type changes
|
||||||
|
|
||||||
|
- Tidak ada migrasi DB (kolom `hit_count`, `analyzed_at`, `expires_at` sudah ada).
|
||||||
|
- Tidak ada perubahan kontrak WS/oRPC/frontend.
|
||||||
|
- Type baru: none public; internal `StoredModerationVerdict` dipakai ulang.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
1. Unit tests baru (`tests/`):
|
||||||
|
- `cacheBatchLookup.test.ts`: batched getter — hit/miss/expired/error-artifact mapping,
|
||||||
|
chunking >200 keys (mock executeAll), hit_count increment called.
|
||||||
|
- `globalReuseGuard.test.ts`: guard menerima clean+conf≥0.85+action none+umur ≤72h;
|
||||||
|
menolak flagged/warn/conf rendah/action≠none/stale.
|
||||||
|
- `semanticBands.test.ts`: clean @0.93 diterima, flagged @0.93 ditolak, flagged @0.98 diterima.
|
||||||
|
- `imageKeyNormalize.test.ts`: URL Discord dgn/ex token → key sama; non-Discord beda query → beda.
|
||||||
|
2. Gate service: `pnpm typecheck && pnpm exec biome check --diagnostic-level=error . && pnpm exec vitest run`.
|
||||||
|
3. Deploy via GHA (`git push origin main`) → watch `Build & Deploy (Nix)` → verifikasi
|
||||||
|
`systemctl show gmw-discord-gateway -p ActiveEnterTimestamp` baru.
|
||||||
|
4. Runtime probe pasca-deploy: journalctl level 30 normal; beberapa jam kemudian
|
||||||
|
`SELECT sum(hit_count) FROM text_analysis_cache WHERE source='user_moderation'` > 0 membuktikan
|
||||||
|
metrics jalan; log "User moderation cache applied" menunjukkan hits>0 pada traffic ramai.
|
||||||
|
|
||||||
|
## Rollback
|
||||||
|
|
||||||
|
Semua fitur behind config defaults yang mempertahankan perilaku lama pada nilai konservatif;
|
||||||
|
rollback = redeploy commit sebelumnya (tanpa migrasi DB, tanpa state eksternal).
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
# Spec: Perbaiki Delay Attachment 162s→<20s (GMW AI Analysis)
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Repo `~/GMW` · Service discord-gateway
|
||||||
|
|
||||||
|
## Evidence (audit produksi)
|
||||||
|
|
||||||
|
Klaster pesan attachment delay ~330–400 detik. Trace pesan `1541417073245290638` (.gif):
|
||||||
|
19:01:08 dibuat → 19:01:09 batch incomplete → fan-out individual → **guard upload-pending
|
||||||
|
mengembalikan `results:[]`** → diperalakukan sukses (`complete ... (undefined)`) → row
|
||||||
|
tertahan `ai_status='processing'` **tanpa penanggung jawab** → 19:06:12 cleanup mengembalikan
|
||||||
|
ke `pending` (tepat 300s) → baru dianalisis. Plus vision gagal 3× utk GIF besar
|
||||||
|
("Stream ended before producing a non-ping SSE event") → degradasi teks.
|
||||||
|
|
||||||
|
## Root causes
|
||||||
|
|
||||||
|
- **A (fatal)**: `individualFallbackProcessor.processIndividualFallback` memperlakukan
|
||||||
|
`ok:true + results:[]` sebagai sukses. Race-guard upload di `ai-analysis-worker.processIndividual`
|
||||||
|
sengaja balik `results:[]` (desain lama) → pesan yatim `processing` sampai cleanup 300s.
|
||||||
|
- **B**: `llmVision` hanya mencoba `stream:true`; kegagalan SSE truncation pada gambar besar
|
||||||
|
= 3 retry sia-sia (semua jalur sama) → bukti media hilang.
|
||||||
|
- **C**: safety-net cleanup 300s terlalu lambat sbg satu-satunya pemulih `processing`.
|
||||||
|
|
||||||
|
## Fix
|
||||||
|
|
||||||
|
1. **F1 — sinyal eksplisit upload-pending**: `IndividualOkResponse` + field opsional
|
||||||
|
`uploadPending?: boolean`. Worker set `uploadPending:true` saat race guard kena.
|
||||||
|
2. **F2 — processor menangani 3 kondisi** via helper murni baru
|
||||||
|
`classifyIndividualWorkerResult(result): "success" | "upload_pending" | "incomplete" | "error"`
|
||||||
|
(modul baru `fallbackResultClassifier.ts`, zero-dep agar mudah dites):
|
||||||
|
- `upload_pending` → tulis ulang row ke `pending` (pola sama dgn revert apiFailed di
|
||||||
|
batchProcessor) + broadcast + **re-schedule analisis percakapan segera**
|
||||||
|
(dynamic import batchScheduler, pola anti-siklus yg sudah ada) → retry dalam ~250ms
|
||||||
|
begitu upload beres. Bukan error, tidak naikkan CB counter.
|
||||||
|
- `incomplete` (flags analysis_incomplete) → perilaku lama (exhausted path).
|
||||||
|
- `error` / `results kosong tanpa penjelasan` → throw transien (retry oleh recovery),
|
||||||
|
BUKAN sukses palsu. Log "(undefined)" hilang.
|
||||||
|
3. **F3 — vision non-stream fallback**: di `llmVision`, jika error match
|
||||||
|
`/Stream ended before producing a non-ping SSE|stream ended/i` → coba SEKALI lagi dengan
|
||||||
|
`stream:false` (router agregasi penuh; timeout tetap 60s). Konversi hard-fail jadi sukses.
|
||||||
|
4. **F4 — turunkan safety net**: default `revertStuckProcessingMessages` 300000 → 120000 ms.
|
||||||
|
|
||||||
|
## File disentuh
|
||||||
|
|
||||||
|
- `src/modules/ai-moderation/fallbackResultClassifier.ts` (BARU, pure)
|
||||||
|
- `src/modules/ai-moderation/ai-analysis-worker.ts` (tipe + set flag uploadPending)
|
||||||
|
- `src/modules/ai-moderation/individualFallbackProcessor.ts` (konsumsi classifier + reschedule)
|
||||||
|
- `src/modules/ai-moderation/llmClient.ts` (fallback non-stream di llmVision)
|
||||||
|
- `src/modules/message-capture/messagesCleanup.ts` (default 120s)
|
||||||
|
|
||||||
|
## Verifikasi
|
||||||
|
|
||||||
|
- Test baru `tests/fallbackResultClassifier.test.ts` (4 klasifikasi + edge kosong).
|
||||||
|
- Gate: tsc --noEmit, biome error-level, vitest run semua hijau.
|
||||||
|
- Deploy GHA sukses; pasca-deploy: pesan attachment baru p50 < 20s
|
||||||
|
(`SELECT percentile_cont(0.5) ... WHERE metadata attachments>0 AND created_at > deploy`),
|
||||||
|
tidak ada lagi "complete ... (undefined)".
|
||||||
@@ -0,0 +1,205 @@
|
|||||||
|
# GMW FE Rombak — "Constellation Ops" Implementation Plan
|
||||||
|
|
||||||
|
> **For Hermes:** Use subagent-driven-development skill to implement this plan task-by-task.
|
||||||
|
> Setiap task = edit → lint → build/test → smoke → commit (author `asepharyana`, tanpa Co-Authored-By).
|
||||||
|
|
||||||
|
**Goal:** Rombak total FE GMW dari template dashboard klasik menjadi **Constellation Node Graph** — graf node interaktif (three.js force layout) di mana channel/event/moderasi adalah node bintang dan navigasi adalah terbang antar node — untuk semua 9 route sekaligus.
|
||||||
|
|
||||||
|
**Architecture:** Hapus total shell template (TopBar + NavRail + MobileNav + main scroll container). Ganti dengan satu `ConstellationStage`: canvas full-bleed fixed di belakang seluruh app yang me-render force-directed graph per-route (scene), plus overlay HTML mengambang (tanpa card box standar) untuk konten detail. Router tetap Next App Router; perpindahan route = kamera "fly-to" ke scene baru. Data tetap lewat pola SSR seed (`page.tsx` server fetch → `view.tsx` client + SWR fallbackData) Line truncated
|
||||||
|
|
||||||
|
**Tech Stack:** Next.js 16 App Router · React 19 · TypeScript strict · Tailwind v4 (token oklch existing) · three r180 (+ `@types/three`) · d3-force (+ `@types/d3-force`) untuk simulasi layout deterministik · motion v12 (transisi antar-scene) · biome (0 error **dan** 0 warning) · bun:test untuk modul murni.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Keputusan Terkunci (2026-08-24, user MythEclipse)
|
||||||
|
|
||||||
|
| Keputusan | Pilihan user |
|
||||||
|
|---|---|
|
||||||
|
| Cakupan | **Semua 9 route sekali jalan** (dashboard, voice, media, recordings, moderation, messages, analysis, channels, glossary) |
|
||||||
|
| Arah struktural | **Constellation Node Graph** — navigasi = terbang antar node |
|
||||||
|
| Tema | **Pertahankan toggle light/dark** (jangan rebuild sistem tema — harden saja) |
|
||||||
|
|
||||||
|
## Kondisi Saat Ini (fakta repo, 2026-08-24)
|
||||||
|
|
||||||
|
- Repo: `/home/code/GMW/services/frontend` (monorepo GMW, deploy Nix-first via GHA "Build & Deploy (Nix)" ke `imphnen.asepharyana.my.id`; nginx gmw-proxy :4009 → Next standalone :4017).
|
||||||
|
- Shell aktif: `src/components/shell/ambient-app.tsx` → `AppFrame` = NavRail + TopBar + `<main>` scroll + MobileNav + MiniPlayer, dipakai SEMUA route via `(dashboard)/layout.tsx`. AmbientCanvas (233 l) masih ada sebagai background di belakang chrome template.
|
||||||
|
- 9 route di `src/app/(dashboard)/`: dashboard (368 l), voice (311), media (293), moderation (424), messages (611), analysis (196), recordings (201), channels (23), glossary (15).
|
||||||
|
- `next.config.ts`: `output: "standalone"` + `trailingSlash: true` → **nav via `router.push`/Link bisa no-op; pakai `<a href>` polos** (pitfall terverifikasi).
|
||||||
|
- Token desain ada di `globals.css` `@theme`: `--color-canvas/-2`, `--color-ink/-soft/-faint`, `--color-signal(+glow)`, amber, vermilion, hairline; font Bricolage Grotesque/JetBrains Mono; `.light` class override + next-themes sudah jalan — JANGAN dibangun ulang.
|
||||||
|
- Sudah ada & dipertahankan: CommandPalette (⌘K), Chatbot, MiniPlayer, primitives (GlassPanel/GlassCard/Badge/…), shared states+skeletons, `lib/format.ts`, `lib/ai-status.ts`, WS typed events (`message_created/updated/deleted/analyzed/snapshot*`, `voice_state`, dll).
|
||||||
|
- Tooling lokal: `pnpm lint|build|format`, `pnpm exec biome check --write --unsafe .` buat import order; bun 1.3.14 tersedia untuk unit test modul murni.
|
||||||
|
- Smoke test SELALU di port non-prod (mis. 4024), cek `ss -ltnp | grep <port>` dulu; JANGAN pernah bind 4017.
|
||||||
|
|
||||||
|
## Struktur Target
|
||||||
|
|
||||||
|
```
|
||||||
|
src/components/shell/
|
||||||
|
constellation-frame.tsx # pengganti AppFrame: tanpa topbar/rail/bottom-bar
|
||||||
|
constellation-stage.tsx # canvas three.js full-bleed + hit-test + kamera
|
||||||
|
route-scenes.ts # map pathname → scene config (nodes/edges/focus)
|
||||||
|
floating-chrome.tsx # brand mark + status dot + route switcher minim + palette hint
|
||||||
|
(hapus setelah migrasi): topbar.tsx, nav-rail.tsx, mobile-nav.tsx, ambient-app.tsx*
|
||||||
|
src/lib/constellation/
|
||||||
|
graph.ts # tipe GraphNode/GraphEdge + builder dari data API (murni)
|
||||||
|
layout.ts # wrapper d3-force deterministik (seeded) + fallback radial (murni)
|
||||||
|
camera.ts # interpolasi kamera fly-to (murni, testable)
|
||||||
|
*.test.ts # bun:test utk modul murni di atas
|
||||||
|
```
|
||||||
|
|
||||||
|
*ambient-canvas/context dievaluasi: bisa di-refactor jadi bagian dari stage atau dihapus.
|
||||||
|
|
||||||
|
### Scene per route (bahasa visual satu kesatuan)
|
||||||
|
|
||||||
|
| Route | Scene |
|
||||||
|
|---|---|
|
||||||
|
| `/dashboard/` | Guild = bintang pusat; channel = node orbit; event WS = pulse edge; metric cluster floating (tanpa box) |
|
||||||
|
| `/channels/` | Semua channel sebagai konstelasi; klik channel = fly-to + panel detail mengambang |
|
||||||
|
| `/moderation/` | Pesan flagged = node merah (vermilion) mengelilingi hub AI-verdict; LiveModerationFeed jadi ribbon ticker |
|
||||||
|
| `/messages/` | Stream pesan = sabuk orbital; node baru masuk lewat animasi dari tepi |
|
||||||
|
| `/voice/` | Stage node di tengah; speaker aktif = node mengorbit dengan glow intensitas |
|
||||||
|
| `/media/` | Galeri spiral node (thumbnail via div bg-image); klik = MiniPlayer handoff |
|
||||||
|
| `/recordings/` | Variasi spiral media, timeline density sebagai cincin |
|
||||||
|
| `/analysis/` | Hub analitik; chart existing dirender sebagai panel translusen mengambang ter-anchor ke node |
|
||||||
|
| `/glossary/` | Term KB = cincin satelit; search memfilter node real-time |
|
||||||
|
|
||||||
|
### Checklist anti-default (wajib lolos sebelum commit akhir)
|
||||||
|
|
||||||
|
- [ ] Tidak ada top bar / nav rail / side panel / bottom prompt bar
|
||||||
|
- [ ] Tidak ada card grid standar sebagai struktur utama
|
||||||
|
- [ ] Layout beda SPASIAL dari versi lama (bukan ganti warna)
|
||||||
|
- [ ] Verifikasi visual sungguhan: browser_navigate + browser_vision → "bukan template dashboard"
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Tasks
|
||||||
|
|
||||||
|
### Phase 0 — Fondasi
|
||||||
|
|
||||||
|
#### Task 1: Dependencies + scaffold direktori
|
||||||
|
**Files:** `package.json`, create `src/lib/constellation/`
|
||||||
|
1. `cd /home/code/GMW/services/frontend && pnpm add three @types/three d3-force @types/d3-force && pnpm add -d @types/bun`
|
||||||
|
2. Buat 4 file kosong bertipe sesuai struktur target (ekspor placeholder bertipe agar tsc lolos).
|
||||||
|
3. Run: `pnpm exec tsc --noEmit` → expected PASS.
|
||||||
|
4. Commit: `chore(frontend): scaffold constellation lib + deps`.
|
||||||
|
|
||||||
|
> Catatan: jika `@types/bun` bentrok dengan tsconfig Next (duplikat globals), fallback: exclude `"**/*.test.ts"` dari `tsconfig.json` dan jalankan test hanya via `bun test` (tanpa typecheck). Putuskan saat eksekusi, dokumentasikan di commit body.
|
||||||
|
|
||||||
|
#### Task 2 (TDD): Modul murni graph + layout + camera
|
||||||
|
**Files:** `src/lib/constellation/{graph,layout,camera}.ts` + `*.test.ts`
|
||||||
|
1. **Tulis test dulu** (bun:test):
|
||||||
|
- `graph.test.ts`: builder `dashboardToGraph()` menghasilkan node guild pusat + N channel + edges benar dari fixture `/api/dashboard/stats|channels` shape (lihat AGENTS.md; nama channel di `message.metadata.channelName`).
|
||||||
|
- `layout.test.ts`: `computeLayout(nodes, edges, {seed:42})` DETERMINISTIK (dua pemanggilan = posisi identik), semua posisi finite, radius dalam bound; `radialLayout()` fallback untuk reduced-motion.
|
||||||
|
- `camera.test.ts`: `flyTo(from,to,t)` easing monotonic, t=0→from, t=1→to.
|
||||||
|
2. Run: `bun test src/lib/constellation` → expected FAIL (belum diimplementasi).
|
||||||
|
3. Implementasi minimal sampai PASS.
|
||||||
|
4. Run: `bun test src/lib/constellation && pnpm exec tsc --noEmit` → PASS.
|
||||||
|
5. Commit: `feat(frontend): constellation graph/layout/camera pure modules (tested)`.
|
||||||
|
|
||||||
|
### Phase 1 — Shell Baru
|
||||||
|
|
||||||
|
#### Task 3: `ConstellationStage` (canvas renderer)
|
||||||
|
**Files:** `src/components/shell/constellation-stage.tsx`
|
||||||
|
- `<canvas>` fixed inset-0 `-z-10`; three.js orthographic 2D-ish render node+edge (sprite/glow shader sederhana — jangan over-engineer; titik + garis + glow cukup).
|
||||||
|
- Interaksi: drag = pan, wheel/pinch = zoom, hover = highlight node + tooltip title, click node ber-`href` = navigasi via `<a>` semantics (window.location assign — hindari router.push no-op).
|
||||||
|
- DPR-aware resize; pause render loop saat `document.hidden`; `prefers-reduced-motion` → render statis tanpa animasi.
|
||||||
|
- Sampling warna dari CSS var (`getComputedStyle`) + `MutationObserver` pada `documentElement.classList` agar ikut toggle dark/light.
|
||||||
|
- Verify: `pnpm lint && pnpm build` PASS → commit `feat(frontend): constellation stage canvas renderer`.
|
||||||
|
|
||||||
|
#### Task 4: `route-scenes.ts` + `ConstellationFrame`
|
||||||
|
**Files:** `src/components/shell/{route-scenes,constellation-frame,floating-chrome}.tsx`
|
||||||
|
- `route-scenes.ts`: `usePathname()` → config scene (builder mana, focus node, overlay slots).
|
||||||
|
- `ConstellationFrame`: render `ConstellationStage` + `children` (overlay HTML absolute, BUKAN flex column bertingkat) + `floating-chrome` (brand mark kiri-atas dgn status dot WS, switcher route minimal kanan-bawah, hint ⌘K). MiniPlayer + Chatbot + CommandPalette tetap termounting.
|
||||||
|
- Switcher & semua nav internal pakai `<a href="/<route>/">` polos (trailingSlash!). Verifikasi klik di browser nanti, bukan cuma baca kode.
|
||||||
|
- Wire `(dashboard)/layout.tsx`: ganti `AppFrame` → `ConstellationFrame` (provider Ambient/WS tetap).
|
||||||
|
- Smoke: `ss -ltnp | grep 4024` kosong → `PORT=4024 pnpm start` → curl semua 9 route = 200 → kill server.
|
||||||
|
- Commit: `feat(frontend): replace classic shell with constellation frame`.
|
||||||
|
|
||||||
|
#### Task 5: Hapus chrome lama + bersih-bersih
|
||||||
|
**Files:** hapus `shell/topbar.tsx`, `shell/nav-rail.tsx`, `shell/mobile-nav.tsx`, update `shell/index.ts`; eval `ambient-*` (refactor jadi layer stage ATAU hapus).
|
||||||
|
- `grep -rn "TopBar\|NavRail\|MobileNav\|AppFrame" src/` → 0 referensi tersisa.
|
||||||
|
- `pnpm exec biome check --write --unsafe .` → import order rapi.
|
||||||
|
- Commit: `refactor(frontend): remove classic dashboard chrome`.
|
||||||
|
|
||||||
|
### Phase 2 — Migrasi 9 Route (batch, tiap batch ship hijau)
|
||||||
|
|
||||||
|
Urutan sengaja dari yang paling graph-native ke paling padat konten:
|
||||||
|
|
||||||
|
#### Task 6: `/dashboard/` + `/channels/`
|
||||||
|
- Dashboard view: guild-star + channel orbit + WS pulse; metric cluster floating (reuse MetricTile tapi tanpa card-box — ubah jadi teks besar + hairline).
|
||||||
|
- Channels: konstelasi channel, klik = fly-to + panel detail (data dari hook `use-channels`/server fetch existing — jangan karang endpoint).
|
||||||
|
- Lint+build+smoke 2 route → commit `feat(frontend): dashboard & channels constellation scenes`.
|
||||||
|
|
||||||
|
#### Task 7: `/glossary/` + `/analysis/`
|
||||||
|
- Glossary: cincin satelit term + search filter node (client-side filter pada node label).
|
||||||
|
- Analysis: hub node + panel chart translusen mengambang (reuse komponen charts/* existing, anchor posisi ke node).
|
||||||
|
- Commit: `feat(frontend): glossary & analysis scenes`.
|
||||||
|
|
||||||
|
#### Task 8: `/messages/` + `/moderation/`
|
||||||
|
- Messages: sabuk orbital stream (reuse sortMessages/logika WS `use-messages.ts` — jangan duplikasi); pesan baru animate-in dari tepi.
|
||||||
|
- Moderation: flagged nodes vermilion + hub verdict; LiveModerationFeed jadi ticker ribbon bawah (bukan panel).
|
||||||
|
- Commit: `feat(frontend): messages & moderation scenes`.
|
||||||
|
|
||||||
|
#### Task 9: `/voice/` + `/media/` + `/recordings/`
|
||||||
|
- Voice: speaker aktif orbit dgn glow (seed dari snapshot `activeSpeakers` server-authoritative — pertahankan perilaku useSpeakers).
|
||||||
|
- Media/Recordings: spiral galeri; thumbnail pakai div background-image (hindari `noImgElement`; kalau terpaksa `<img>` → `// biome-ignore lint/performance/noImgElement: <alasan>`); MiniPlayer handoff tidak boleh regress.
|
||||||
|
- Commit: `feat(frontend): voice, media & recordings scenes`.
|
||||||
|
|
||||||
|
### Phase 3 — Polish, A11y, Tema
|
||||||
|
|
||||||
|
#### Task 10: Motion & micro-interaction
|
||||||
|
- Fly-to antar scene pakai `motion` v12 (interpolasi kamera dari `lib/constellation/camera.ts`).
|
||||||
|
- Overlay: stagger masuk (pattern `animate-stagger` + `staggerDelay(i)` existing); tambah semua `animate-*` baru ke kill-list `prefers-reduced-motion` di `globals.css`.
|
||||||
|
- Commit: `feat(frontend): scene transitions + motion polish`.
|
||||||
|
|
||||||
|
#### Task 11: A11y & fallback semantik
|
||||||
|
- Setiap scene punya fallback semantik: `<nav aria-label="Routes">` visually-hidden berisi link semua route + `<ul>` node (label + href) yang ter-sync dgn graph state.
|
||||||
|
- Keyboard: Tab ke daftar route; panah memindahkan fokus antar node; Enter aktivasi; `focus-visible` ring pakai `--color-ring`.
|
||||||
|
- Kontras ink-on-canvas dicek (WCAG AA utk teks overlay).
|
||||||
|
- Hooks-after-early-return pitfall: compute flags dulu, guard di dalam useEffect.
|
||||||
|
- Commit: `feat(frontend): a11y semantic fallback for constellation`.
|
||||||
|
|
||||||
|
#### Task 12: Hardening tema light/dark
|
||||||
|
- Pastikan canvas sampling var `.light` benar saat toggle (MutationObserver dari Task 3); harden `.light`: `color-scheme: light`, scrollbar/selection, glow opacity turun.
|
||||||
|
- JANGAN menyentuh sistem next-themes.
|
||||||
|
- Commit: `fix(frontend): light theme parity for constellation stage`.
|
||||||
|
|
||||||
|
### Phase 4 — Gerbang Ship (non-negotiable)
|
||||||
|
|
||||||
|
#### Task 13: Verifikasi penuh + deploy
|
||||||
|
1. `pnpm lint` → **Found 0 errors. Found 0 warnings** (warning pun harus bersih).
|
||||||
|
2. `bun test src/lib/constellation` → all PASS; `pnpm exec tsc --noEmit` PASS; `pnpm build` sukses.
|
||||||
|
3. Smoke: port 4024 bersih → start → curl 9 route (dgn trailing slash) semua **200** → kill server.
|
||||||
|
4. `git status` audit: hanya file scope yang ter-commit (`git checkout --` file liar hasil auto-format).
|
||||||
|
5. Push `origin/main` → `gh run watch` "Build & Deploy (Nix)" → hijau.
|
||||||
|
6. Live: curl `https://imphnen.asepharyana.my.id/<route>/` × 9 → 200.
|
||||||
|
7. **Verifikasi visual sungguhan**: browser_navigate ke live URL → browser_vision: "Apakah ini masih template dashboard?" — jawaban HARUS bukan; screenshot dikirim ke user (`MEDIA:<path>`).
|
||||||
|
8. Checklist anti-default dicentang satu per satu di PR/commit message akhir.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Files Likely to Change
|
||||||
|
|
||||||
|
```
|
||||||
|
package.json, pnpm-lock.yaml
|
||||||
|
next.config.ts # hanya jika perlu (tidak diharapkan)
|
||||||
|
src/app/(dashboard)/layout.tsx # wire frame baru
|
||||||
|
src/app/(dashboard)/{9 route}/page.tsx|view.tsx # scene migration
|
||||||
|
src/components/shell/** # frame baru + hapus lama
|
||||||
|
src/lib/constellation/** # baru (murni + test)
|
||||||
|
src/app/globals.css # tokens tambahan + reduced-motion kill list
|
||||||
|
biome.json # hanya jika rule intentional baru perlu "off"
|
||||||
|
```
|
||||||
|
|
||||||
|
## Risks / Tradeoffs
|
||||||
|
|
||||||
|
| Risiko | Mitigasi |
|
||||||
|
|---|---|
|
||||||
|
| Perf GPU di device lemah (full-page WebGL) | Node count kecil (<200/route), pause saat tab hidden, fallback radial statis utk reduced-motion; jika tetap berat → downgrade ke Canvas2D renderer dgn API sama |
|
||||||
|
| router.push no-op (standalone+trailingSlash) | Semua nav pakai `<a href>`; verifikasi klik nyata di browser |
|
||||||
|
| Konten padat (messages 611 l) tak muat di metafora graf | Prinsip: graf = struktur/navigasi; DETAIL tetap lewat overlay panel mengambang — konten tidak dikorbankan, hanya wadahnya yang berubah |
|
||||||
|
| Bun test + Next tsconfig konflik types | Exclude `*.test.ts` dari tsconfig (keputusan Task 1) |
|
||||||
|
| Regress fitur existing (theme toggle, palette, chatbot, mini-player, WS sync) | Dipertahankan apa adanya; smoke + klik verifikasi per phase; sortMessages & snapshot voice TIDAK disentuh logikanya |
|
||||||
|
|
||||||
|
## Open Questions
|
||||||
|
|
||||||
|
- Tidak ada blocker. Satu preferensi opsional: apakah brand mark perlu jam/uptime seperti TopBar lama — default: tidak (minimal). Keputusan bisa diambil saat review Task 4.
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
# GMW FE — Monokrom Hitam-Putih + Sidebar Ala Menu Game + Ringan di Mobile
|
||||||
|
|
||||||
|
Tanggal: 2026-08-24 · Basis: `eda5c75` (shell usable hasil revert)
|
||||||
|
|
||||||
|
## Tujuan
|
||||||
|
1. Tema **monokrom murni** (hitam-putih, tanpa warna) di dark & light.
|
||||||
|
2. Sidebar (desktop NavRail + mobile dock) beranimasi **ala menu game** — corner
|
||||||
|
brackets, sweep, stagger masuk, marker segitiga.
|
||||||
|
3. **Ringan di mobile**: matikan WebGL ambient di layar kecil, kurangi biaya
|
||||||
|
blur/backdrop, animasi transform/opacity saja.
|
||||||
|
|
||||||
|
## Non-goals
|
||||||
|
- Tidak menyentuh backend, endpoint, hooks/data-flow, struktur route.
|
||||||
|
- Tidak menambah dependensi baru (CSS murni untuk semua animasi).
|
||||||
|
|
||||||
|
## File yang disentuh
|
||||||
|
| File | Perubahan |
|
||||||
|
|---|---|
|
||||||
|
| `src/app/globals.css` | Token mono (dark+light): signal/amber/vermilion → skala putih-abu; `.glass` blur adaptif; kelas baru `.game-nav-item` (bracket ::before/::after, sweep, stagger via `--i`), `.game-frame` (panel sudut terpotong + garis tergambar), keyframes `sweep-x`, `draw-line`, `nav-in`; media query `<md`: blur 18→8px, hambat animasi berat |
|
||||||
|
| `src/components/shell/nav-rail.tsx` | Item pakai `.game-nav-item` + `style={{'--i': n}}`; marker aktif jadi segitiga ▸ putih; hapus box-shadow glow besar (ganti sweep) |
|
||||||
|
| `src/components/shell/mobile-nav.tsx` | Dock mono: tab aktif = bar atas putih + sweep sekali; target sentuh ≥44px; hapus glow blob |
|
||||||
|
| `src/components/shell/topbar.tsx` | Aksen mono + `.game-frame` pada container (cek markup dulu) |
|
||||||
|
| `src/components/ambient/ambient-canvas.tsx` | Early-return WebGL bila `(pointer: coarse)` / lebar <768 / `saveData` / core ≤4; fallback statik CSS tetap |
|
||||||
|
| `src/components/ambient/status/signal tone` (`SIGNAL_RGB`) | Semua tone jadi grayscale (putih; intensitas beda per tone) |
|
||||||
|
| `src/app/(dashboard)/dashboard/view.tsx` | Hero + kartu metrik pakai `.game-frame`/cut-corner sebagai showcase |
|
||||||
|
|
||||||
|
## Keputusan desain
|
||||||
|
- **Full monokrom termasuk danger**: flag/moderation tidak lagi merah —
|
||||||
|
ditandai badge putih-di-atlas-hitam inversi + pulse. Kalau user kangen merah,
|
||||||
|
tinggal isi ulang `--color-vermilion`.
|
||||||
|
- Semua animasi hanya `transform`/`opacity` (compositor-friendly), hormati
|
||||||
|
`prefers-reduced-motion` (sudah ada kill-switch global).
|
||||||
|
|
||||||
|
## Verifikasi (gerbang)
|
||||||
|
1. `tsc --noEmit` bersih; biome 0 error 0 warning.
|
||||||
|
2. `pnpm build` sukses; smoke lokal 4024 → 9 route 200.
|
||||||
|
3. Push → GHA "Build & Deploy (Nix)" hijau → live 9×200.
|
||||||
|
4. Visual check live: desktop (rail game-menu terlihat) + cek rule mobile
|
||||||
|
(media query & gate kode) — screenshot disimpan.
|
||||||
@@ -93,6 +93,12 @@ type BatchOkResponse = {
|
|||||||
ok: true;
|
ok: true;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
|
/**
|
||||||
|
* Race-guard signal (2026-08-25): target ids whose attachment upload is
|
||||||
|
* still in-flight — NO analysis ran for them. The processor must defer
|
||||||
|
* these (requeue + poll), never fan them out as failures.
|
||||||
|
*/
|
||||||
|
uploadPendingIds?: string[];
|
||||||
};
|
};
|
||||||
type BatchErrorResponse = {
|
type BatchErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
@@ -100,7 +106,16 @@ type BatchErrorResponse = {
|
|||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
error: string;
|
error: string;
|
||||||
};
|
};
|
||||||
type IndividualOkResponse = { ok: true; results: AnalysisResult[] };
|
type IndividualOkResponse = {
|
||||||
|
ok: true;
|
||||||
|
results: AnalysisResult[];
|
||||||
|
/**
|
||||||
|
* Race-guard signal (2026-08-24): the message's attachment upload is still
|
||||||
|
* in-flight — NO analysis ran. The processor must re-queue the message as
|
||||||
|
* `pending` and re-schedule, never treat this as a completed moderation.
|
||||||
|
*/
|
||||||
|
uploadPending?: boolean;
|
||||||
|
};
|
||||||
type IndividualErrorResponse = {
|
type IndividualErrorResponse = {
|
||||||
ok: false;
|
ok: false;
|
||||||
results: AnalysisResult[];
|
results: AnalysisResult[];
|
||||||
@@ -310,7 +325,16 @@ async function processBatch(job: {
|
|||||||
? messages
|
? messages
|
||||||
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
: messages.filter((m) => !pendingUploadTargetIds.has(m.id));
|
||||||
if (readyMessages.length === 0) {
|
if (readyMessages.length === 0) {
|
||||||
return { ok: true, conversationKey, rows: [] };
|
// Explicit signal (2026-08-25): every target is still upload-pending.
|
||||||
|
// Returning bare {ok:true, rows:[]} made the processor classify all of
|
||||||
|
// them "incomplete" and fan out to the individual queue — a hot ~300ms
|
||||||
|
// requeue loop for the whole upload duration.
|
||||||
|
return {
|
||||||
|
ok: true,
|
||||||
|
conversationKey,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: messages.map((m) => m.id),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
// The orchestrator handles text/media split + caching + parallel paths
|
// The orchestrator handles text/media split + caching + parallel paths
|
||||||
@@ -415,7 +439,7 @@ async function processIndividual(job: {
|
|||||||
(a) => a.message_id === message.id && a.upload_status === "pending",
|
(a) => a.message_id === message.id && a.upload_status === "pending",
|
||||||
);
|
);
|
||||||
if (uploadStillPending) {
|
if (uploadStillPending) {
|
||||||
return { ok: true, results: [] };
|
return { ok: true, results: [], uploadPending: true };
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
/**
|
||||||
|
* batchOutcomeClassifier.ts
|
||||||
|
*
|
||||||
|
* Pure partitioner of the batch worker response (2026-08-25).
|
||||||
|
*
|
||||||
|
* Bug history: the batch race guard returned `{ok:true, rows:[]}` when every
|
||||||
|
* target's attachment upload was still in-flight. The processor classified all
|
||||||
|
* of them as "incomplete" and fanned out to the individual queue, where the
|
||||||
|
* guard there requeued + rescheduled at the 250ms debounce — a hot ~300ms loop
|
||||||
|
* for the entire upload duration (~10 cycles in 3s in prod logs). Root fix:
|
||||||
|
* the worker now reports `uploadPendingIds` explicitly and this pure function
|
||||||
|
* partitions the outcome so upload-pending targets NEVER enter the fanout.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export interface BatchRowLike {
|
||||||
|
id?: string;
|
||||||
|
ai_status?: string | null;
|
||||||
|
ai_moderation_flags?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface BatchWorkerResponseLike {
|
||||||
|
ok?: boolean;
|
||||||
|
rows?: BatchRowLike[];
|
||||||
|
/** Explicit race-guard signal from the worker (2026-08-25). */
|
||||||
|
uploadPendingIds?: string[];
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** One target's per-message disposition after a batch attempt. */
|
||||||
|
export type BatchTargetKind =
|
||||||
|
| "completed"
|
||||||
|
| "upload_pending"
|
||||||
|
| "incomplete"
|
||||||
|
| "parse_failed"
|
||||||
|
| "api_failed";
|
||||||
|
|
||||||
|
function flagsOf(row: { ai_moderation_flags?: string | null }): string[] {
|
||||||
|
if (!row.ai_moderation_flags) return [];
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(row.ai_moderation_flags) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [] as string[];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Partition the input message ids into per-message dispositions for one batch
|
||||||
|
* worker response. Pure: no DB/Piscina/logger — unit-testable directly.
|
||||||
|
*
|
||||||
|
* Priority per id: explicit uploadPendingIds → completed row → flag-based
|
||||||
|
* failure kinds → unexplained missing (treated like incomplete).
|
||||||
|
*/
|
||||||
|
export function partitionBatchOutcome(
|
||||||
|
messages: ReadonlyArray<{ id: string }>,
|
||||||
|
response: BatchWorkerResponseLike,
|
||||||
|
): Map<string, BatchTargetKind> {
|
||||||
|
const pendingSet = new Set(response.uploadPendingIds ?? []);
|
||||||
|
const rowsById = new Map(
|
||||||
|
(response.rows ?? [])
|
||||||
|
.filter((r): r is BatchRowLike & { id: string } => Boolean(r?.id))
|
||||||
|
.map((r) => [r.id, r]),
|
||||||
|
);
|
||||||
|
|
||||||
|
const out = new Map<string, BatchTargetKind>();
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (pendingSet.has(msg.id)) {
|
||||||
|
out.set(msg.id, "upload_pending");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const row = rowsById.get(msg.id);
|
||||||
|
if (!row) {
|
||||||
|
// Unexplained drop: LLM silently omitted it. Same retryable bucket as
|
||||||
|
// analysis_incomplete — never a silent success.
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
if (row.ai_status !== "error") {
|
||||||
|
out.set(msg.id, "completed");
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const flags = flagsOf(row);
|
||||||
|
if (flags.includes("analysis_incomplete")) {
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
} else if (flags.includes("analysis_parse_failed")) {
|
||||||
|
out.set(msg.id, "parse_failed");
|
||||||
|
} else if (flags.includes("analysis_api_failed")) {
|
||||||
|
out.set(msg.id, "api_failed");
|
||||||
|
} else {
|
||||||
|
out.set(msg.id, "incomplete");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Linear backoff ramp for consecutive upload-pending polls:
|
||||||
|
* poll N (1-based) waits min(base × N, cap). Keeps latency low for fast
|
||||||
|
* uploads while bounding total polling cost for long uploads.
|
||||||
|
*/
|
||||||
|
export function computeUploadPollDelayMs(
|
||||||
|
consecutivePolls: number,
|
||||||
|
baseMs: number,
|
||||||
|
capMs: number,
|
||||||
|
): number {
|
||||||
|
const n = Math.max(1, Math.floor(consecutivePolls));
|
||||||
|
return Math.min(Math.round(baseMs * n), Math.round(capMs));
|
||||||
|
}
|
||||||
@@ -4,6 +4,10 @@ import { isAgeRestrictedMetadata } from "../message-capture/messageMetadata.js";
|
|||||||
import { messageStore } from "../message-capture/messageStore.js";
|
import { messageStore } from "../message-capture/messageStore.js";
|
||||||
import type { MessageRecord } from "../message-capture/types.js";
|
import type { MessageRecord } from "../message-capture/types.js";
|
||||||
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
import { pickBatchWithinBudget as pickBatchWithinBudgetPure } from "./batchBudget.js";
|
||||||
|
import {
|
||||||
|
computeUploadPollDelayMs,
|
||||||
|
partitionBatchOutcome,
|
||||||
|
} from "./batchOutcomeClassifier.js";
|
||||||
import { workerPool } from "./circuitBreaker.js";
|
import { workerPool } from "./circuitBreaker.js";
|
||||||
import { estimateTokens } from "./conversationContext.js";
|
import { estimateTokens } from "./conversationContext.js";
|
||||||
import {
|
import {
|
||||||
@@ -21,11 +25,20 @@ import {
|
|||||||
|
|
||||||
const logger = createChildLogger("batch-processor");
|
const logger = createChildLogger("batch-processor");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Consecutive upload-pending poll counter per conversation (2026-08-25).
|
||||||
|
* Drives the linear backoff ramp while attachments are still uploading;
|
||||||
|
* cleared as soon as a batch comes back with no upload-pending targets.
|
||||||
|
*/
|
||||||
|
const conversationUploadPolls = new Map<string, number>();
|
||||||
|
|
||||||
export interface AnalysisWorkerResponse {
|
export interface AnalysisWorkerResponse {
|
||||||
ok: boolean;
|
ok: boolean;
|
||||||
conversationKey: string;
|
conversationKey: string;
|
||||||
rows: MessageRecord[];
|
rows: MessageRecord[];
|
||||||
error?: string;
|
error?: string;
|
||||||
|
/** Explicit upload-in-flight signal from the batch race guard (2026-08-25). */
|
||||||
|
uploadPendingIds?: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -141,6 +154,8 @@ export async function processBatch(
|
|||||||
|
|
||||||
activeRequests++;
|
activeRequests++;
|
||||||
let shouldScheduleNext = false;
|
let shouldScheduleNext = false;
|
||||||
|
/** Set when upload-pending targets defer the next cycle by this many ms. */
|
||||||
|
let deferredUploadRescheduleMs: number | null = null;
|
||||||
try {
|
try {
|
||||||
const result = (await workerPool.run({
|
const result = (await workerPool.run({
|
||||||
type: "batch",
|
type: "batch",
|
||||||
@@ -199,37 +214,85 @@ export async function processBatch(
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Batch succeeded -- check for messages the LLM silently dropped or failed
|
// Batch succeeded -- partition per-message outcome explicitly (2026-08-25).
|
||||||
const incompleteMessages: MessageRecord[] = [];
|
// upload_pending targets are DEFERRED (never fanned out): the old code
|
||||||
const parseFailedMessages: MessageRecord[] = [];
|
// treated them as incomplete -> individual queue -> requeue+250ms
|
||||||
|
// reschedule -> hot ~300ms loop for the whole upload duration.
|
||||||
|
const outcomeById = partitionBatchOutcome(messages, result);
|
||||||
|
const messagesForIndividualQueue: MessageRecord[] = [];
|
||||||
const apiFailedMessages: MessageRecord[] = [];
|
const apiFailedMessages: MessageRecord[] = [];
|
||||||
|
const uploadPendingMessages: MessageRecord[] = [];
|
||||||
|
|
||||||
for (const msg of messages) {
|
for (const msg of messages) {
|
||||||
const row = result.rows.find((r) => r.id === msg.id);
|
switch (outcomeById.get(msg.id)) {
|
||||||
if (!row) {
|
case "upload_pending":
|
||||||
incompleteMessages.push(msg);
|
uploadPendingMessages.push(msg);
|
||||||
continue;
|
break;
|
||||||
}
|
case "api_failed":
|
||||||
if (row.ai_status === "error") {
|
// Preserve the dedicated api-failure semantics below: revert +
|
||||||
let flags: string[] = [];
|
// conversation cooldown instead of an immediate individual retry.
|
||||||
try {
|
|
||||||
flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
|
|
||||||
} catch {}
|
|
||||||
|
|
||||||
if (flags.includes("analysis_incomplete")) {
|
|
||||||
incompleteMessages.push(msg);
|
|
||||||
} else if (flags.includes("analysis_parse_failed")) {
|
|
||||||
parseFailedMessages.push(msg);
|
|
||||||
} else if (flags.includes("analysis_api_failed")) {
|
|
||||||
apiFailedMessages.push(msg);
|
apiFailedMessages.push(msg);
|
||||||
}
|
break;
|
||||||
|
default:
|
||||||
|
// incomplete / parse_failed / unexplained drops stay retryable via
|
||||||
|
// the individual fallback queue (same semantics as before).
|
||||||
|
messagesForIndividualQueue.push(msg);
|
||||||
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const messagesForIndividualQueue = [
|
if (uploadPendingMessages.length > 0) {
|
||||||
...incompleteMessages,
|
const polls = (conversationUploadPolls.get(conversationKey) ?? 0) + 1;
|
||||||
...parseFailedMessages,
|
conversationUploadPolls.set(conversationKey, polls);
|
||||||
];
|
const delayMs = computeUploadPollDelayMs(
|
||||||
|
polls,
|
||||||
|
config.AI_ANALYSIS_UPLOAD_POLL_MS,
|
||||||
|
config.AI_ANALYSIS_MAX_UPLOAD_POLL_MS,
|
||||||
|
);
|
||||||
|
logger.debug(
|
||||||
|
{
|
||||||
|
conversationKey,
|
||||||
|
count: uploadPendingMessages.length,
|
||||||
|
ids: uploadPendingMessages.map((m) => m.id),
|
||||||
|
pollAttempt: polls,
|
||||||
|
delayMs,
|
||||||
|
},
|
||||||
|
"Attachment upload in-flight for batch targets — deferring with poll backoff",
|
||||||
|
);
|
||||||
|
|
||||||
|
// Put the rows back to `pending` so the scheduler owns them again.
|
||||||
|
await messageStore
|
||||||
|
.updateMessagesAIAnalysisBulk(
|
||||||
|
uploadPendingMessages.map((msg) => ({
|
||||||
|
messageId: msg.id,
|
||||||
|
result: {
|
||||||
|
status: "pending",
|
||||||
|
flags: null,
|
||||||
|
score: null,
|
||||||
|
analysis: null,
|
||||||
|
categories: null,
|
||||||
|
severity: null,
|
||||||
|
confidence: null,
|
||||||
|
recommendedAction: null,
|
||||||
|
analyzedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: String(err), ids: uploadPendingMessages.map((m) => m.id) },
|
||||||
|
"Failed to revert upload-pending batch targets to pending",
|
||||||
|
);
|
||||||
|
return [] as MessageRecord[];
|
||||||
|
});
|
||||||
|
|
||||||
|
// Poll backoff instead of the 250ms debounce: the finally-block
|
||||||
|
// schedules the next cycle after this delay instead of immediately.
|
||||||
|
deferredUploadRescheduleMs = delayMs;
|
||||||
|
} else {
|
||||||
|
conversationUploadPolls.delete(conversationKey);
|
||||||
|
}
|
||||||
|
|
||||||
if (messagesForIndividualQueue.length > 0) {
|
if (messagesForIndividualQueue.length > 0) {
|
||||||
logger.warn(
|
logger.warn(
|
||||||
@@ -307,7 +370,11 @@ export async function processBatch(
|
|||||||
resetConversationBatchFailures(conversationKey);
|
resetConversationBatchFailures(conversationKey);
|
||||||
conversationErrorCooldown.delete(conversationKey);
|
conversationErrorCooldown.delete(conversationKey);
|
||||||
}
|
}
|
||||||
|
// Upload-pending defer owns the next-cycle timing; don't let the default
|
||||||
|
// immediate schedule override it.
|
||||||
|
if (deferredUploadRescheduleMs === null) {
|
||||||
shouldScheduleNext = true;
|
shouldScheduleNext = true;
|
||||||
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
recordConversationBatchFailure(conversationKey);
|
recordConversationBatchFailure(conversationKey);
|
||||||
|
|
||||||
@@ -344,7 +411,17 @@ export async function processBatch(
|
|||||||
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
if (conversationProcessing.get(conversationKey) === processingStartedAt) {
|
||||||
conversationProcessing.delete(conversationKey);
|
conversationProcessing.delete(conversationKey);
|
||||||
}
|
}
|
||||||
if (shouldScheduleNext) {
|
if (deferredUploadRescheduleMs !== null) {
|
||||||
|
// Upload still in-flight: re-schedule after the backoff delay instead of
|
||||||
|
// immediately (the old path hot-looped at ~250-300ms per cycle).
|
||||||
|
const delayMs = deferredUploadRescheduleMs;
|
||||||
|
setTimeout(() => {
|
||||||
|
// Dynamic import to avoid circular dependency at module scope
|
||||||
|
import("./batchScheduler.js").then((m) =>
|
||||||
|
m.scheduleConversationAnalysis(conversationKey),
|
||||||
|
);
|
||||||
|
}, delayMs).unref();
|
||||||
|
} else if (shouldScheduleNext) {
|
||||||
setImmediate(() => {
|
setImmediate(() => {
|
||||||
// Dynamic import to avoid circular dependency at module scope
|
// Dynamic import to avoid circular dependency at module scope
|
||||||
import("./batchScheduler.js").then((m) =>
|
import("./batchScheduler.js").then((m) =>
|
||||||
|
|||||||
@@ -0,0 +1,65 @@
|
|||||||
|
/**
|
||||||
|
* fallbackResultClassifier.ts
|
||||||
|
*
|
||||||
|
* Pure classifier for the individual-fallback worker response.
|
||||||
|
*
|
||||||
|
* Bug history (2026-08-24): the worker's upload-pending race guard returned
|
||||||
|
* `{ ok: true, results: [] }` (a legacy "no results yet" signal), but the
|
||||||
|
* processor treated ANY `ok:true` as a successful moderation. Empty results
|
||||||
|
* meant nothing was written to the DB — the message stayed stuck in
|
||||||
|
* `ai_status='processing'` with nobody watching it until the 300s cleanup
|
||||||
|
* reverted it. That single gap produced the ~330-400s attachment delay
|
||||||
|
* cluster. Classification now happens in ONE pure function so every outcome
|
||||||
|
* has an explicit, testable owner.
|
||||||
|
*/
|
||||||
|
|
||||||
|
export type WorkerResultKind =
|
||||||
|
| "success"
|
||||||
|
| "upload_pending"
|
||||||
|
| "incomplete"
|
||||||
|
| "error";
|
||||||
|
|
||||||
|
export interface ClassifiableWorkerResult {
|
||||||
|
ok?: boolean;
|
||||||
|
/** Upload-pending marker set by ai-analysis-worker's race guard. */
|
||||||
|
uploadPending?: boolean;
|
||||||
|
results?: Array<{ status?: string; flags?: string[] | string } | undefined>;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
function flagsOf(r: { flags?: string[] | string }): string[] {
|
||||||
|
if (!r.flags) return [];
|
||||||
|
if (Array.isArray(r.flags)) return r.flags;
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(r.flags) as unknown;
|
||||||
|
return Array.isArray(parsed) ? (parsed as string[]) : [];
|
||||||
|
} catch {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Classify an individual-fallback worker response:
|
||||||
|
* - "upload_pending": explicit race-guard signal — retry shortly, NOT an error.
|
||||||
|
* - "success": at least one result and none is analysis_incomplete.
|
||||||
|
* - "incomplete": LLM ran but dropped/failed this message after retries
|
||||||
|
* (analysis_incomplete flag) — terminal exhausted path.
|
||||||
|
* - "error": anything else (ok:false, or ok:true with NO explainable
|
||||||
|
* results). The old code silently succeeded here — never again.
|
||||||
|
*/
|
||||||
|
export function classifyIndividualWorkerResult(
|
||||||
|
result: ClassifiableWorkerResult,
|
||||||
|
): WorkerResultKind {
|
||||||
|
if (result.uploadPending === true) return "upload_pending";
|
||||||
|
const results = (result.results ?? []).filter(
|
||||||
|
(r): r is NonNullable<typeof r> => Boolean(r),
|
||||||
|
);
|
||||||
|
if (results.length === 0) return "error";
|
||||||
|
if (result.ok !== true) return "error";
|
||||||
|
for (const r of results) {
|
||||||
|
const flags = flagsOf(r);
|
||||||
|
if (flags.includes("analysis_incomplete")) return "incomplete";
|
||||||
|
if ((r.status ?? "") === "") return "error";
|
||||||
|
}
|
||||||
|
return "success";
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import type {
|
|||||||
} from "../message-capture/types.js";
|
} from "../message-capture/types.js";
|
||||||
import { getConversationKey, workerPool } from "./circuitBreaker.js";
|
import { getConversationKey, workerPool } from "./circuitBreaker.js";
|
||||||
import { fireAlert } from "./conversationState.js";
|
import { fireAlert } from "./conversationState.js";
|
||||||
|
import { classifyIndividualWorkerResult } from "./fallbackResultClassifier.js";
|
||||||
import {
|
import {
|
||||||
broadcastAnalysisCompleted,
|
broadcastAnalysisCompleted,
|
||||||
LAST_ERROR,
|
LAST_ERROR,
|
||||||
@@ -78,26 +79,82 @@ async function processIndividualFallback(
|
|||||||
message,
|
message,
|
||||||
skipNormalAnalysis: false,
|
skipNormalAnalysis: false,
|
||||||
} as unknown)) as
|
} as unknown)) as
|
||||||
| { ok: true; results: AnalysisResult[] }
|
| { ok: true; results: AnalysisResult[]; uploadPending?: boolean }
|
||||||
| { ok: false; results: AnalysisResult[]; error: string };
|
| { ok: false; results: AnalysisResult[]; error: string };
|
||||||
|
|
||||||
|
// Explicit outcome classification (2026-08-24): the old code treated any
|
||||||
|
// ok:true as a completed moderation, so the upload-pending race guard's
|
||||||
|
// empty results left messages stuck in `processing` until the 300s
|
||||||
|
// cleanup reverted them — the root cause of the ~330s attachment delays.
|
||||||
|
const kind = classifyIndividualWorkerResult(workerResult);
|
||||||
|
|
||||||
|
if (kind === "upload_pending") {
|
||||||
|
// Attachment still uploading — put the row back to `pending` and
|
||||||
|
// re-schedule this conversation immediately. The next scheduler cycle
|
||||||
|
// (~debounce 250ms) re-fetches; once upload_status flips to done the
|
||||||
|
// race guard passes and analysis proceeds. NOT an error: never touches
|
||||||
|
// the circuit breaker counters.
|
||||||
|
const revertedRows = await messageStore
|
||||||
|
.updateMessagesAIAnalysisBulk([
|
||||||
|
{
|
||||||
|
messageId,
|
||||||
|
result: {
|
||||||
|
status: "pending",
|
||||||
|
flags: null,
|
||||||
|
score: null,
|
||||||
|
analysis: null,
|
||||||
|
categories: null,
|
||||||
|
severity: null,
|
||||||
|
confidence: null,
|
||||||
|
recommendedAction: null,
|
||||||
|
analyzedAt: null,
|
||||||
|
error: null,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
.catch((dbErr: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ messageId, error: String(dbErr) },
|
||||||
|
"Failed to revert upload-pending message to pending",
|
||||||
|
);
|
||||||
|
return [] as MessageRecord[];
|
||||||
|
});
|
||||||
|
for (const row of revertedRows) {
|
||||||
|
broadcastAnalysisCompleted(row);
|
||||||
|
}
|
||||||
|
logger.debug(
|
||||||
|
{ messageId, conversationKey },
|
||||||
|
"Individual fallback: attachment upload in-flight — requeued as pending + rescheduled",
|
||||||
|
);
|
||||||
|
setImmediate(() => {
|
||||||
|
import("./batchScheduler.js")
|
||||||
|
.then((m) => m.scheduleConversationAnalysis(conversationKey))
|
||||||
|
.catch(() => {});
|
||||||
|
});
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
let analysisResult: { results: AnalysisResult[] } | null = null;
|
let analysisResult: { results: AnalysisResult[] } | null = null;
|
||||||
|
|
||||||
if (workerResult.ok) {
|
if (kind === "success") {
|
||||||
const stillIncomplete = workerResult.results.some((r) =>
|
analysisResult = workerResult;
|
||||||
r.flags.includes("analysis_incomplete"),
|
} else if (kind === "incomplete") {
|
||||||
);
|
|
||||||
if (stillIncomplete) {
|
|
||||||
exhaustedOnIncomplete = true;
|
exhaustedOnIncomplete = true;
|
||||||
analysisResult = null;
|
analysisResult = null;
|
||||||
} else {
|
} else {
|
||||||
analysisResult = workerResult;
|
// "error" — includes ok:true with unexplainable empty results (the old
|
||||||
}
|
// silent-success bug). Throw so it is treated as a transient failure.
|
||||||
|
throw new Error(
|
||||||
|
(workerResult as { error?: string }).error ??
|
||||||
|
"Individual worker returned no explainable results",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// No heuristic fallback: an incomplete/errored LLM result stays a
|
// No heuristic fallback: an incomplete/errored LLM result stays a
|
||||||
// retryable error — the recovery worker picks it up later. Producing a
|
// retryable error — the recovery worker picks it up later. Producing a
|
||||||
// regex/wordlist verdict here would reintroduce false positives.
|
// regex/wordlist verdict here would reintroduce false positives.
|
||||||
|
// (incomplete keeps its exhausted flag so the catch writes the terminal
|
||||||
|
// individual_analysis_exhausted status.)
|
||||||
if (!analysisResult) {
|
if (!analysisResult) {
|
||||||
throw new Error(`LLM analysis failed for message ${messageId}`);
|
throw new Error(`LLM analysis failed for message ${messageId}`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export async function callModerationLLM(
|
|||||||
jsonResponse: { type: "json_object" },
|
jsonResponse: { type: "json_object" },
|
||||||
retries: 0,
|
retries: 0,
|
||||||
signal,
|
signal,
|
||||||
// Router (9router/omniroute) always streams SSE even when the
|
// Router (9router / formerly omniroute) always streams SSE even when the
|
||||||
// request omits `stream`. In non-stream mode the OpenAI SDK waits
|
// request omits `stream`. In non-stream mode the OpenAI SDK waits
|
||||||
// for the FULL body before parsing, so slow/long upstream streams
|
// for the FULL body before parsing, so slow/long upstream streams
|
||||||
// hit the 30s/60s timeout and abort mid-generation. Streaming mode
|
// hit the 30s/60s timeout and abort mid-generation. Streaming mode
|
||||||
|
|||||||
@@ -356,10 +356,10 @@ export async function llmVision(
|
|||||||
promptText: string,
|
promptText: string,
|
||||||
imageUrl: { url: string },
|
imageUrl: { url: string },
|
||||||
): Promise<string | null> {
|
): Promise<string | null> {
|
||||||
const completion = await llmChat({
|
const params = {
|
||||||
messages: [
|
messages: [
|
||||||
{
|
{
|
||||||
role: "user",
|
role: "user" as const,
|
||||||
content: [
|
content: [
|
||||||
{ type: "text" as const, text: promptText },
|
{ type: "text" as const, text: promptText },
|
||||||
{ type: "image_url" as const, image_url: imageUrl },
|
{ type: "image_url" as const, image_url: imageUrl },
|
||||||
@@ -371,9 +371,26 @@ export async function llmVision(
|
|||||||
temperature: 0.1,
|
temperature: 0.1,
|
||||||
top_p: 0.9,
|
top_p: 0.9,
|
||||||
retries: 0,
|
retries: 0,
|
||||||
stream: true, // router always streams SSE; non-stream waits for full body and times out
|
|
||||||
timeout: config.AI_LLM_VISION_ANALYSIS_TIMEOUT_MS ?? 60_000,
|
timeout: config.AI_LLM_VISION_ANALYSIS_TIMEOUT_MS ?? 60_000,
|
||||||
});
|
};
|
||||||
|
|
||||||
|
// Streaming first (the router always streams SSE; a non-stream request
|
||||||
|
// waits for the full body and times out on slow models). Fallback (2026-08-24):
|
||||||
|
// large GIFs/images sometimes get their SSE stream truncated mid-flight by
|
||||||
|
// the upstream ("Stream ended before producing a non-ping SSE event") — all
|
||||||
|
// streaming retries fail identically, so retry ONCE with stream:false where
|
||||||
|
// the router assembles the complete response server-side.
|
||||||
|
let completion: Awaited<ReturnType<typeof llmChat>>;
|
||||||
|
try {
|
||||||
|
completion = await llmChat({ ...params, stream: true });
|
||||||
|
} catch (err) {
|
||||||
|
const msg = err instanceof Error ? err.message : String(err);
|
||||||
|
if (/stream ended before producing a non-ping sse/i.test(msg)) {
|
||||||
|
completion = await llmChat({ ...params, stream: false });
|
||||||
|
} else {
|
||||||
|
throw err;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!completion) return null;
|
if (!completion) return null;
|
||||||
return completion.choices[0]?.message?.content?.trim() ?? null;
|
return completion.choices[0]?.message?.content?.trim() ?? null;
|
||||||
|
|||||||
@@ -4,6 +4,8 @@
|
|||||||
* Orchestrates LLM-based moderation analysis — manages batch splitting,
|
* Orchestrates LLM-based moderation analysis — manages batch splitting,
|
||||||
* parallel text+media analysis, LLM calls with retry, and cache handling.
|
* parallel text+media analysis, LLM calls with retry, and cache handling.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
|
import { extractMessageMediaEvidence } from "../message-capture/messageMetadata.js";
|
||||||
@@ -20,16 +22,29 @@ import { isQdrantConfigured, searchQdrantBatch } from "./qdrantClient.js";
|
|||||||
import { logCacheEvent } from "./responseLogger.js";
|
import { logCacheEvent } from "./responseLogger.js";
|
||||||
import { runTextOnlyBatch } from "./textBatchProcessor.js";
|
import { runTextOnlyBatch } from "./textBatchProcessor.js";
|
||||||
import {
|
import {
|
||||||
|
bumpTextModerationHitCounts,
|
||||||
|
ERROR_ARTIFACT_FLAGS,
|
||||||
findSimilarTextModeration,
|
findSimilarTextModeration,
|
||||||
getCachedTextModeration,
|
getCachedTextModerations,
|
||||||
|
isGloballyReusableCleanVerdict,
|
||||||
|
isSemanticBandAccepted,
|
||||||
makeModerationContextKey,
|
makeModerationContextKey,
|
||||||
makeTextModerationCacheKey,
|
makeTextModerationCacheKey,
|
||||||
parseQdrantVerdict,
|
parseQdrantVerdict,
|
||||||
|
type StoredModerationVerdict,
|
||||||
setCachedTextModeration,
|
setCachedTextModeration,
|
||||||
|
upsertBareKeyToQdrant,
|
||||||
} from "./textCacheStore.js";
|
} from "./textCacheStore.js";
|
||||||
|
|
||||||
const log = createChildLogger("moderationOrchestrator");
|
const log = createChildLogger("moderationOrchestrator");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bare keys already written this process (dual-key write-back dedupe).
|
||||||
|
* LRU-bounded so a long-lived gateway can't grow it without limit; the DB
|
||||||
|
* upsert underneath is idempotent anyway — this just avoids redundant writes.
|
||||||
|
*/
|
||||||
|
const globalBareKeysWritten = new LRUCache<string, true>({ max: 5000 });
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Types
|
// Types
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -73,46 +88,74 @@ export async function runModerationAnalysis(
|
|||||||
initCacheStore(config.REDIS_URL);
|
initCacheStore(config.REDIS_URL);
|
||||||
if (!targets.length) throw new Error("No targets provided for analysis");
|
if (!targets.length) throw new Error("No targets provided for analysis");
|
||||||
|
|
||||||
// ── Phase 1: exact-hash cache (per conversation context) ────────────────
|
// ── Phase 1: exact-hash cache — ONE batched DB query ────────────────────
|
||||||
|
// Key is content + conversation context (channel/thread). On a scoped miss
|
||||||
|
// we also probe the legacy bare key: verdicts that CANNOT trigger an action
|
||||||
|
// (clean, flagless, action=none) may be reused across channels under strict
|
||||||
|
// freshness + confidence guards — flagged/warn verdicts never leave their
|
||||||
|
// conversation. This replaced the old N-sequential-query loop (60-message
|
||||||
|
// burst = 60 PgBouncer round-trips before).
|
||||||
const cacheHits: AnalysisResult[] = [];
|
const cacheHits: AnalysisResult[] = [];
|
||||||
const uncachedTargets: MessageRecord[] = [];
|
const uncachedTargets: MessageRecord[] = [];
|
||||||
// cacheKey → result for identical-content dedupe within one batch
|
// cacheKey → representative result for identical-content dedupe
|
||||||
const hitByKey = new Map<string, AnalysisResult>();
|
const hitByKey = new Map<string, AnalysisResult>();
|
||||||
// Embedding per exact cache key — computed once during lookup, reused
|
// Embedding per exact cache key — computed once during lookup, reused
|
||||||
// when the fresh LLM verdict is written back to the semantic cache.
|
// when the fresh LLM verdict is written back to the semantic cache.
|
||||||
const embeddingsByKey = new Map<string, number[]>();
|
const embeddingsByKey = new Map<string, number[]>();
|
||||||
|
|
||||||
|
interface ExactCandidate {
|
||||||
|
target: MessageRecord;
|
||||||
|
scopedKey: string;
|
||||||
|
bareKey: string;
|
||||||
|
}
|
||||||
|
const candidates: ExactCandidate[] = [];
|
||||||
for (const target of targets) {
|
for (const target of targets) {
|
||||||
const hasMedia = hasMediaContent(target, attachments);
|
if (hasMediaContent(target, attachments)) {
|
||||||
if (hasMedia) {
|
|
||||||
uncachedTargets.push(target);
|
uncachedTargets.push(target);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rawContent = target.edited_content ?? target.content;
|
const rawContent = target.edited_content ?? target.content;
|
||||||
if (!rawContent.trim()) {
|
if (!rawContent.trim()) {
|
||||||
uncachedTargets.push(target);
|
uncachedTargets.push(target);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
candidates.push({
|
||||||
const cacheKey = makeTextModerationCacheKey(
|
target,
|
||||||
|
scopedKey: makeTextModerationCacheKey(
|
||||||
rawContent,
|
rawContent,
|
||||||
makeModerationContextKey(target),
|
makeModerationContextKey(target),
|
||||||
);
|
),
|
||||||
const seen = hitByKey.get(cacheKey);
|
bareKey: makeTextModerationCacheKey(rawContent),
|
||||||
if (seen) {
|
});
|
||||||
// Same content already resolved this batch — reuse the verdict.
|
|
||||||
cacheHits.push({ ...seen, messageId: target.id });
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
// Identical content within one batch resolves once (representative).
|
||||||
const cached = await getCachedTextModeration(cacheKey);
|
const firstByScopedKey = new Map<string, ExactCandidate>();
|
||||||
if (cached) {
|
for (const c of candidates) {
|
||||||
|
if (!firstByScopedKey.has(c.scopedKey))
|
||||||
|
firstByScopedKey.set(c.scopedKey, c);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Single round-trip for every key we might serve from (scoped + bare).
|
||||||
|
const storedEntries = await getCachedTextModerations([
|
||||||
|
...firstByScopedKey.keys(),
|
||||||
|
...Array.from(firstByScopedKey.values(), (c) => c.bareKey),
|
||||||
|
]);
|
||||||
|
// Keys actually served — bumped in one UPDATE at the end for metrics.
|
||||||
|
const servedCacheKeys = new Set<string>();
|
||||||
|
|
||||||
|
/** Validate + admit one stored verdict for a candidate. */
|
||||||
|
const acceptExactVerdict = (
|
||||||
|
candidate: ExactCandidate,
|
||||||
|
cacheKey: string,
|
||||||
|
entry: { verdict: StoredModerationVerdict },
|
||||||
|
policyVersion: string,
|
||||||
|
): boolean => {
|
||||||
|
const { verdict } = entry;
|
||||||
const hasMediaInMeta =
|
const hasMediaInMeta =
|
||||||
target.metadata &&
|
candidate.target.metadata &&
|
||||||
(() => {
|
(() => {
|
||||||
const ev = extractMessageMediaEvidence(target.metadata);
|
const ev = extractMessageMediaEvidence(candidate.target.metadata);
|
||||||
return (
|
return (
|
||||||
ev.attachments.length > 0 ||
|
ev.attachments.length > 0 ||
|
||||||
ev.stickers.length > 0 ||
|
ev.stickers.length > 0 ||
|
||||||
@@ -122,48 +165,87 @@ export async function runModerationAnalysis(
|
|||||||
|
|
||||||
if (hasMediaInMeta) {
|
if (hasMediaInMeta) {
|
||||||
log.debug(
|
log.debug(
|
||||||
{ messageId: target.id, cacheKey },
|
{ messageId: candidate.target.id, cacheKey },
|
||||||
"Cache entry but message has media — treating as miss",
|
"Cache entry but message has media — treating as miss",
|
||||||
);
|
);
|
||||||
} else if (
|
return false;
|
||||||
cached.flags.some((f) =>
|
}
|
||||||
[
|
if (
|
||||||
"analysis_api_failed",
|
verdict.flags.some((f) =>
|
||||||
"analysis_parse_failed",
|
(ERROR_ARTIFACT_FLAGS as readonly string[]).includes(f),
|
||||||
"analysis_incomplete",
|
|
||||||
].includes(f),
|
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
log.warn(
|
log.warn(
|
||||||
{ messageId: target.id, cacheKey },
|
{ messageId: candidate.target.id, cacheKey },
|
||||||
"Cache entry contains error artifact — treating as miss",
|
"Cache entry contains error artifact — treating as miss",
|
||||||
);
|
);
|
||||||
} else {
|
return false;
|
||||||
const hit: AnalysisResult = {
|
|
||||||
messageId: target.id,
|
|
||||||
status: cached.status,
|
|
||||||
flags: cached.flags,
|
|
||||||
score: cached.score,
|
|
||||||
analysis: cached.analysis,
|
|
||||||
categories: cached.categories,
|
|
||||||
severity: cached.severity as AnalysisResult["severity"],
|
|
||||||
confidence: cached.confidence,
|
|
||||||
recommendedAction:
|
|
||||||
cached.recommendedAction as AnalysisResult["recommendedAction"],
|
|
||||||
policyVersion: "cached-user-moderation-2026-06",
|
|
||||||
evidence: [],
|
|
||||||
};
|
|
||||||
cacheHits.push(hit);
|
|
||||||
hitByKey.set(cacheKey, hit);
|
|
||||||
logCacheEvent("hit", cacheKey, "text");
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* proceed */
|
|
||||||
}
|
}
|
||||||
|
|
||||||
uncachedTargets.push(target);
|
hitByKey.set(candidate.scopedKey, {
|
||||||
|
messageId: candidate.target.id,
|
||||||
|
status: verdict.status,
|
||||||
|
flags: verdict.flags,
|
||||||
|
score: verdict.score,
|
||||||
|
analysis: verdict.analysis,
|
||||||
|
categories: verdict.categories,
|
||||||
|
severity: verdict.severity as AnalysisResult["severity"],
|
||||||
|
confidence: verdict.confidence,
|
||||||
|
recommendedAction:
|
||||||
|
verdict.recommendedAction as AnalysisResult["recommendedAction"],
|
||||||
|
policyVersion,
|
||||||
|
evidence: [],
|
||||||
|
});
|
||||||
|
servedCacheKeys.add(cacheKey);
|
||||||
|
logCacheEvent("hit", cacheKey, "text");
|
||||||
|
return true;
|
||||||
|
};
|
||||||
|
|
||||||
|
for (const candidate of firstByScopedKey.values()) {
|
||||||
|
const scopedEntry = storedEntries.get(candidate.scopedKey);
|
||||||
|
if (
|
||||||
|
scopedEntry &&
|
||||||
|
acceptExactVerdict(
|
||||||
|
candidate,
|
||||||
|
candidate.scopedKey,
|
||||||
|
scopedEntry,
|
||||||
|
"cached-user-moderation-2026-06",
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Context-free fallback: ONLY non-actionable clean verdicts qualify
|
||||||
|
// (guard enforces status/flags/action/confidence/freshness). The bare
|
||||||
|
// key equals the scoped key for context-less messages, so the guard
|
||||||
|
// also prevents double-serving the same row.
|
||||||
|
const bareEntry = storedEntries.get(candidate.bareKey);
|
||||||
|
if (
|
||||||
|
bareEntry &&
|
||||||
|
candidate.bareKey !== candidate.scopedKey &&
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
bareEntry.verdict,
|
||||||
|
bareEntry.analyzedAt ?? undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
acceptExactVerdict(
|
||||||
|
candidate,
|
||||||
|
candidate.bareKey,
|
||||||
|
bareEntry,
|
||||||
|
"cached-global-clean-2026-08",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fan-out: every candidate (representative + in-batch duplicates) gets its
|
||||||
|
// own copy of the representative verdict; unresolved ones stay queued.
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const representative = hitByKey.get(candidate.scopedKey);
|
||||||
|
if (representative) {
|
||||||
|
cacheHits.push({ ...representative, messageId: candidate.target.id });
|
||||||
|
} else {
|
||||||
|
uncachedTargets.push(candidate.target);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── Phase 2: semantic cache — batched (one embed call + one Qdrant
|
// ── Phase 2: semantic cache — batched (one embed call + one Qdrant
|
||||||
@@ -199,10 +281,12 @@ export async function runModerationAnalysis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (isQdrantConfigured()) {
|
if (isQdrantConfigured()) {
|
||||||
|
// ONE batch search at the LOOSER threshold; per-hit re-classification
|
||||||
|
// enforces the strict band for actionable verdicts.
|
||||||
const batchHits = await searchQdrantBatch(
|
const batchHits = await searchQdrantBatch(
|
||||||
embeddings,
|
embeddings,
|
||||||
config.AI_LLM_EMBEDDING_MAX_CANDIDATES,
|
config.AI_LLM_EMBEDDING_MAX_CANDIDATES,
|
||||||
config.AI_LLM_EMBEDDING_MIN_SIMILARITY,
|
config.AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN,
|
||||||
);
|
);
|
||||||
for (let i = 0; i < semanticCandidates.length; i++) {
|
for (let i = 0; i < semanticCandidates.length; i++) {
|
||||||
const { target, cacheKey } = semanticCandidates[i];
|
const { target, cacheKey } = semanticCandidates[i];
|
||||||
@@ -210,6 +294,7 @@ export async function runModerationAnalysis(
|
|||||||
if (hits.length === 0) continue;
|
if (hits.length === 0) continue;
|
||||||
const verdict = parseQdrantVerdict(hits[0].payload, hits[0].score);
|
const verdict = parseQdrantVerdict(hits[0].payload, hits[0].score);
|
||||||
if (!verdict) continue;
|
if (!verdict) continue;
|
||||||
|
if (!isSemanticBandAccepted(verdict, verdict.similarity)) continue;
|
||||||
log.debug(
|
log.debug(
|
||||||
{
|
{
|
||||||
messageId: target.id,
|
messageId: target.id,
|
||||||
@@ -234,6 +319,7 @@ export async function runModerationAnalysis(
|
|||||||
};
|
};
|
||||||
cacheHits.push(hit);
|
cacheHits.push(hit);
|
||||||
hitByKey.set(cacheKey, hit);
|
hitByKey.set(cacheKey, hit);
|
||||||
|
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
|
||||||
logCacheEvent("hit", cacheKey, "text");
|
logCacheEvent("hit", cacheKey, "text");
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@@ -242,10 +328,12 @@ export async function runModerationAnalysis(
|
|||||||
const { target, cacheKey } = semanticCandidates[i];
|
const { target, cacheKey } = semanticCandidates[i];
|
||||||
const semantic = await findSimilarTextModeration(
|
const semantic = await findSimilarTextModeration(
|
||||||
embeddings[i],
|
embeddings[i],
|
||||||
config.AI_LLM_EMBEDDING_MIN_SIMILARITY,
|
config.AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN,
|
||||||
config.AI_LLM_EMBEDDING_MAX_CANDIDATES,
|
config.AI_LLM_EMBEDDING_MAX_CANDIDATES,
|
||||||
);
|
);
|
||||||
if (!semantic) continue;
|
if (!semantic) continue;
|
||||||
|
if (!isSemanticBandAccepted(semantic, semantic.similarity))
|
||||||
|
continue;
|
||||||
log.debug(
|
log.debug(
|
||||||
{
|
{
|
||||||
messageId: target.id,
|
messageId: target.id,
|
||||||
@@ -270,6 +358,7 @@ export async function runModerationAnalysis(
|
|||||||
};
|
};
|
||||||
cacheHits.push(hit);
|
cacheHits.push(hit);
|
||||||
hitByKey.set(cacheKey, hit);
|
hitByKey.set(cacheKey, hit);
|
||||||
|
servedCacheKeys.add(cacheKey); // bump hit_count for metrics
|
||||||
logCacheEvent("hit", cacheKey, "text");
|
logCacheEvent("hit", cacheKey, "text");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -290,11 +379,14 @@ export async function runModerationAnalysis(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (cacheHits.length > 0) {
|
if (cacheHits.length > 0) {
|
||||||
|
// Metrics: one bulk UPDATE for every exact-cache key actually served.
|
||||||
|
bumpTextModerationHitCounts(Array.from(servedCacheKeys));
|
||||||
log.info(
|
log.info(
|
||||||
{
|
{
|
||||||
cacheHits: cacheHits.length,
|
cacheHits: cacheHits.length,
|
||||||
uncached: uncachedTargets.length,
|
uncached: uncachedTargets.length,
|
||||||
total: targets.length,
|
total: targets.length,
|
||||||
|
servedKeys: servedCacheKeys.size,
|
||||||
},
|
},
|
||||||
"User moderation cache applied",
|
"User moderation cache applied",
|
||||||
);
|
);
|
||||||
@@ -355,9 +447,7 @@ export async function runModerationAnalysis(
|
|||||||
rawContent,
|
rawContent,
|
||||||
makeModerationContextKey(target),
|
makeModerationContextKey(target),
|
||||||
);
|
);
|
||||||
setCachedTextModeration(
|
const stored = {
|
||||||
cacheKey,
|
|
||||||
{
|
|
||||||
flags: result.flags ?? [],
|
flags: result.flags ?? [],
|
||||||
score: result.score ?? 0,
|
score: result.score ?? 0,
|
||||||
analysis: result.analysis ?? "",
|
analysis: result.analysis ?? "",
|
||||||
@@ -366,9 +456,50 @@ export async function runModerationAnalysis(
|
|||||||
confidence: result.confidence ?? result.score ?? 0,
|
confidence: result.confidence ?? result.score ?? 0,
|
||||||
recommendedAction: result.recommendedAction ?? "none",
|
recommendedAction: result.recommendedAction ?? "none",
|
||||||
status: result.status,
|
status: result.status,
|
||||||
},
|
};
|
||||||
|
setCachedTextModeration(
|
||||||
|
cacheKey,
|
||||||
|
stored,
|
||||||
embeddingsByKey.get(cacheKey),
|
embeddingsByKey.get(cacheKey),
|
||||||
).catch(() => {});
|
).catch(() => {});
|
||||||
|
|
||||||
|
// Dual-key write-back (2026-08-24): the FIRST analysis of a message runs
|
||||||
|
// WITH conversation context (accurate), but its verdict is also stored
|
||||||
|
// under the context-free bare key so repeats in OTHER channels hit the
|
||||||
|
// exact cache instead of paying a new LLM call. Same guard as the read
|
||||||
|
// path — only non-actionable clean verdicts may cross channels.
|
||||||
|
//
|
||||||
|
// 2026-08-25 cache-hit fix: the bare key is ALSO upserted to Qdrant
|
||||||
|
// (via upsertBareKeyToQdrant) with the SAME embedding already computed
|
||||||
|
// at lookup time. Previously the bare key was only PG-written with
|
||||||
|
// embedding=null — bare clean verdicts were DB-only and invisible to
|
||||||
|
// searchQdrantBatch, capping the semantic hit-rate below the exact-cache
|
||||||
|
// hit-rate for cross-channel repeats.
|
||||||
|
const bareKey = makeTextModerationCacheKey(rawContent);
|
||||||
|
if (
|
||||||
|
bareKey !== cacheKey &&
|
||||||
|
!globalBareKeysWritten.has(bareKey) &&
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
{
|
||||||
|
status: stored.status,
|
||||||
|
flags: stored.flags,
|
||||||
|
score: stored.score,
|
||||||
|
analysis: stored.analysis,
|
||||||
|
categories: stored.categories,
|
||||||
|
severity: stored.severity,
|
||||||
|
confidence: stored.confidence,
|
||||||
|
recommendedAction: stored.recommendedAction,
|
||||||
|
},
|
||||||
|
undefined,
|
||||||
|
)
|
||||||
|
) {
|
||||||
|
globalBareKeysWritten.set(bareKey, true);
|
||||||
|
setCachedTextModeration(bareKey, stored, null).catch(() => {});
|
||||||
|
const bareEmbedding = embeddingsByKey.get(cacheKey);
|
||||||
|
if (bareEmbedding && bareEmbedding.length > 0) {
|
||||||
|
upsertBareKeyToQdrant(bareKey, stored, bareEmbedding).catch(() => {});
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const allResults = [
|
const allResults = [
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { createHash } from "node:crypto";
|
import { createHash } from "node:crypto";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
|
import { config } from "../../shared/config/config.js";
|
||||||
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
import { executeAll, executeGet } from "../../shared/database/drizzle.js";
|
||||||
import { findBestEmbeddingMatch } from "./embeddingClient.js";
|
import { findBestEmbeddingMatch } from "./embeddingClient.js";
|
||||||
import {
|
import {
|
||||||
@@ -84,10 +85,43 @@ export function makeImageCacheKey(imageUrl: string): string {
|
|||||||
// size is 8191"), which fails acquireMediaAnalysisLock and silently skips
|
// size is 8191"), which fails acquireMediaAnalysisLock and silently skips
|
||||||
// every media analysis. A 32-char sha256 keeps the key well under the limit
|
// every media analysis. A 32-char sha256 keeps the key well under the limit
|
||||||
// and is still deterministic (same attachment → same key).
|
// and is still deterministic (same attachment → same key).
|
||||||
const hash = createHash("sha256").update(imageUrl).digest("hex").slice(0, 32);
|
const hash = createHash("sha256")
|
||||||
|
.update(normalizeDiscordImageUrl(imageUrl))
|
||||||
|
.digest("hex")
|
||||||
|
.slice(0, 32);
|
||||||
return `image:${hash}`;
|
return `image:${hash}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Strip volatile query params from Discord CDN URLs so the SAME attachment
|
||||||
|
* always maps to ONE vision-cache key regardless of how it reached us
|
||||||
|
* (signed `?ex=&is=&hm=` tokens rotate per fetch; render variants differ by
|
||||||
|
* `format/width/height/size`). Previously each token variant hashed to its
|
||||||
|
* own key → the same image was re-downloaded and re-analyzed by the vision
|
||||||
|
* model once per variant. Non-Discord URLs and data: URLs are returned
|
||||||
|
* untouched (their query can be semantically meaningful).
|
||||||
|
*/
|
||||||
|
export function normalizeDiscordImageUrl(imageUrl: string): string {
|
||||||
|
try {
|
||||||
|
const parsed = new URL(imageUrl);
|
||||||
|
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
const host = parsed.hostname;
|
||||||
|
const isAttachmentCdn = host === "cdn.discordapp.com";
|
||||||
|
const isRenderOrPreview =
|
||||||
|
host === "media.discordapp.net" ||
|
||||||
|
/^images-ext-\d+\.discordapp\.net$/.test(host);
|
||||||
|
if (!isAttachmentCdn && !isRenderOrPreview) return imageUrl;
|
||||||
|
if (!parsed.search) return imageUrl;
|
||||||
|
// Path IS the stable identity of the attachment; everything after "?" is
|
||||||
|
// signing or a render variant.
|
||||||
|
return `${parsed.origin}${parsed.pathname}`;
|
||||||
|
} catch {
|
||||||
|
return imageUrl;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup a cached media analysis result.
|
* Lookup a cached media analysis result.
|
||||||
* Returns the full cached text (the analysis summary string) or null if not found or expired.
|
* Returns the full cached text (the analysis summary string) or null if not found or expired.
|
||||||
@@ -294,10 +328,71 @@ export interface StoredModerationVerdict {
|
|||||||
recommendedAction: string;
|
recommendedAction: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Raw DB row shape needed to rebuild a StoredModerationVerdict. */
|
||||||
|
interface VerdictRow {
|
||||||
|
flags: string;
|
||||||
|
analyzed_at?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse one `text_analysis_cache` row into a StoredModerationVerdict.
|
||||||
|
* Shared by the single-key and batched getters so their semantics can never
|
||||||
|
* drift apart (status normalization lives in exactly one place).
|
||||||
|
*/
|
||||||
|
export function parseStoredVerdictRow(
|
||||||
|
row: VerdictRow,
|
||||||
|
): StoredModerationVerdict | null {
|
||||||
|
let parsed: Record<string, unknown>;
|
||||||
|
try {
|
||||||
|
parsed = JSON.parse(row.flags) as Record<string, unknown>;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
if (!parsed || typeof parsed !== "object") return null;
|
||||||
|
|
||||||
|
const flags = Array.isArray(parsed.flags) ? (parsed.flags as string[]) : [];
|
||||||
|
const status = normalizeStoredStatus(
|
||||||
|
parsed.status as string | undefined,
|
||||||
|
flags,
|
||||||
|
);
|
||||||
|
return {
|
||||||
|
status,
|
||||||
|
flags,
|
||||||
|
score: (parsed.score as number) ?? 0,
|
||||||
|
analysis: (parsed.analysis as string) ?? "",
|
||||||
|
categories: (parsed.categories as string[]) ?? [],
|
||||||
|
severity: (parsed.severity as string) ?? "none",
|
||||||
|
confidence: (parsed.confidence as number) ?? 0,
|
||||||
|
recommendedAction: (parsed.recommendedAction as string) ?? "none",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Increment the hit counter for a cache key (fire-and-forget).
|
||||||
|
*
|
||||||
|
* Bug history: `hit_count` was written as 0 on insert and never updated by
|
||||||
|
* any reader, so cache effectiveness was unmeasurable. This is best-effort
|
||||||
|
* observability — a failed bump must never affect the read path.
|
||||||
|
*/
|
||||||
|
function bumpHitCount(cacheKey: string): void {
|
||||||
|
executeAll(
|
||||||
|
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = $1`,
|
||||||
|
[cacheKey],
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Error-artifact flags that make a cached verdict unusable. */
|
||||||
|
export const ERROR_ARTIFACT_FLAGS = [
|
||||||
|
"analysis_api_failed",
|
||||||
|
"analysis_parse_failed",
|
||||||
|
"analysis_incomplete",
|
||||||
|
] as const;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Lookup a cached moderation result for a text content.
|
* Lookup a cached moderation result for a text content.
|
||||||
* Returns the stored result fields or null.
|
* Returns the stored result fields or null.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export async function getCachedTextModeration(
|
export async function getCachedTextModeration(
|
||||||
cacheKey: string,
|
cacheKey: string,
|
||||||
): Promise<StoredModerationVerdict | null> {
|
): Promise<StoredModerationVerdict | null> {
|
||||||
@@ -311,25 +406,11 @@ export async function getCachedTextModeration(
|
|||||||
|
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
|
|
||||||
const parsed = JSON.parse(row.flags) as Record<string, unknown>;
|
const verdict = parseStoredVerdictRow(row);
|
||||||
const flags = (parsed.flags as string[]) ?? [];
|
if (!verdict) return null;
|
||||||
// Use stored status if available (new entries), otherwise derive from
|
|
||||||
// flags (legacy compatibility). "warn" must survive the round-trip.
|
|
||||||
const status = normalizeStoredStatus(
|
|
||||||
parsed.status as string | undefined,
|
|
||||||
flags,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
bumpHitCount(cacheKey);
|
||||||
status,
|
return verdict;
|
||||||
flags,
|
|
||||||
score: (parsed.score as number) ?? 0,
|
|
||||||
analysis: (parsed.analysis as string) ?? "",
|
|
||||||
categories: (parsed.categories as string[]) ?? [],
|
|
||||||
severity: (parsed.severity as string) ?? "none",
|
|
||||||
confidence: (parsed.confidence as number) ?? 0,
|
|
||||||
recommendedAction: (parsed.recommendedAction as string) ?? "none",
|
|
||||||
};
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
logger.error(
|
logger.error(
|
||||||
{ error: error instanceof Error ? error.message : String(error) },
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
@@ -339,6 +420,128 @@ export async function getCachedTextModeration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Batched exact-hash lookup: ONE query for N keys.
|
||||||
|
*
|
||||||
|
* Semantics are identical to calling `getCachedTextModeration` per key
|
||||||
|
* (unexpired rows only, shared row parser). Per-key hit-count bumps are NOT
|
||||||
|
* issued here — the orchestrator logs an aggregate "cache applied" line
|
||||||
|
* instead, keeping a 60-message burst at exactly one round-trip.
|
||||||
|
* `analyzedAt` is surfaced so callers can apply freshness guards.
|
||||||
|
*/
|
||||||
|
export interface BatchedVerdictEntry {
|
||||||
|
verdict: StoredModerationVerdict;
|
||||||
|
analyzedAt: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function getCachedTextModerations(
|
||||||
|
cacheKeys: string[],
|
||||||
|
): Promise<Map<string, BatchedVerdictEntry>> {
|
||||||
|
const results = new Map<string, BatchedVerdictEntry>();
|
||||||
|
const uniqueKeys = Array.from(new Set(cacheKeys)).filter(Boolean);
|
||||||
|
if (uniqueKeys.length === 0) return results;
|
||||||
|
|
||||||
|
const CHUNK_SIZE = 200;
|
||||||
|
try {
|
||||||
|
for (let i = 0; i < uniqueKeys.length; i += CHUNK_SIZE) {
|
||||||
|
const chunk = uniqueKeys.slice(i, i + CHUNK_SIZE);
|
||||||
|
// Postgres has a 32k bind-parameter ceiling; ANY($1) keeps it at one
|
||||||
|
// array param per chunk regardless of chunk length.
|
||||||
|
const rows = await executeAll(
|
||||||
|
`SELECT text, flags, analyzed_at
|
||||||
|
FROM text_analysis_cache
|
||||||
|
WHERE text = ANY($1::text[]) AND expires_at > $2`,
|
||||||
|
[chunk, Date.now()],
|
||||||
|
);
|
||||||
|
for (const row of rows ?? []) {
|
||||||
|
if (results.has(row.text)) continue;
|
||||||
|
const verdict = parseStoredVerdictRow(row);
|
||||||
|
if (!verdict) continue;
|
||||||
|
results.set(row.text, {
|
||||||
|
verdict,
|
||||||
|
analyzedAt:
|
||||||
|
typeof row.analyzed_at === "number" ? row.analyzed_at : null,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
logger.error(
|
||||||
|
{ error: error instanceof Error ? error.message : String(error) },
|
||||||
|
"Failed batched text moderation lookup",
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return results;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fire-and-forget bulk hit-count bump for keys actually served as hits.
|
||||||
|
* Companion to the batched getter (which skips per-row bumps): one UPDATE
|
||||||
|
* per analysis batch keeps hit-rate metrics working at zero extra latency
|
||||||
|
* cost per message.
|
||||||
|
*/
|
||||||
|
export function bumpTextModerationHitCounts(cacheKeys: string[]): void {
|
||||||
|
const uniqueKeys = Array.from(new Set(cacheKeys)).filter(Boolean);
|
||||||
|
if (uniqueKeys.length === 0) return;
|
||||||
|
executeAll(
|
||||||
|
`UPDATE text_analysis_cache SET hit_count = hit_count + 1 WHERE text = ANY($1::text[])`,
|
||||||
|
[uniqueKeys],
|
||||||
|
).catch(() => {});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Semantic two-band acceptance
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a semantic-cache hit may be reused given its verdict class.
|
||||||
|
* Two bands (2026-08-24): non-actionable verdicts (clean / flagless /
|
||||||
|
* action=none) are accepted from the LOOSER clean band; actionable verdicts
|
||||||
|
* (warn/flagged or any flags/action) keep the strict historical gate.
|
||||||
|
* Between the bands → reject → the message falls through to the LLM
|
||||||
|
* (fail-open toward accuracy).
|
||||||
|
*/
|
||||||
|
export function isSemanticBandAccepted(
|
||||||
|
verdict: StoredModerationVerdict,
|
||||||
|
similarity: number,
|
||||||
|
): boolean {
|
||||||
|
const isNonActionable =
|
||||||
|
verdict.status === "clean" &&
|
||||||
|
verdict.flags.length === 0 &&
|
||||||
|
(verdict.recommendedAction ?? "none") === "none";
|
||||||
|
return isNonActionable
|
||||||
|
? similarity >= config.AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN
|
||||||
|
: similarity >= config.AI_LLM_EMBEDDING_MIN_SIMILARITY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Global exact-cache reuse guard (context-free fallback)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/**
|
||||||
|
* True when a stored verdict is safe to reuse OUTSIDE its original channel:
|
||||||
|
* only verdicts that cannot trigger an action and carry no flags qualify,
|
||||||
|
* and they must be confident + fresh. Flagged/warn verdicts are NEVER
|
||||||
|
* globally reused — enforcement is context-sensitive by design.
|
||||||
|
*/
|
||||||
|
export function isGloballyReusableCleanVerdict(
|
||||||
|
verdict: Omit<StoredModerationVerdict, "status"> & { status: string },
|
||||||
|
analyzedAtMs: number | undefined,
|
||||||
|
): boolean {
|
||||||
|
if (verdict.status !== "clean") return false;
|
||||||
|
if (verdict.flags.length > 0) return false;
|
||||||
|
if ((verdict.recommendedAction ?? "none") !== "none") return false;
|
||||||
|
if (!(verdict.confidence >= config.AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE))
|
||||||
|
return false;
|
||||||
|
if (
|
||||||
|
typeof analyzedAtMs === "number" &&
|
||||||
|
Date.now() - analyzedAtMs >
|
||||||
|
config.AI_CACHE_GLOBAL_REUSE_MAX_AGE_H * 60 * 60 * 1000
|
||||||
|
) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Parse a Qdrant verdict payload into the result shape shared by the
|
* Parse a Qdrant verdict payload into the result shape shared by the
|
||||||
* semantic cache lookups. Returns null on malformed payloads (callers then
|
* semantic cache lookups. Returns null on malformed payloads (callers then
|
||||||
@@ -353,31 +556,13 @@ export function parseQdrantVerdict(
|
|||||||
similarity: number;
|
similarity: number;
|
||||||
})
|
})
|
||||||
| null {
|
| null {
|
||||||
let parsed: Record<string, unknown>;
|
const parsed = parseStoredVerdictRow({ flags: payload.flags });
|
||||||
try {
|
if (!parsed) return null;
|
||||||
parsed = JSON.parse(payload.flags) as Record<string, unknown>;
|
|
||||||
} catch {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
if (!parsed || typeof parsed !== "object") return null;
|
|
||||||
|
|
||||||
const flags = (parsed.flags as string[]) ?? [];
|
|
||||||
const status = normalizeStoredStatus(
|
|
||||||
parsed.status as string | undefined,
|
|
||||||
flags,
|
|
||||||
);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
...parsed,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
similarity,
|
similarity,
|
||||||
status,
|
|
||||||
flags,
|
|
||||||
score: (parsed.score as number) ?? 0,
|
|
||||||
analysis: (parsed.analysis as string) ?? "",
|
|
||||||
categories: (parsed.categories as string[]) ?? [],
|
|
||||||
severity: (parsed.severity as string) ?? "none",
|
|
||||||
confidence: (parsed.confidence as number) ?? 0,
|
|
||||||
recommendedAction: (parsed.recommendedAction as string) ?? "none",
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -479,6 +664,55 @@ export async function findSimilarTextModeration(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Upsert a bare (context-free) clean verdict to the Qdrant vector store,
|
||||||
|
* making global-reuse clean verdicts discoverable by semantic search.
|
||||||
|
*
|
||||||
|
* Why: the main `setCachedTextModeration` writes bare-key rows to Postgres
|
||||||
|
* with embedding=null (deliberate — no duplicate PG embedding column), but
|
||||||
|
* a bare clean verdict that never reaches Qdrant is invisible to
|
||||||
|
* searchQdrantBatch. So two messages with identical clean content in
|
||||||
|
* DIFFERENT channels never match semantically — the semantic hit-rate is
|
||||||
|
* capped below the exact-cache hit-rate. This helper shares the embedding
|
||||||
|
* already computed at lookup time so the bare point is semantically
|
||||||
|
* findable.
|
||||||
|
*
|
||||||
|
* Guard: only non-actionable clean verdicts qualify (same guard as the
|
||||||
|
* read path and as the orchestrator's bare-key write-back). No-op when
|
||||||
|
* Qdrant is disabled or no embedding is available.
|
||||||
|
*/
|
||||||
|
export async function upsertBareKeyToQdrant(
|
||||||
|
bareKey: string,
|
||||||
|
result: {
|
||||||
|
status: string;
|
||||||
|
flags: string[];
|
||||||
|
score: number;
|
||||||
|
analysis: string;
|
||||||
|
categories: string[];
|
||||||
|
severity: string;
|
||||||
|
confidence: number;
|
||||||
|
recommendedAction: string;
|
||||||
|
},
|
||||||
|
embedding: number[] | null | undefined,
|
||||||
|
): Promise<void> {
|
||||||
|
if (!isQdrantConfigured() || !embedding || embedding.length === 0) return;
|
||||||
|
if (!isGloballyReusableCleanVerdict(result, undefined)) return;
|
||||||
|
const now = Date.now();
|
||||||
|
const USER_MOD_CACHE_TTL_MS = 24 * 60 * 60 * 1000;
|
||||||
|
await upsertQdrantPoint(bareKey, embedding, {
|
||||||
|
text: bareKey,
|
||||||
|
flags: JSON.stringify(result),
|
||||||
|
analyzed_at: now,
|
||||||
|
expires_at: now + USER_MOD_CACHE_TTL_MS,
|
||||||
|
content_hash: bareKey.split(":").pop() ?? "",
|
||||||
|
}).catch((err: unknown) => {
|
||||||
|
logger.error(
|
||||||
|
{ error: err instanceof Error ? err.message : String(err), bareKey },
|
||||||
|
"Failed to upsert bare-key clean verdict to Qdrant",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Store a moderation result for a (user, content) pair.
|
* Store a moderation result for a (user, content) pair.
|
||||||
* The `flags` field stores the full result object as JSON.
|
* The `flags` field stores the full result object as JSON.
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { resolve } from "node:dns/promises";
|
import { resolve } from "node:dns/promises";
|
||||||
import { isIP } from "node:net";
|
import { isIP } from "node:net";
|
||||||
|
import { LRUCache } from "lru-cache";
|
||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
||||||
|
|
||||||
@@ -150,6 +151,47 @@ function truncateAndCleanHtml(html: string, maxLen = 1000): string {
|
|||||||
export async function fetchUrlSafely(
|
export async function fetchUrlSafely(
|
||||||
url: string,
|
url: string,
|
||||||
depth = 0,
|
depth = 0,
|
||||||
|
): Promise<FetchedUrlContext> {
|
||||||
|
// Text results are memoized (in-process, short TTL): the same link recurs
|
||||||
|
// across batches and re-downloading + re-parsing the page each time was
|
||||||
|
// pure latency. Images are NEVER cached here — they are vision evidence
|
||||||
|
// and multi-MB buffers don't belong in an LRU. Errors are not cached so a
|
||||||
|
// transient network blip retries on the next batch.
|
||||||
|
if (depth === 0) {
|
||||||
|
const memo = textFetchMemo.get(url);
|
||||||
|
if (memo) return memo;
|
||||||
|
// In-flight dedupe: concurrent callers share one live request.
|
||||||
|
const existing = textInFlight.get(url);
|
||||||
|
if (existing) return existing;
|
||||||
|
const promise = fetchUrlSafelyUncached(url, depth)
|
||||||
|
.then((fetched) => {
|
||||||
|
if (fetched.type === "text") textFetchMemo.set(url, fetched);
|
||||||
|
return fetched;
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
textInFlight.delete(url);
|
||||||
|
});
|
||||||
|
textInFlight.set(url, promise);
|
||||||
|
return promise;
|
||||||
|
}
|
||||||
|
return fetchUrlSafelyUncached(url, depth);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** In-process memo of successful TEXT fetches (30 min TTL, bounded size). */
|
||||||
|
const textFetchMemo = new LRUCache<string, FetchedUrlContext>({
|
||||||
|
max: 500,
|
||||||
|
ttl: 30 * 60 * 1000,
|
||||||
|
});
|
||||||
|
|
||||||
|
/** Concurrent same-URL text fetches collapse into one live request. */
|
||||||
|
const textInFlight = new LRUCache<string, Promise<FetchedUrlContext>>({
|
||||||
|
max: 100,
|
||||||
|
ttl: 60_000,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function fetchUrlSafelyUncached(
|
||||||
|
url: string,
|
||||||
|
depth = 0,
|
||||||
): Promise<FetchedUrlContext> {
|
): Promise<FetchedUrlContext> {
|
||||||
if (depth > 1) {
|
if (depth > 1) {
|
||||||
return { url, type: "error", error: "Max redirect/meta depth reached" };
|
return { url, type: "error", error: "Max redirect/meta depth reached" };
|
||||||
|
|||||||
@@ -21,6 +21,7 @@
|
|||||||
import { createChildLogger } from "@/shared/logger/index";
|
import { createChildLogger } from "@/shared/logger/index";
|
||||||
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
import { createAbortControllerWithTimeout } from "@/shared/utils/index";
|
||||||
import { config } from "../../shared/config/config.js";
|
import { config } from "../../shared/config/config.js";
|
||||||
|
import { cacheGet, cacheSet, makeCacheKey } from "./cacheStore.js";
|
||||||
|
|
||||||
const log = createChildLogger("wikipedia-client");
|
const log = createChildLogger("wikipedia-client");
|
||||||
|
|
||||||
@@ -57,10 +58,19 @@ function stripHtml(snippet: string): string {
|
|||||||
.trim();
|
.trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Redis TTL for cached search results (6h — articles change slowly). */
|
||||||
|
const SEARCH_CACHE_TTL_SECONDS = 6 * 60 * 60;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Search Wikipedia for a query and return up to MAX_RESULTS structured hits.
|
* Search Wikipedia for a query and return up to MAX_RESULTS structured hits.
|
||||||
* Uses the Action API `list=search` (srsearch) which is stable and returns
|
* Uses the Action API `list=search` (srsearch) which is stable and returns
|
||||||
* title + HTML snippet. Graceful: returns [] on any failure.
|
* title + HTML snippet. Graceful: returns [] on any failure.
|
||||||
|
*
|
||||||
|
* Cached in the shared Redis store: the same query recurs across batches
|
||||||
|
* (repeat slang, recurring topics), and an uncached re-search per batch was
|
||||||
|
* pure latency + Wikipedia rate-limit pressure. Only NON-EMPTY results are
|
||||||
|
* cached — an empty result may be a transient limiter/network blip, so it is
|
||||||
|
* retried on a later batch instead of being pinned for 6 hours.
|
||||||
*/
|
*/
|
||||||
export async function wikipediaSearch(
|
export async function wikipediaSearch(
|
||||||
query: string,
|
query: string,
|
||||||
@@ -69,6 +79,32 @@ export async function wikipediaSearch(
|
|||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (!q) return [];
|
if (!q) return [];
|
||||||
|
|
||||||
|
const cacheKey = makeCacheKey("wikisearch", q);
|
||||||
|
const cached = await cacheGet(cacheKey);
|
||||||
|
if (cached) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(cached) as SearchResult[];
|
||||||
|
if (Array.isArray(parsed) && parsed.length > 0) {
|
||||||
|
log.debug({ query: q }, "Wikipedia search cache HIT");
|
||||||
|
return parsed;
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Malformed entry — fall through to live fetch.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const mapped = await wikipediaSearchLive(q, timeoutMs);
|
||||||
|
if (mapped.length > 0) {
|
||||||
|
cacheSet(cacheKey, JSON.stringify(mapped), SEARCH_CACHE_TTL_SECONDS);
|
||||||
|
}
|
||||||
|
return mapped;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Live (uncached) Action API search. Returns [] on any failure. */
|
||||||
|
async function wikipediaSearchLive(
|
||||||
|
q: string,
|
||||||
|
timeoutMs: number,
|
||||||
|
): Promise<SearchResult[]> {
|
||||||
const params = new URLSearchParams({
|
const params = new URLSearchParams({
|
||||||
action: "query",
|
action: "query",
|
||||||
list: "search",
|
list: "search",
|
||||||
|
|||||||
@@ -48,7 +48,13 @@ export class MessagesCleanup {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async revertStuckProcessingMessages(
|
async revertStuckProcessingMessages(
|
||||||
timeoutMs: number = 300000,
|
// 2026-08-24: lowered from 300000 — this cleanup is the last-resort
|
||||||
|
// recoverer for rows stuck in `processing`. With the upload-pending
|
||||||
|
// race-guard now requeueing properly (fallbackResultClassifier), any row
|
||||||
|
// that still sits here for >2min is a genuine leak; reverting sooner
|
||||||
|
// bounds the worst-case delay without racing legitimate in-flight work
|
||||||
|
// (media batches can legitimately take ~60s+).
|
||||||
|
timeoutMs: number = 120000,
|
||||||
): Promise<number> {
|
): Promise<number> {
|
||||||
this.logger.debug({ timeoutMs }, "revertStuckProcessingMessages entry");
|
this.logger.debug({ timeoutMs }, "revertStuckProcessingMessages entry");
|
||||||
try {
|
try {
|
||||||
|
|||||||
@@ -149,8 +149,8 @@ export const configSchema = z
|
|||||||
AI_LLM_BASE_URL: z
|
AI_LLM_BASE_URL: z
|
||||||
.string()
|
.string()
|
||||||
.url()
|
.url()
|
||||||
.default("https://omniroute.imrnes.team/v1"),
|
.default("https://9router.asepharyana.my.id/v1"),
|
||||||
AI_LLM_MODEL: z.string().default("text"),
|
AI_LLM_MODEL: z.string().default("claude-opus-5"),
|
||||||
// Vision uses the SAME router/base URL as text moderation
|
// Vision uses the SAME router/base URL as text moderation
|
||||||
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
|
||||||
// multimodal endpoint was removed.
|
// multimodal endpoint was removed.
|
||||||
@@ -168,11 +168,21 @@ export const configSchema = z
|
|||||||
.min(0)
|
.min(0)
|
||||||
.max(1)
|
.max(1)
|
||||||
.default(0.97),
|
.default(0.97),
|
||||||
|
// Two-band semantic acceptance (2026-08-24): non-actionable verdicts
|
||||||
|
// (clean, no flags, action=none) may be reused from a LOOSER similarity
|
||||||
|
// band than actionable ones (warn/flagged). Actionable verdicts keep the
|
||||||
|
// strict gate above; anything between the two bands falls through to the
|
||||||
|
// LLM (fail-open toward accuracy).
|
||||||
|
AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1)
|
||||||
|
.default(0.92),
|
||||||
AI_LLM_EMBEDDING_MAX_CANDIDATES: z.coerce
|
AI_LLM_EMBEDDING_MAX_CANDIDATES: z.coerce
|
||||||
.number()
|
.number()
|
||||||
.int()
|
.int()
|
||||||
.positive()
|
.positive()
|
||||||
.default(30),
|
.default(50),
|
||||||
// Qdrant vector store for the semantic moderation cache. When
|
// Qdrant vector store for the semantic moderation cache. When
|
||||||
// QDRANT_URL is set, embeddings are stored/searched there (Postgres
|
// QDRANT_URL is set, embeddings are stored/searched there (Postgres
|
||||||
// embedding column remains as a legacy fallback).
|
// embedding column remains as a legacy fallback).
|
||||||
@@ -244,9 +254,29 @@ export const configSchema = z
|
|||||||
.positive()
|
.positive()
|
||||||
.default(10000),
|
.default(10000),
|
||||||
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
AI_ANALYSIS_ERROR_COOLDOWN_MS: z.coerce.number().positive().default(30000),
|
||||||
|
// Upload-pending batch poll (2026-08-25): when a batch is deferred because
|
||||||
|
// attachments are still uploading, the processor re-schedules with this
|
||||||
|
// base delay (linear ramp per consecutive poll, capped) instead of the
|
||||||
|
// 250ms debounce — the old path hot-looped ~300ms for the whole upload.
|
||||||
|
AI_ANALYSIS_UPLOAD_POLL_MS: z.coerce.number().positive().default(1500),
|
||||||
|
AI_ANALYSIS_MAX_UPLOAD_POLL_MS: z.coerce.number().positive().default(8000),
|
||||||
|
|
||||||
// ── AI Analysis Batch ───────────────────────────────────────────────
|
// ── AI Analysis Batch ───────────────────────────────────────────────
|
||||||
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
AI_ANALYSIS_MAX_BATCH_SIZE: z.coerce.number().int().positive().default(200),
|
||||||
|
// Global exact-cache reuse guard (2026-08-24): a context-scoped miss may
|
||||||
|
// fall back to the legacy bare (context-free) key, but ONLY for verdicts
|
||||||
|
// that cannot trigger an action and are fresh + confident. These knobs
|
||||||
|
// bound that reuse.
|
||||||
|
AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE: z.coerce
|
||||||
|
.number()
|
||||||
|
.min(0)
|
||||||
|
.max(1)
|
||||||
|
.default(0.85),
|
||||||
|
AI_CACHE_GLOBAL_REUSE_MAX_AGE_H: z.coerce
|
||||||
|
.number()
|
||||||
|
.int()
|
||||||
|
.positive()
|
||||||
|
.default(120),
|
||||||
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
|
AI_ANALYSIS_MAX_CONTEXT_TOKENS: z.coerce.number().positive().default(8000),
|
||||||
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(14000),
|
AI_ANALYSIS_MAX_TARGET_TOKENS: z.coerce.number().positive().default(14000),
|
||||||
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce
|
AI_ANALYSIS_CONTEXT_MESSAGE_LIMIT: z.coerce
|
||||||
|
|||||||
@@ -0,0 +1,110 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// partitionBatchOutcome — upload-pending defer vs fanout (2026-08-25)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bug history: the batch worker's race guard returned {ok:true, rows:[]} while
|
||||||
|
// attachments were still uploading; every target was classified "incomplete",
|
||||||
|
// fanned out to the individual queue, requeued there, rescheduled at 250ms —
|
||||||
|
// a hot ~300ms loop for the whole upload duration (~10 cycles in 3s in prod).
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
computeUploadPollDelayMs,
|
||||||
|
partitionBatchOutcome,
|
||||||
|
} from "../src/modules/ai-moderation/batchOutcomeClassifier.js";
|
||||||
|
|
||||||
|
const msgs = (...ids: string[]) => ids.map((id) => ({ id }));
|
||||||
|
|
||||||
|
describe("partitionBatchOutcome", () => {
|
||||||
|
it("marks ALL targets upload_pending when the full-batch guard fires", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("a", "b"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: ["a", "b"],
|
||||||
|
});
|
||||||
|
expect(out.get("a")).toBe("upload_pending");
|
||||||
|
expect(out.get("b")).toBe("upload_pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("never classifies an explicit upload_pending id as incomplete", () => {
|
||||||
|
// The regression this file exists for: uploadPendingIds must win over the
|
||||||
|
// missing-row heuristic.
|
||||||
|
const out = partitionBatchOutcome(msgs("a"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [],
|
||||||
|
uploadPendingIds: ["a"],
|
||||||
|
});
|
||||||
|
expect(out.get("a")).not.toBe("incomplete");
|
||||||
|
expect(out.get("a")).toBe("upload_pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("partitions a mixed batch: completed + upload-pending + missing", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("ok1", "up1", "gone1"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{ id: "ok1", ai_status: "clean" },
|
||||||
|
// up1 has NO row but IS in uploadPendingIds -> deferred, not failed
|
||||||
|
],
|
||||||
|
uploadPendingIds: ["up1"],
|
||||||
|
});
|
||||||
|
expect(out.get("ok1")).toBe("completed");
|
||||||
|
expect(out.get("up1")).toBe("upload_pending");
|
||||||
|
expect(out.get("gone1")).toBe("incomplete"); // unexplained drop stays retryable
|
||||||
|
});
|
||||||
|
|
||||||
|
it("routes flag-based failures to their buckets", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("i", "p", "f"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{
|
||||||
|
id: "i",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_incomplete"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "p",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_parse_failed"]),
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "f",
|
||||||
|
ai_status: "error",
|
||||||
|
ai_moderation_flags: JSON.stringify(["analysis_api_failed"]),
|
||||||
|
},
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.get("i")).toBe("incomplete");
|
||||||
|
expect(out.get("p")).toBe("parse_failed");
|
||||||
|
expect(out.get("f")).toBe("api_failed");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats error rows with no known flag as retryable incomplete", () => {
|
||||||
|
const out = partitionBatchOutcome(msgs("x"), {
|
||||||
|
ok: true,
|
||||||
|
rows: [
|
||||||
|
{ id: "x", ai_status: "error", ai_moderation_flags: '["weird_flag"]' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
expect(out.get("x")).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts DB-round-trip MessageRecord shape (null ai_status)", () => {
|
||||||
|
const out = partitionBatchOutcome([{ id: "r", ai_status: null }] as never, {
|
||||||
|
ok: true,
|
||||||
|
rows: [{ id: "r", ai_status: null }],
|
||||||
|
});
|
||||||
|
expect(out.get("r")).toBe("completed");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("computeUploadPollDelayMs", () => {
|
||||||
|
it("ramps linearly and respects the cap", () => {
|
||||||
|
expect(computeUploadPollDelayMs(1, 1500, 8000)).toBe(1500);
|
||||||
|
expect(computeUploadPollDelayMs(2, 1500, 8000)).toBe(3000);
|
||||||
|
expect(computeUploadPollDelayMs(3, 1500, 8000)).toBe(4500);
|
||||||
|
expect(computeUploadPollDelayMs(9, 1500, 8000)).toBe(8000); // capped
|
||||||
|
});
|
||||||
|
|
||||||
|
it("is safe on degenerate input", () => {
|
||||||
|
expect(computeUploadPollDelayMs(0, 1500, 8000)).toBe(1500);
|
||||||
|
expect(computeUploadPollDelayMs(-5, 1000, 4000)).toBe(1000);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Batched exact-cache lookup (getCachedTextModerations)
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Design (2026-08-24): phase-1 cache lookups collapse N sequential per-key
|
||||||
|
// queries into ONE `text = ANY($1::text[])` query. Semantics must match the
|
||||||
|
// single-key getter: unexpired rows only, malformed rows skipped, verdicts
|
||||||
|
// normalized through the shared parser. The DB layer is mocked — no live
|
||||||
|
// Postgres in unit tests.
|
||||||
|
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||||
|
|
||||||
|
const executeAll = vi.fn();
|
||||||
|
const executeGet = vi.fn();
|
||||||
|
|
||||||
|
vi.mock("../src/shared/database/drizzle.js", () => ({
|
||||||
|
executeAll: (...args: unknown[]) => executeAll(...args),
|
||||||
|
executeGet: (...args: unknown[]) => executeGet(...args),
|
||||||
|
}));
|
||||||
|
|
||||||
|
import {
|
||||||
|
getCachedTextModerations,
|
||||||
|
parseStoredVerdictRow,
|
||||||
|
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||||
|
|
||||||
|
function rowFor(
|
||||||
|
text: string,
|
||||||
|
status: string,
|
||||||
|
analyzedAt = Date.now() - 1000,
|
||||||
|
): Record<string, unknown> {
|
||||||
|
return {
|
||||||
|
text,
|
||||||
|
flags: JSON.stringify({
|
||||||
|
status,
|
||||||
|
flags: [],
|
||||||
|
score: 0,
|
||||||
|
analysis: "ok",
|
||||||
|
categories: [],
|
||||||
|
severity: "none",
|
||||||
|
confidence: 0.9,
|
||||||
|
recommendedAction: "none",
|
||||||
|
}),
|
||||||
|
source: "user_moderation",
|
||||||
|
analyzed_at: analyzedAt,
|
||||||
|
expires_at: Date.now() + 3_600_000,
|
||||||
|
hit_count: 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
beforeEach(() => {
|
||||||
|
executeAll.mockReset();
|
||||||
|
executeGet.mockReset();
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("parseStoredVerdictRow", () => {
|
||||||
|
it("parses a valid stored verdict", () => {
|
||||||
|
const v = parseStoredVerdictRow({
|
||||||
|
flags: JSON.stringify({ status: "warn", flags: ["x"] }),
|
||||||
|
});
|
||||||
|
expect(v).not.toBeNull();
|
||||||
|
expect(v?.status).toBe("warn");
|
||||||
|
expect(v?.flags).toEqual(["x"]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null on malformed JSON", () => {
|
||||||
|
expect(parseStoredVerdictRow({ flags: "{not-json" })).toBeNull();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("derives legacy status from flags when status is absent", () => {
|
||||||
|
const v = parseStoredVerdictRow({
|
||||||
|
flags: JSON.stringify({ flags: ["a"] }),
|
||||||
|
});
|
||||||
|
expect(v?.status).toBe("flagged");
|
||||||
|
const v2 = parseStoredVerdictRow({ flags: JSON.stringify({}) });
|
||||||
|
expect(v2?.status).toBe("clean");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("getCachedTextModerations", () => {
|
||||||
|
it("returns an empty map for empty input and issues no query", async () => {
|
||||||
|
const result = await getCachedTextModerations([]);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
expect(executeAll).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("dedupes keys and returns parsed verdicts keyed by cache key", async () => {
|
||||||
|
executeAll.mockResolvedValueOnce([
|
||||||
|
rowFor("text_mod:c1:aaa", "clean"),
|
||||||
|
rowFor("text_mod:c1:bbb", "warn"),
|
||||||
|
]);
|
||||||
|
const result = await getCachedTextModerations([
|
||||||
|
"text_mod:c1:aaa",
|
||||||
|
"text_mod:c1:aaa",
|
||||||
|
"text_mod:c1:bbb",
|
||||||
|
]);
|
||||||
|
expect(result.size).toBe(2);
|
||||||
|
expect(result.get("text_mod:c1:aaa")?.verdict.status).toBe("clean");
|
||||||
|
expect(result.get("text_mod:c1:bbb")?.verdict.status).toBe("warn");
|
||||||
|
// Exactly ONE batched round-trip.
|
||||||
|
expect(executeAll).toHaveBeenCalledTimes(1);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips rows with malformed payloads instead of failing the batch", async () => {
|
||||||
|
executeAll.mockResolvedValueOnce([
|
||||||
|
{
|
||||||
|
text: "k_good",
|
||||||
|
flags: JSON.stringify({ status: "clean", flags: [] }),
|
||||||
|
analyzed_at: 1,
|
||||||
|
},
|
||||||
|
{ text: "k_bad", flags: "{broken" },
|
||||||
|
]);
|
||||||
|
const result = await getCachedTextModerations(["k_good", "k_bad"]);
|
||||||
|
expect(result.has("k_good")).toBe(true);
|
||||||
|
expect(result.has("k_bad")).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("survives a DB failure and returns an empty map (fail-open)", async () => {
|
||||||
|
executeAll.mockRejectedValueOnce(new Error("connection refused"));
|
||||||
|
const result = await getCachedTextModerations(["k1", "k2"]);
|
||||||
|
expect(result.size).toBe(0);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("chunks queries beyond 200 keys", async () => {
|
||||||
|
executeAll.mockResolvedValue([]);
|
||||||
|
const keys = Array.from({ length: 450 }, (_, i) => `k${i}`);
|
||||||
|
await getCachedTextModerations(keys);
|
||||||
|
expect(executeAll).toHaveBeenCalledTimes(3); // 200 + 200 + 50
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,169 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Semantic two-band acceptance + global exact-cache reuse guard
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Design (2026-08-24): cache hits may be served MORE aggressively for
|
||||||
|
// verdicts that cannot trigger enforcement actions, and NEVER more
|
||||||
|
// aggressively for actionable ones. Two layers enforce this:
|
||||||
|
// - isSemanticBandAccepted: similarity thresholds differ by verdict class
|
||||||
|
// (clean band 0.92 default vs strict actionable band 0.97 default).
|
||||||
|
// - isGloballyReusableCleanVerdict: context-free (cross-channel) reuse of
|
||||||
|
// the legacy bare key only for clean / flagless / action=none verdicts
|
||||||
|
// with high confidence and bounded age.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
isGloballyReusableCleanVerdict,
|
||||||
|
type StoredModerationVerdict,
|
||||||
|
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||||
|
|
||||||
|
function makeVerdict(
|
||||||
|
overrides: Partial<StoredModerationVerdict> = {},
|
||||||
|
): StoredModerationVerdict {
|
||||||
|
return {
|
||||||
|
status: "clean",
|
||||||
|
flags: [],
|
||||||
|
score: 0,
|
||||||
|
analysis: "",
|
||||||
|
categories: [],
|
||||||
|
severity: "none",
|
||||||
|
confidence: 0.95,
|
||||||
|
recommendedAction: "none",
|
||||||
|
...overrides,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("isSemanticBandAccepted", () => {
|
||||||
|
it("accepts a non-actionable clean verdict at the loose clean band", () => {
|
||||||
|
// Default AI_LLM_EMBEDDING_MIN_SIMILARITY_CLEAN = 0.92.
|
||||||
|
expect(isBandAccept(makeVerdict(), 0.93)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a clean verdict exactly at the clean band boundary", () => {
|
||||||
|
expect(isBandAccept(makeVerdict({ confidence: 0.99 }), 0.92)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a clean verdict below the clean band", () => {
|
||||||
|
expect(isBandAccept(makeVerdict(), 0.91)).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects an actionable flagged verdict between the bands", () => {
|
||||||
|
// 0.93 >= clean band BUT < strict band → must NOT be served.
|
||||||
|
expect(
|
||||||
|
isBandAccept(
|
||||||
|
makeVerdict({ status: "flagged", flags: ["hate_speech"] }),
|
||||||
|
0.93,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts a flagged verdict at the strict band", () => {
|
||||||
|
expect(
|
||||||
|
isBandAccept(
|
||||||
|
makeVerdict({ status: "flagged", flags: ["hate_speech"] }),
|
||||||
|
0.98,
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects a warn verdict below the strict band", () => {
|
||||||
|
expect(
|
||||||
|
isBandAccept(
|
||||||
|
makeVerdict({ status: "warn", recommendedAction: "warn" }),
|
||||||
|
0.96,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a clean verdict WITH flags as actionable (strict band)", () => {
|
||||||
|
expect(isBandAccept(makeVerdict({ flags: ["borderline"] }), 0.93)).toBe(
|
||||||
|
false,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a clean verdict with a non-none action as actionable", () => {
|
||||||
|
expect(
|
||||||
|
isBandAccept(makeVerdict({ recommendedAction: "review" }), 0.93),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Import indirection so the describe block reads cleanly.
|
||||||
|
import { isSemanticBandAccepted as isBandAccept } from "../src/modules/ai-moderation/textCacheStore.js";
|
||||||
|
|
||||||
|
describe("isGloballyReusableCleanVerdict", () => {
|
||||||
|
it("accepts a fresh, confident, flagless clean verdict", () => {
|
||||||
|
const v = makeVerdict({ confidence: 0.9 });
|
||||||
|
expect(isGloballyReusableCleanVerdict(v, Date.now() - 60_000)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects flagged / warn verdicts outright", () => {
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
makeVerdict({ status: "flagged", flags: ["harassment"] }),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
makeVerdict({ status: "warn", flags: ["mild"] }),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects clean verdicts carrying flags", () => {
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(makeVerdict({ flags: ["x"] }), Date.now()),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects verdicts whose recommended action is not none", () => {
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
makeVerdict({ recommendedAction: "delete" }),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects low-confidence verdicts below the guard threshold", () => {
|
||||||
|
// Default AI_CACHE_GLOBAL_REUSE_MIN_CONFIDENCE = 0.85.
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
makeVerdict({ confidence: 0.6 }),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts confidence exactly at the guard threshold", () => {
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
makeVerdict({ confidence: 0.85 }),
|
||||||
|
Date.now(),
|
||||||
|
),
|
||||||
|
).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects entries older than the freshness window", () => {
|
||||||
|
// Default AI_CACHE_GLOBAL_REUSE_MAX_AGE_H = 120h.
|
||||||
|
const tooOld = Date.now() - 121 * 60 * 60 * 1000;
|
||||||
|
expect(isGloballyReusableCleanVerdict(makeVerdict(), tooOld)).toBe(false);
|
||||||
|
const freshEnough = Date.now() - 119 * 60 * 60 * 1000;
|
||||||
|
expect(isGloballyReusableCleanVerdict(makeVerdict(), freshEnough)).toBe(
|
||||||
|
true,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("skips the age check when analyzedAt is unknown", () => {
|
||||||
|
expect(isGloballyReusableCleanVerdict(makeVerdict(), undefined)).toBe(true);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("rejects non-standard statuses such as processing write-backs", () => {
|
||||||
|
expect(
|
||||||
|
isGloballyReusableCleanVerdict(
|
||||||
|
{ ...makeVerdict(), status: "processing" },
|
||||||
|
undefined,
|
||||||
|
),
|
||||||
|
).toBe(false);
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,89 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// normalizeDiscordImageUrl — unified vision cache keys for Discord CDN URLs
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Design (2026-08-24): the same attachment reached through different signed
|
||||||
|
// URLs (?ex=&is=&hm= tokens rotate per fetch) or render variants
|
||||||
|
// (?format=&width=) must map to ONE vision-cache key, otherwise the vision
|
||||||
|
// model re-downloads and re-analyzes the identical image once per variant.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import {
|
||||||
|
makeImageCacheKey,
|
||||||
|
normalizeDiscordImageUrl,
|
||||||
|
} from "../src/modules/ai-moderation/textCacheStore.js";
|
||||||
|
|
||||||
|
describe("normalizeDiscordImageUrl", () => {
|
||||||
|
it("strips rotating signed tokens from cdn.discordapp.com URLs", () => {
|
||||||
|
const a = normalizeDiscordImageUrl(
|
||||||
|
"https://cdn.discordapp.com/attachments/1/2/img.png?ex=67a&is=67b&hm=tokA",
|
||||||
|
);
|
||||||
|
const b = normalizeDiscordImageUrl(
|
||||||
|
"https://cdn.discordapp.com/attachments/1/2/img.png?ex=78c&is=78d&hm=tokB",
|
||||||
|
);
|
||||||
|
expect(a).toBe("https://cdn.discordapp.com/attachments/1/2/img.png");
|
||||||
|
expect(a).toBe(b);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips render variants from media.discordapp.net URLs", () => {
|
||||||
|
const a = normalizeDiscordImageUrl(
|
||||||
|
"https://media.discordapp.net/attachments/1/2/img.png?format=webp&width=400&height=300",
|
||||||
|
);
|
||||||
|
const b = normalizeDiscordImageUrl(
|
||||||
|
"https://media.discordapp.net/attachments/1/2/img.png?format=png&width=1024&height=768",
|
||||||
|
);
|
||||||
|
expect(a).toBe(b);
|
||||||
|
expect(a).toBe("https://media.discordapp.net/attachments/1/2/img.png");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("strips query params from images-ext preview hosts", () => {
|
||||||
|
const a = normalizeDiscordImageUrl(
|
||||||
|
"https://images-ext-1.discordapp.net/external/X/https/example.com/cat.jpg?format=webp",
|
||||||
|
);
|
||||||
|
expect(a).toBe(
|
||||||
|
"https://images-ext-1.discordapp.net/external/X/https/example.com/cat.jpg",
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves URLs without query untouched", () => {
|
||||||
|
const u = "https://cdn.discordapp.com/attachments/1/2/img.png";
|
||||||
|
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves non-Discord URLs untouched (query may be meaningful)", () => {
|
||||||
|
const u = "https://example.com/image?token=abc&id=1";
|
||||||
|
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("leaves data: URLs untouched", () => {
|
||||||
|
const u = "data:image/png;base64,iVBORw0KGgoAAAANS?weird=query";
|
||||||
|
// new URL() parses data: with protocol "data:" → not http(s) → untouched.
|
||||||
|
expect(normalizeDiscordImageUrl(u)).toBe(u);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("makeImageCacheKey — Discord URL unification", () => {
|
||||||
|
it("produces the SAME key for the same attachment across token variants", () => {
|
||||||
|
const keyA = makeImageCacheKey(
|
||||||
|
"https://cdn.discordapp.com/attachments/9/8/pic.png?ex=111&is=222&hm=AAA",
|
||||||
|
);
|
||||||
|
const keyB = makeImageCacheKey(
|
||||||
|
"https://cdn.discordapp.com/attachments/9/8/pic.png?ex=333&is=444&hm=BBB",
|
||||||
|
);
|
||||||
|
expect(keyA).toBe(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("still produces DIFFERENT keys for different attachments", () => {
|
||||||
|
const keyA = makeImageCacheKey(
|
||||||
|
"https://cdn.discordapp.com/attachments/9/8/a.png?ex=1",
|
||||||
|
);
|
||||||
|
const keyB = makeImageCacheKey(
|
||||||
|
"https://cdn.discordapp.com/attachments/9/8/b.png?ex=1",
|
||||||
|
);
|
||||||
|
expect(keyA).not.toBe(keyB);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("preserves the historical full-hash behavior for non-Discord URLs", () => {
|
||||||
|
// Regression guard: external URLs keep pre-change keys.
|
||||||
|
const key = makeImageCacheKey("https://example.com/x.png");
|
||||||
|
expect(key).toBe(makeImageCacheKey("https://example.com/x.png"));
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// classifyIndividualWorkerResult — upload-pending vs success vs incomplete vs error
|
||||||
|
// ═══════════════════════════════════════════════════════════════════════════
|
||||||
|
// Bug (2026-08-24): the worker's upload-pending race guard returned
|
||||||
|
// {ok:true, results:[]}; the processor treated it as a completed moderation,
|
||||||
|
// leaving messages stuck in `processing` until the 300s cleanup reverted them.
|
||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { classifyIndividualWorkerResult } from "../src/modules/ai-moderation/fallbackResultClassifier.js";
|
||||||
|
|
||||||
|
describe("classifyIndividualWorkerResult", () => {
|
||||||
|
it("classifies the upload-pending race guard signal FIRST", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: true,
|
||||||
|
results: [],
|
||||||
|
uploadPending: true,
|
||||||
|
}),
|
||||||
|
).toBe("upload_pending");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies a normal verdict as success", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: true,
|
||||||
|
results: [{ status: "clean", flags: [] }],
|
||||||
|
}),
|
||||||
|
).toBe("success");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies analysis_incomplete as incomplete", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: true,
|
||||||
|
results: [{ status: "error", flags: ["analysis_incomplete"] }],
|
||||||
|
}),
|
||||||
|
).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("accepts flags as JSON string (DB round-trip shape)", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: true,
|
||||||
|
results: [
|
||||||
|
{ status: "error", flags: JSON.stringify(["analysis_incomplete"]) },
|
||||||
|
],
|
||||||
|
}),
|
||||||
|
).toBe("incomplete");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("classifies ok:false as error", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: false,
|
||||||
|
results: [],
|
||||||
|
error: "boom",
|
||||||
|
}),
|
||||||
|
).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("THE BUG: empty results with ok:true is an ERROR, not a success", () => {
|
||||||
|
// Old code silently succeeded here → stuck `processing` rows.
|
||||||
|
expect(classifyIndividualWorkerResult({ ok: true, results: [] })).toBe(
|
||||||
|
"error",
|
||||||
|
);
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({ ok: true, results: [undefined] }),
|
||||||
|
).toBe("error");
|
||||||
|
expect(classifyIndividualWorkerResult({})).toBe("error");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("treats a result with no status as unexplainable (error)", () => {
|
||||||
|
expect(
|
||||||
|
classifyIndividualWorkerResult({
|
||||||
|
ok: true,
|
||||||
|
results: [{ flags: [] }],
|
||||||
|
}),
|
||||||
|
).toBe("error");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -0,0 +1,91 @@
|
|||||||
|
# FE Optimasi: Error Handling, State Refactor, Polish
|
||||||
|
|
||||||
|
**Scope:** `services/frontend/src` — Next.js 16, React 19, SWR, oRPC-over-WS.
|
||||||
|
**Branch:** `feat/fe-error-handling-state` (buat baru dari `main`).
|
||||||
|
**User:** MythEclipse — bahasa Indonesia/Inggris campur, "gas" = eksekusi langsung.
|
||||||
|
|
||||||
|
## 1. Masalah yang teridentifikasi
|
||||||
|
|
||||||
|
### 1.1 Error handling tidak seragam
|
||||||
|
- `ErrorState` (states.tsx:126) sudah ada dengan `onRetry?`, tapi **80% pemanggilan tidak pass `onRetry`** → user stuck, harus refresh manual.
|
||||||
|
- Pola `if (error && !data) return <ErrorState />` berulang di 5+ view, setiap satu hardcode logic-nya.
|
||||||
|
- `useAction` (use-action.ts) set `error` di state tapi komponen pakai `try/catch` lokal juga — **dua error states** (hook `action.error` + lokal `catch (e)`).
|
||||||
|
|
||||||
|
### 1.2 State duplication / double source of truth
|
||||||
|
- `useReview` (use-messages.ts:129) pakai `refreshInterval: 15_000` (polling) padahal WS sudah broadcast `moderation_action` real-time. **Polling + WS = double-fetch + race condition.**
|
||||||
|
- `page.tsx` dan `view.tsx` duplikat state `guildId`/`channelId`/`query` — logic selection ada di 2 tempat.
|
||||||
|
- Chatbot pakai local `failed` state + `setFailed(text)` untuk retry, tapi `useAction` sudah ada `error` field yang tidak dipakai.
|
||||||
|
- `useSpeakers` (use-voice.ts:50) pakai local `useState` untuk speakers, bukan SWR → tidak konsisten dengan pola SWR yang dipakai semua hook lain.
|
||||||
|
|
||||||
|
### 1.3 WebSocket error/user feedback lemah
|
||||||
|
- `WsConnection.onerror` (connection.ts:78) cuma `setStatus("error")` — tidak ada info ke user "reconnecting… (attempt N)".
|
||||||
|
- Tidak ada broadcast error ke komponen (tidak ada `ws_error` handler).
|
||||||
|
|
||||||
|
## 2. Rencana perubahan
|
||||||
|
|
||||||
|
### 2.1 Standardisasi ErrorBoundary + Retry (FE-only)
|
||||||
|
**File:** `src/components/shared/states.tsx`
|
||||||
|
- Tambahkan komponen `ErrorBoundary` (React error boundary untuk crash React, bukan fetch error).
|
||||||
|
- Refactor semua `<ErrorState error={error} />` → `<ErrorState error={error} onRetry={retryFn} />`.
|
||||||
|
- Tambahkan helper `withSWR` atau pattern: tiap view pakai `error` + `mutate`/`refetch` dari SWR dan pass ke ErrorState.
|
||||||
|
|
||||||
|
**File:** `src/app/(dashboard)/*/view.tsx` (6 files)
|
||||||
|
- Setiap `ErrorState` dapat `onRetry` yang memanggil `mutate`/`refetch`.
|
||||||
|
- `DashboardView`: `onRetry={() => void mutate(["dashboard-stats"])}` — tapi SWR keys tersebar. Solusi: export `mutate` via custom hook atau pakai `useSWR` config `onErrorRetry` global.
|
||||||
|
- **Keputusan:** gunakan pola **global SWR config** (`src/lib/swr-config.ts`) dengan `onErrorRetry` backoff, dan tiap view pass `onRetry` explicit ke ErrorState.
|
||||||
|
|
||||||
|
### 2.2 Hapus polling `useReview`, ganti WS-driven
|
||||||
|
**File:** `src/hooks/use-messages.ts`
|
||||||
|
- Hapus `refreshInterval: 15_000` dari `useReview`.
|
||||||
|
- Tambahkan `useReviewWsSync(ws)` — subscribe ke `moderation_action` WS event, mutate key `["messages-review", channelId]`.
|
||||||
|
- Backend sudah broadcast `moderation_action` via WS (redis-channels.ts:130). Review messages yang di-flag akan dapat `moderation_action` event. **Bisa pakai ini.**
|
||||||
|
|
||||||
|
**File:** `src/app/(dashboard)/messages/view.tsx`
|
||||||
|
- Tambahkan `useReviewWsSync(ws)` call.
|
||||||
|
|
||||||
|
### 2.3 Konsistensi state: `useSpeakers` → SWR
|
||||||
|
**File:** `src/hooks/use-voice.ts`
|
||||||
|
- Refactor `useSpeakers` agar pakai SWR key `["voice-speakers"]` + initialData dari `useVoiceStatus`. Ini memungkinkan revalidate + cache sharing.
|
||||||
|
- Tapi `voice_state` dan `voice_active_user` adalah WS events — perlu persist ke SWR cache via `mutate`. Refactor: `useSpeakers` subscribe WS + mutate SWR key.
|
||||||
|
|
||||||
|
### 2.4 Chatbot: gunakan `useAction` error state, hapus duplikat `failed`
|
||||||
|
**File:** `src/components/chatbot/chatbot.tsx`
|
||||||
|
- Hapus `const [failed, setFailed]` — sebalihoikan ke `useAction` return `error`.
|
||||||
|
- Tapi `useAction` `mutateAsync` throw — perlu catch. Refactor: pakai `mutate` (fire-and-forget) + `isPending` + `error`.
|
||||||
|
- **Note:** chatbot pakai `send` yang butuh pemuatan history — tetap pakai local state untuk msgs tapi gunakan `action.error` untuk display.
|
||||||
|
|
||||||
|
### 2.5 WS error feedback
|
||||||
|
**File:** `src/lib/ws/connection.ts`
|
||||||
|
- `onerror` emit kode/status ke status listeners.
|
||||||
|
- Tambahkan method `getReconnectAttempt()` atau expose via status change.
|
||||||
|
|
||||||
|
**File:** `src/lib/ws/context.tsx`
|
||||||
|
- Subscribe `onStatusChange` di `WsProvider`, toast "Reconnecting… (attempt N)" ketika status `error`/`connecting`.
|
||||||
|
|
||||||
|
## 3. Verification
|
||||||
|
- `npx tsc --noEmit` — compile OK
|
||||||
|
- `npx biome check src/` — lint OK
|
||||||
|
- `npm run build` — build OK (Next 16 SSG/SSR)
|
||||||
|
- Manual: refresh halaman, pastikan ErrorState muncul dengan tombol Retry yang bisa diklik.
|
||||||
|
|
||||||
|
## 4. Files yang disentuh
|
||||||
|
```
|
||||||
|
src/components/shared/states.tsx # ErrorBoundary + helper
|
||||||
|
src/lib/swr-config.ts # global SWR config (baru)
|
||||||
|
src/hooks/use-messages.ts # useReviewWsSync, hapus polling
|
||||||
|
src/hooks/use-voice.ts # useSpeakers → SWR
|
||||||
|
src/hooks/use-action.ts # expose resetError
|
||||||
|
src/lib/ws/connection.ts # reconnect info
|
||||||
|
src/lib/ws/context.tsx # WS error toast
|
||||||
|
src/app/(dashboard)/messages/view.tsx # useReviewWsSync
|
||||||
|
src/app/(dashboard)/messages/page.tsx # SSR error passthrough
|
||||||
|
src/app/(dashboard)/moderation/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/dashboard/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/voice/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/media/view.tsx # onRetry
|
||||||
|
src/app/(dashboard)/recordings/view.tsx # onRetry
|
||||||
|
src/components/chatbot/chatbot.tsx # pakai useAction error
|
||||||
|
```
|
||||||
|
|
||||||
|
## 5. Out of scope (BE)
|
||||||
|
- Regex heuristic di moderation.repository.ts (scam domain extraction) — ini BE task, catat tapi jangan sentuh kecuali diminta.
|
||||||
@@ -12,7 +12,6 @@
|
|||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@orpc/client": "1.15.0",
|
"@orpc/client": "1.15.0",
|
||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"d3-force": "^3.0.0",
|
|
||||||
"lucide-react": "^1.27.0",
|
"lucide-react": "^1.27.0",
|
||||||
"motion": "^12.0.0",
|
"motion": "^12.0.0",
|
||||||
"next": "16.2.12",
|
"next": "16.2.12",
|
||||||
@@ -27,8 +26,6 @@
|
|||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "2.2.0",
|
"@biomejs/biome": "2.2.0",
|
||||||
"@tailwindcss/postcss": "^4",
|
"@tailwindcss/postcss": "^4",
|
||||||
"@types/bun": "^1.4.0",
|
|
||||||
"@types/d3-force": "^3.0.10",
|
|
||||||
"@types/node": "^20",
|
"@types/node": "^20",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
|
|||||||
Generated
-56
@@ -14,9 +14,6 @@ importers:
|
|||||||
clsx:
|
clsx:
|
||||||
specifier: ^2.1.1
|
specifier: ^2.1.1
|
||||||
version: 2.1.1
|
version: 2.1.1
|
||||||
d3-force:
|
|
||||||
specifier: ^3.0.0
|
|
||||||
version: 3.0.0
|
|
||||||
lucide-react:
|
lucide-react:
|
||||||
specifier: ^1.27.0
|
specifier: ^1.27.0
|
||||||
version: 1.28.0(react@19.2.4)
|
version: 1.28.0(react@19.2.4)
|
||||||
@@ -54,12 +51,6 @@ importers:
|
|||||||
'@tailwindcss/postcss':
|
'@tailwindcss/postcss':
|
||||||
specifier: ^4
|
specifier: ^4
|
||||||
version: 4.3.3
|
version: 4.3.3
|
||||||
'@types/bun':
|
|
||||||
specifier: ^1.4.0
|
|
||||||
version: 1.4.0
|
|
||||||
'@types/d3-force':
|
|
||||||
specifier: ^3.0.10
|
|
||||||
version: 3.0.10
|
|
||||||
'@types/node':
|
'@types/node':
|
||||||
specifier: ^20
|
specifier: ^20
|
||||||
version: 20.19.43
|
version: 20.19.43
|
||||||
@@ -576,12 +567,6 @@ packages:
|
|||||||
'@tweenjs/tween.js@23.1.3':
|
'@tweenjs/tween.js@23.1.3':
|
||||||
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
resolution: {integrity: sha512-vJmvvwFxYuGnF2axRtPYocag6Clbb5YS7kLL+SO/TeVFzHqDIWrNKYtcsPMibjDx9O+bu+psAy9NKfWklassUA==}
|
||||||
|
|
||||||
'@types/bun@1.4.0':
|
|
||||||
resolution: {integrity: sha512-K+lZULY23vRgK/CfTjFIV+tyifaNdSMlPh9j+6mQ/cLfpOznLyAuzgV/JQysyECpkBQLVMSyvjlr2fBUSA9wFQ==}
|
|
||||||
|
|
||||||
'@types/d3-force@3.0.10':
|
|
||||||
resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==}
|
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
resolution: {integrity: sha512-6oYBAi5ikg4Pl+kGsoYtawUMBT2zZMCvPNF7pVLnHZfd1zf38DRiWn/gT01RYCdUqkv7Fhr+C9ot4/tb+2sVvA==}
|
||||||
|
|
||||||
@@ -626,9 +611,6 @@ packages:
|
|||||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||||
hasBin: true
|
hasBin: true
|
||||||
|
|
||||||
bun-types@1.4.0:
|
|
||||||
resolution: {integrity: sha512-iIKw23BspnQQYd3prITOBxeUsxBHnwzX6YJfGMuNOZzeNcMmVqzIIVGRm1l69ogaPQmb4wB6BN8mA5bE9YuC5Q==}
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806:
|
caniuse-lite@1.0.30001806:
|
||||||
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
resolution: {integrity: sha512-72Cuvd95zbSYPKq6Fhg8eDJRlzgWDf7/mtoZv6Qe/DYNCEBdNxoA3+rZAU2ZhGCpZlns3EssFavaZomckT5Uuw==}
|
||||||
|
|
||||||
@@ -655,22 +637,6 @@ packages:
|
|||||||
csstype@3.2.3:
|
csstype@3.2.3:
|
||||||
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
resolution: {integrity: sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==}
|
||||||
|
|
||||||
d3-dispatch@3.0.1:
|
|
||||||
resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
d3-force@3.0.0:
|
|
||||||
resolution: {integrity: sha512-zxV/SsA+U4yte8051P4ECydjD/S+qeYtnaIyAs9tgHCqfguma/aAQDjo85A9Z6EKhBirHRJHXIgJUlffT4wdLg==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
d3-quadtree@3.0.1:
|
|
||||||
resolution: {integrity: sha512-04xDrxQTDTCFwP5H6hRhsRcb9xxv2RzkcsygFzmkSIOJy3PeRJP7sNk3VRIbKXcog561P9oU0/rVH6vDROAgUw==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
d3-timer@3.0.1:
|
|
||||||
resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==}
|
|
||||||
engines: {node: '>=12'}
|
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
resolution: {integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==}
|
||||||
engines: {node: '>=6.0'}
|
engines: {node: '>=6.0'}
|
||||||
@@ -1495,12 +1461,6 @@ snapshots:
|
|||||||
|
|
||||||
'@tweenjs/tween.js@23.1.3': {}
|
'@tweenjs/tween.js@23.1.3': {}
|
||||||
|
|
||||||
'@types/bun@1.4.0':
|
|
||||||
dependencies:
|
|
||||||
bun-types: 1.4.0
|
|
||||||
|
|
||||||
'@types/d3-force@3.0.10': {}
|
|
||||||
|
|
||||||
'@types/node@20.19.43':
|
'@types/node@20.19.43':
|
||||||
dependencies:
|
dependencies:
|
||||||
undici-types: 6.21.0
|
undici-types: 6.21.0
|
||||||
@@ -1548,10 +1508,6 @@ snapshots:
|
|||||||
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
update-browserslist-db: 1.2.3(browserslist@4.28.7)
|
||||||
optional: true
|
optional: true
|
||||||
|
|
||||||
bun-types@1.4.0:
|
|
||||||
dependencies:
|
|
||||||
'@types/node': 20.19.43
|
|
||||||
|
|
||||||
caniuse-lite@1.0.30001806: {}
|
caniuse-lite@1.0.30001806: {}
|
||||||
|
|
||||||
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
chromium-bidi@17.0.2(devtools-protocol@0.0.1666840):
|
||||||
@@ -1575,18 +1531,6 @@ snapshots:
|
|||||||
|
|
||||||
csstype@3.2.3: {}
|
csstype@3.2.3: {}
|
||||||
|
|
||||||
d3-dispatch@3.0.1: {}
|
|
||||||
|
|
||||||
d3-force@3.0.0:
|
|
||||||
dependencies:
|
|
||||||
d3-dispatch: 3.0.1
|
|
||||||
d3-quadtree: 3.0.1
|
|
||||||
d3-timer: 3.0.1
|
|
||||||
|
|
||||||
d3-quadtree@3.0.1: {}
|
|
||||||
|
|
||||||
d3-timer@3.0.1: {}
|
|
||||||
|
|
||||||
debug@4.4.3:
|
debug@4.4.3:
|
||||||
dependencies:
|
dependencies:
|
||||||
ms: 2.1.3
|
ms: 2.1.3
|
||||||
|
|||||||
@@ -1,72 +1,24 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
|
||||||
* Analysis scene — semantic search lives in a floating console (left);
|
import { useEffect, useState } from "react";
|
||||||
* results stream into a translucent dossier (right). The stage shows an
|
|
||||||
* analysis hub wired to the guild's top channels.
|
|
||||||
*/
|
|
||||||
import { Hash, Search, Sparkles } from "lucide-react";
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Avatar, Badge } from "@/components/primitives";
|
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
|
||||||
import { EmptyState, SkeletonRows } from "@/components/shared";
|
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
|
||||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
|
||||||
import { useChannels, useMessageSearch } from "@/hooks";
|
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
import {
|
||||||
formatDuration,
|
formatDuration,
|
||||||
getMessageChannelLabel,
|
getMessageChannelLabel,
|
||||||
renderMessageContent,
|
renderMessageContent,
|
||||||
} from "@/lib/format";
|
} from "@/lib/format";
|
||||||
|
|
||||||
function analysisGraph(
|
|
||||||
channels:
|
|
||||||
| {
|
|
||||||
channel_id: string;
|
|
||||||
channel_name?: string | null;
|
|
||||||
total_messages: number;
|
|
||||||
}[]
|
|
||||||
| undefined,
|
|
||||||
): ConstellationGraph {
|
|
||||||
const rows = (channels ?? []).slice(0, 10);
|
|
||||||
const maxMsg = rows.reduce((m, c) => Math.max(m, c.total_messages), 0);
|
|
||||||
const clamp = (n: number) => (maxMsg <= 0 ? 0.3 : Math.max(0.15, n / maxMsg));
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{ id: "hub", label: "analysis", kind: "guild", value: 1 },
|
|
||||||
...rows.map((c) => ({
|
|
||||||
id: `channel:${c.channel_id}`,
|
|
||||||
label: c.channel_name ?? c.channel_id.slice(0, 8),
|
|
||||||
kind: "channel" as const,
|
|
||||||
href: "/channels/",
|
|
||||||
value: clamp(c.total_messages),
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
edges: rows.map((c) => ({
|
|
||||||
source: "hub",
|
|
||||||
target: `channel:${c.channel_id}`,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AnalysisView() {
|
export function AnalysisView() {
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const search = useMessageSearch(query, query.trim().length >= 2);
|
const search = useMessageSearch(query, query.trim().length >= 2);
|
||||||
|
const { data: reactors } = useTopReactors();
|
||||||
const { data: channels } = useChannels();
|
const { data: channels } = useChannels();
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
|
|
||||||
const graph = useMemo(() => analysisGraph(channels), [channels]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => publish({ graph: { nodes: [], edges: [] }, focus: null }),
|
|
||||||
[publish],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -77,50 +29,52 @@ export function AnalysisView() {
|
|||||||
}, [query, ambient]);
|
}, [query, ambient]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Search console — left */}
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
<section
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-[min(26rem,88vw)]"
|
<div className="flex items-center gap-3">
|
||||||
aria-label="Semantic search"
|
<Sparkles className="size-5 text-signal" />
|
||||||
>
|
<div>
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
<div className="eyebrow">Semantic search</div>
|
||||||
<Sparkles className="size-3.5 text-signal" /> Search the archive
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
</p>
|
Search the archive
|
||||||
<div className="relative">
|
</h2>
|
||||||
<Search className="absolute left-4 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
</div>
|
||||||
<input
|
</div>
|
||||||
type="search"
|
<div className="relative mt-4">
|
||||||
|
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
|
||||||
|
<Input
|
||||||
|
className="h-12 pl-12 text-base"
|
||||||
|
placeholder="Find messages, patterns, flags…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Find messages, patterns, flags…"
|
|
||||||
autoFocus
|
autoFocus
|
||||||
className="h-11 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-11 pr-4 text-sm text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{query.trim().length > 0 && query.trim().length < 2 ? (
|
{query.trim().length > 0 && query.trim().length < 2 && (
|
||||||
<p className="mt-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
<div className="mono mt-2 text-xs text-ink-faint">
|
||||||
minimal 2 karakter…
|
Type at least 2 characters…
|
||||||
</p>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Results dossier — right */}
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
<section
|
<GlassPanel className="lg:col-span-3">
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 hidden max-h-[62vh] w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 backdrop-blur-xl md:flex"
|
<SectionHeader
|
||||||
aria-label="Search results"
|
eyebrow="results"
|
||||||
>
|
title="Matches"
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
action={
|
||||||
<span className="eyebrow">matches</span>
|
<span className="mono text-xs text-ink-faint">
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{(search.data ?? []).length}
|
{(search.data ?? []).length}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="overflow-y-auto p-3">
|
/>
|
||||||
{query.trim().length >= 2 && search.isLoading ? (
|
{query.trim().length >= 2 && search.isLoading && (
|
||||||
<SkeletonRows rows={5} />
|
<SkeletonRows rows={6} />
|
||||||
) : (search.data ?? []).length === 0 ? (
|
)}
|
||||||
|
{(search.data ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Search className="size-6" />}
|
icon={<Search className="size-7" />}
|
||||||
title="No matches yet"
|
title="No matches yet"
|
||||||
description="Run a search to surface messages across the guild."
|
description="Run a search to surface messages across the guild."
|
||||||
/>
|
/>
|
||||||
@@ -129,27 +83,27 @@ export function AnalysisView() {
|
|||||||
{(search.data ?? []).map((m) => (
|
{(search.data ?? []).map((m) => (
|
||||||
<div
|
<div
|
||||||
key={m.id}
|
key={m.id}
|
||||||
className="flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-2.5"
|
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={28} />
|
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
<span className="text-sm font-semibold text-ink">
|
||||||
{m.username}
|
{m.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
{m.ai_status ? (
|
{m.ai_status && (
|
||||||
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
|
||||||
{m.ai_analysis_duration_ms &&
|
{m.ai_analysis_duration_ms &&
|
||||||
m.ai_analysis_duration_ms > 0
|
m.ai_analysis_duration_ms > 0
|
||||||
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
|
||||||
: m.ai_status}
|
: m.ai_status}
|
||||||
</Badge>
|
</Badge>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 text-sm text-ink-soft">
|
||||||
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
{renderMessageContent(m.content, m.metadata) || "(embed)"}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -157,14 +111,86 @@ export function AnalysisView() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Channels hint — bottom-left */}
|
<div className="space-y-5 lg:col-span-2">
|
||||||
<p className="pointer-events-none absolute bottom-24 left-5 hidden items-center gap-1.5 font-mono text-xs text-[var(--color-ink-faint)] lg:flex">
|
<GlassPanel>
|
||||||
<Hash className="size-3.5" /> {(channels ?? []).length} channels
|
<SectionHeader
|
||||||
terhubung ke hub analitik — klik untuk membuka konstelasi channel
|
eyebrow="culture"
|
||||||
</p>
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<TrendingUp className="size-4 text-signal" /> Top reactors
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||||
|
const maxNet = reactors?.[0]?.net_count || 1;
|
||||||
|
const pct = Math.max(
|
||||||
|
4,
|
||||||
|
Math.round((r.net_count / maxNet) * 100),
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
key={r.user_id}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
|
||||||
|
{r.username}
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
|
+{r.net_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(reactors ?? []).length === 0 && (
|
||||||
|
<div className="py-4 text-center text-xs text-ink-faint">
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="channels"
|
||||||
|
title={
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<Hash className="size-4 text-signal" /> Top channels
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(channels ?? []).slice(0, 6).map((c) => (
|
||||||
|
<div
|
||||||
|
key={c.channel_id}
|
||||||
|
className="flex items-center gap-3 text-sm"
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate text-ink-soft">
|
||||||
|
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{c.total_messages}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(channels ?? []).length === 0 && (
|
||||||
|
<div className="py-4 text-center text-xs text-ink-faint">
|
||||||
|
No data
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,18 +1,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import { ChannelCultureGlossary } from "@/components/ChannelCultureGlossary";
|
||||||
* Channels scene — every channel is a star on the stage; clicking a star
|
|
||||||
* opens its floating culture dossier here. Search filters the sky.
|
|
||||||
*/
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { SkeletonPanel } from "@/components/shared";
|
import { SkeletonPanel } from "@/components/shared";
|
||||||
import {
|
|
||||||
useSceneFocusSetter,
|
|
||||||
useSceneGraph,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import { useChannelCultures } from "@/hooks";
|
import { useChannelCultures } from "@/hooks";
|
||||||
import { culturesToGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { ChannelCultureRow } from "@/lib/types";
|
import type { ChannelCultureRow } from "@/lib/types";
|
||||||
|
|
||||||
export function ChannelsView({
|
export function ChannelsView({
|
||||||
@@ -21,92 +11,12 @@ export function ChannelsView({
|
|||||||
initialCultures?: ChannelCultureRow[];
|
initialCultures?: ChannelCultureRow[];
|
||||||
}) {
|
}) {
|
||||||
const { data: cultures } = useChannelCultures(100, initialCultures);
|
const { data: cultures } = useChannelCultures(100, initialCultures);
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
const { state } = useSceneGraph();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const rows = cultures ?? [];
|
|
||||||
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return rows;
|
|
||||||
return rows.filter(
|
|
||||||
(r) =>
|
|
||||||
(r.channel_name ?? r.channel_id).toLowerCase().includes(q) ||
|
|
||||||
(r.culture_summary ?? "").toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [rows, query]);
|
|
||||||
|
|
||||||
const graph = useMemo(() => culturesToGraph(filtered), [filtered]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(() => () => setFocus(null), [setFocus]);
|
|
||||||
|
|
||||||
const selectedId = state?.focus ?? null;
|
|
||||||
const selectedRow = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedId
|
|
||||||
? (rows.find((r) => `channel:${r.channel_id}` === selectedId) ?? null)
|
|
||||||
: null,
|
|
||||||
[rows, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Search whisper — top-left */}
|
{cultures ? (
|
||||||
<section
|
<ChannelCultureGlossary cultures={cultures} />
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64"
|
|
||||||
aria-label="Filter channels"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">Channels · {filtered.length} mapped</p>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="filter the sky…"
|
|
||||||
className="w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-3.5 py-1.5 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
|
|
||||||
/>
|
|
||||||
{!cultures ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<SkeletonPanel rows={3} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Culture dossier for the selected star */}
|
|
||||||
{selectedRow ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 w-[min(34rem,92vw)] -translate-x-1/2 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-4 backdrop-blur-xl md:left-auto md:right-5 md:translate-x-0"
|
|
||||||
aria-label={`Culture of ${selectedRow.channel_name ?? selectedRow.channel_id}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setFocus(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
<p className="eyebrow">channel culture</p>
|
|
||||||
<h3 className="display mt-1 text-xl leading-tight text-ink glow-signal">
|
|
||||||
{selectedRow.channel_name ?? selectedRow.channel_id}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-h-40 overflow-y-auto text-sm text-pretty text-ink-soft">
|
|
||||||
{selectedRow.culture_summary ?? "Belum dianalisis."}
|
|
||||||
</p>
|
|
||||||
<p className="mt-2 font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
|
||||||
{selectedRow.last_analyzed_at
|
|
||||||
? `analyzed ${new Date(selectedRow.last_analyzed_at).toLocaleString()}`
|
|
||||||
: "never analyzed"}
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
) : (
|
) : (
|
||||||
<p className="pointer-events-none absolute inset-x-0 bottom-24 hidden justify-center font-mono text-xs text-[var(--color-ink-faint)] md:flex">
|
<SkeletonPanel rows={6} />
|
||||||
klik sebuah bintang untuk membuka culture dossier
|
|
||||||
</p>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,18 +1,32 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
import {
|
||||||
* Dashboard scene — guild star + channel orbit live on the stage canvas;
|
Activity,
|
||||||
* this overlay adds the metric whisper (top-left), activity ribbon
|
Flag,
|
||||||
* (bottom-left), and moderation gauge (right) as floating panels.
|
MessageSquare,
|
||||||
*/
|
Mic,
|
||||||
import { Activity, Flag, ShieldAlert } from "lucide-react";
|
Radio,
|
||||||
import { useEffect, useMemo } from "react";
|
ShieldAlert,
|
||||||
|
Users,
|
||||||
|
} from "lucide-react";
|
||||||
|
import { useEffect } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { AreaActivity, RadialGauge } from "@/components/charts";
|
import { AreaActivity, RadialGauge } from "@/components/charts";
|
||||||
import { ErrorState, LoadingState } from "@/components/shared";
|
import { GlassPanel } from "@/components/primitives";
|
||||||
import { useScenePublish } from "@/components/shell/scene-graph-context";
|
import {
|
||||||
import { useActivity, useStats } from "@/hooks";
|
ErrorState,
|
||||||
import { statsToGraph } from "@/lib/constellation/graph";
|
LoadingState,
|
||||||
|
SkeletonHero,
|
||||||
|
SkeletonMetricRow,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { MetricTile, SectionHeader } from "@/components/shared/section";
|
||||||
|
import {
|
||||||
|
useActivity,
|
||||||
|
useStats,
|
||||||
|
useTopReactions,
|
||||||
|
useTopReactors,
|
||||||
|
} from "@/hooks";
|
||||||
import { formatNumber } from "@/lib/format";
|
import { formatNumber } from "@/lib/format";
|
||||||
import type { DashboardStats } from "@/lib/types";
|
import type { DashboardStats } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
@@ -36,19 +50,16 @@ export function DashboardView({
|
|||||||
initialStats?: DashboardStats;
|
initialStats?: DashboardStats;
|
||||||
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
initialActivity?: Awaited<ReturnType<typeof useActivity>>["data"];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, error } = useStats(initialStats);
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate: mutateStats,
|
||||||
|
} = useStats(initialStats);
|
||||||
const { data: activity } = useActivity(14, initialActivity as never);
|
const { data: activity } = useActivity(14, initialActivity as never);
|
||||||
|
const { data: reactors } = useTopReactors();
|
||||||
|
const { data: reactions } = useTopReactions();
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
|
|
||||||
const graph = useMemo(
|
|
||||||
() => (stats ? statsToGraph(stats) : { nodes: [], edges: [] }),
|
|
||||||
[stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: "guild" });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const s = deriveSignal(stats);
|
const s = deriveSignal(stats);
|
||||||
@@ -59,70 +70,150 @@ export function DashboardView({
|
|||||||
);
|
);
|
||||||
}, [stats, ambient]);
|
}, [stats, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats)
|
||||||
if (!stats) return <LoadingState label="aligning constellation" />;
|
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||||
|
if (!stats && isLoading)
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SkeletonHero />
|
||||||
|
<SkeletonMetricRow cols={4} />
|
||||||
|
<SkeletonPanel rows={5} />
|
||||||
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
|
<SkeletonPanel className="lg:col-span-3" rows={4} />
|
||||||
|
<SkeletonPanel className="lg:col-span-2" rows={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!stats)
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
error={error ?? new Error("No data")}
|
||||||
|
onRetry={() => void mutateStats()}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
|
||||||
const s = stats;
|
const s = stats;
|
||||||
const total = s.total_flagged + s.total_clean || 1;
|
const total = s.total_flagged + s.total_clean || 1;
|
||||||
const cleanRatio = s.total_clean / total;
|
const cleanRatio = s.total_clean / total;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Metric whisper — top-left under brand */}
|
{/* Hero */}
|
||||||
<section
|
<GlassPanel glow className="game-frame relative overflow-hidden">
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64 animate-stagger"
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
style={staggerDelay(0)}
|
<div className="flex flex-wrap items-end justify-between gap-4">
|
||||||
aria-label="Key metrics"
|
<div>
|
||||||
>
|
<div className="eyebrow mb-2">GMW · Operations Grid</div>
|
||||||
<p className="eyebrow mb-2">Operations · {deriveSignal(s).label}</p>
|
<h2 className="display hero-clamp leading-none text-ink glow-signal">
|
||||||
<div className="space-y-1.5">
|
Ambient Field
|
||||||
<WhisperRow
|
</h2>
|
||||||
label="messages"
|
<p className="mt-2 max-w-md text-pretty text-sm text-ink-soft">
|
||||||
|
Real-time moderation, voice & media presence across the monitored
|
||||||
|
guild. {formatNumber(s.total_messages)} messages captured.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-ink-soft">
|
||||||
|
<Radio className="size-4 text-signal animate-breathe" />
|
||||||
|
<span className="mono text-xs uppercase tracking-wider">
|
||||||
|
{deriveSignal(s).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
|
<MetricTile
|
||||||
|
label="Messages"
|
||||||
value={formatNumber(s.total_messages)}
|
value={formatNumber(s.total_messages)}
|
||||||
tone="ink"
|
tone="signal"
|
||||||
|
icon={<MessageSquare className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(0)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="flagged"
|
label="Flagged"
|
||||||
value={formatNumber(s.total_flagged)}
|
value={formatNumber(s.total_flagged)}
|
||||||
tone={s.total_flagged > 0 ? "vermilion" : "ink"}
|
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
|
||||||
hint={`${s.today_flagged} today`}
|
hint={`${s.today_flagged} today`}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(1)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="active 24h"
|
label="Active 24h"
|
||||||
value={formatNumber(s.active_users_24h)}
|
value={formatNumber(s.active_users_24h)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
|
icon={<Users className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(2)}
|
||||||
/>
|
/>
|
||||||
<WhisperRow
|
<MetricTile
|
||||||
label="voice clips"
|
label="Voice clips"
|
||||||
value={formatNumber(s.total_voice_recordings)}
|
value={formatNumber(s.total_voice_recordings)}
|
||||||
tone="ink"
|
icon={<Mic className="size-3.5" />}
|
||||||
|
className="animate-stagger"
|
||||||
|
style={staggerDelay(3)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Activity ribbon — bottom-left */}
|
{/* Activity */}
|
||||||
<section
|
<GlassPanel>
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 hidden w-80 lg:block"
|
<SectionHeader
|
||||||
aria-label="Activity"
|
eyebrow="14-day signal"
|
||||||
>
|
title={
|
||||||
<p className="eyebrow mb-1 flex items-center gap-1.5">
|
<span className="flex items-center gap-2">
|
||||||
<Activity className="size-3.5 text-signal" /> 14-day signal
|
<Activity className="size-4 text-signal" /> Activity & moderation
|
||||||
</p>
|
</span>
|
||||||
|
}
|
||||||
|
action={
|
||||||
|
<div className="flex items-center gap-3 text-xs text-ink-soft">
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-signal" /> messages
|
||||||
|
</span>
|
||||||
|
<span className="flex items-center gap-1.5">
|
||||||
|
<span className="size-2 rounded-full bg-vermilion" /> flagged
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
/>
|
||||||
{activity ? (
|
{activity ? (
|
||||||
<AreaActivity daily={activity.daily} />
|
<AreaActivity daily={activity.daily} />
|
||||||
) : (
|
) : (
|
||||||
<LoadingState label="streaming" />
|
<LoadingState label="streaming" />
|
||||||
)}
|
)}
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Moderation gauge — right */}
|
{/* Two-column: channels + moderation */}
|
||||||
<section
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
className="pointer-events-auto absolute right-5 top-16 hidden w-56 md:block"
|
<GlassPanel className="lg:col-span-3">
|
||||||
aria-label="Moderation"
|
<SectionHeader eyebrow="throughput" title="Top channels" />
|
||||||
>
|
<div className="space-y-2.5">
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
{s.top_channels.slice(0, 7).map((c) => {
|
||||||
<ShieldAlert className="size-3.5 text-signal" /> Trust
|
const pct =
|
||||||
</p>
|
(c.message_count / (s.top_channels[0]?.message_count || 1)) *
|
||||||
|
100;
|
||||||
|
return (
|
||||||
|
<div key={c.channel_id} className="flex items-center gap-3">
|
||||||
|
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||||
|
{c.channel_name ?? c.channel_id.slice(0, 8)}
|
||||||
|
</span>
|
||||||
|
<div className="h-2 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-14 text-right text-xs text-ink-faint">
|
||||||
|
{formatNumber(c.message_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader eyebrow="trust" title="Moderation" />
|
||||||
|
<div className="flex items-center gap-5">
|
||||||
<RadialGauge
|
<RadialGauge
|
||||||
value={cleanRatio}
|
value={cleanRatio}
|
||||||
tone={
|
tone={
|
||||||
@@ -135,59 +226,155 @@ export function DashboardView({
|
|||||||
label={`${Math.round(cleanRatio * 100)}%`}
|
label={`${Math.round(cleanRatio * 100)}%`}
|
||||||
sublabel="clean"
|
sublabel="clean"
|
||||||
/>
|
/>
|
||||||
<div className="mt-3 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
<div className="flex-1 space-y-2 text-sm">
|
||||||
<p className="flex items-center justify-between">
|
<Row
|
||||||
<span className="inline-flex items-center gap-1.5">
|
icon={<ShieldAlert className="size-4 text-signal" />}
|
||||||
<Flag className="size-3 text-vermilion" /> flagged
|
label="Clean"
|
||||||
</span>
|
value={formatNumber(s.total_clean)}
|
||||||
<span>{formatNumber(s.total_flagged)}</span>
|
/>
|
||||||
</p>
|
<Row
|
||||||
<p className="flex items-center justify-between">
|
icon={<Flag className="size-4 text-vermilion" />}
|
||||||
<span>warned</span>
|
label="Flagged"
|
||||||
<span>{formatNumber(s.total_warned)}</span>
|
value={formatNumber(s.total_flagged)}
|
||||||
</p>
|
/>
|
||||||
<p className="flex items-center justify-between">
|
<Row
|
||||||
<span>pending</span>
|
icon={<Activity className="size-4 text-amber" />}
|
||||||
<span>{s.moderation_overview.pending}</span>
|
label="Warned"
|
||||||
</p>
|
value={formatNumber(s.total_warned)}
|
||||||
<p className="flex items-center justify-between">
|
/>
|
||||||
<span>processing</span>
|
</div>
|
||||||
<span>{s.moderation_overview.processing}</span>
|
</div>
|
||||||
</p>
|
<div className="mt-4 flex items-center justify-around border-t border-hairline pt-3 text-center">
|
||||||
|
<Mini
|
||||||
|
label="pending"
|
||||||
|
value={s.moderation_overview.pending}
|
||||||
|
tone="amber"
|
||||||
|
/>
|
||||||
|
<Mini
|
||||||
|
label="processing"
|
||||||
|
value={s.moderation_overview.processing}
|
||||||
|
tone="signal"
|
||||||
|
/>
|
||||||
|
<Mini
|
||||||
|
label="errors"
|
||||||
|
value={s.moderation_overview.error}
|
||||||
|
tone="vermilion"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Reactors + reactions */}
|
||||||
|
<div className="grid gap-5 lg:grid-cols-2">
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="engagement" title="Top reactors" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(reactors ?? []).slice(0, 6).map((r, i) => {
|
||||||
|
const maxNet = reactors?.[0]?.net_count || 1;
|
||||||
|
const pct = Math.max(4, Math.round((r.net_count / maxNet) * 100));
|
||||||
|
return (
|
||||||
|
<div key={r.user_id} className="flex items-center gap-3">
|
||||||
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
|
<span className="w-28 shrink-0 truncate text-sm text-ink-soft sm:w-40">
|
||||||
|
{r.username}
|
||||||
|
</span>
|
||||||
|
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
|
||||||
|
<div
|
||||||
|
className="h-full rounded-full bg-signal/70"
|
||||||
|
style={{ width: `${pct}%` }}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<span className="mono w-12 text-right text-xs text-signal">
|
||||||
|
+{formatNumber(r.net_count)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{(reactors ?? []).length === 0 && <EmptyHint />}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="culture" title="Top reactions" />
|
||||||
|
<div className="space-y-3">
|
||||||
|
{(reactions ?? []).slice(0, 5).map((m) => (
|
||||||
|
<div key={m.message_id} className="flex items-start gap-3">
|
||||||
|
<div className="flex flex-wrap gap-1 pt-0.5">
|
||||||
|
{m.top_emojis.slice(0, 3).map((e, i) => (
|
||||||
|
<span
|
||||||
|
key={`${m.message_id}-${i}`}
|
||||||
|
className="text-lg leading-none"
|
||||||
|
>
|
||||||
|
{e.emoji}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="truncate text-sm text-ink">
|
||||||
|
{m.content || "(no text)"}
|
||||||
|
</div>
|
||||||
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
|
{m.username} · {m.channel_name ?? m.channel_id.slice(0, 8)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="mono text-xs text-ink-soft">
|
||||||
|
{m.reaction_count}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(reactions ?? []).length === 0 && <EmptyHint />}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function WhisperRow({
|
function Row({
|
||||||
|
icon,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
}: {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
label: string;
|
||||||
|
value: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2.5">
|
||||||
|
{icon}
|
||||||
|
<span className="flex-1 text-ink-soft">{label}</span>
|
||||||
|
<span className="mono text-ink">{value}</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function Mini({
|
||||||
label,
|
label,
|
||||||
value,
|
value,
|
||||||
tone,
|
tone,
|
||||||
hint,
|
|
||||||
}: {
|
}: {
|
||||||
label: string;
|
label: string;
|
||||||
value: string;
|
value: number;
|
||||||
tone: "ink" | "signal" | "vermilion";
|
tone: "signal" | "amber" | "vermilion";
|
||||||
hint?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const color =
|
const color =
|
||||||
tone === "vermilion"
|
tone === "vermilion"
|
||||||
? "text-vermilion"
|
? "text-vermilion"
|
||||||
: tone === "signal"
|
: tone === "amber"
|
||||||
? "text-signal"
|
? "text-amber"
|
||||||
: "text-ink";
|
: "text-signal";
|
||||||
return (
|
return (
|
||||||
<p className="flex items-baseline gap-2">
|
<div>
|
||||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
<div className={`display text-xl ${color}`}>{value}</div>
|
||||||
{label}
|
<div className="eyebrow mt-0.5">{label}</div>
|
||||||
</span>
|
</div>
|
||||||
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
);
|
||||||
{hint ? (
|
}
|
||||||
<span className="font-mono text-[10px] text-[var(--color-ink-faint)]">
|
|
||||||
{hint}
|
function EmptyHint() {
|
||||||
</span>
|
return (
|
||||||
) : null}
|
<div className="py-6 text-center text-xs text-ink-faint">
|
||||||
</p>
|
Awaiting data…
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,118 +1,15 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Glossary scene — term KB as a satellite ring on the stage; the search
|
|
||||||
* whisper filters nodes live, and a term's definition floats in a dossier.
|
|
||||||
*/
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import { SkeletonPanel } from "@/components/shared";
|
import { SkeletonPanel } from "@/components/shared";
|
||||||
import {
|
import { TermGlossary } from "@/components/TermGlossary";
|
||||||
useSceneFocusSetter,
|
|
||||||
useSceneGraph,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import { useGlossary } from "@/hooks";
|
import { useGlossary } from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { GlossaryRow } from "@/lib/types";
|
import type { GlossaryRow } from "@/lib/types";
|
||||||
|
|
||||||
/** Terms → satellite ring (deterministic positions come from layout). */
|
|
||||||
function termsToGraph(rows: GlossaryRow[]): ConstellationGraph {
|
|
||||||
return {
|
|
||||||
nodes: rows.slice(0, 60).map((t) => ({
|
|
||||||
id: `term:${t.term}`,
|
|
||||||
label: t.term,
|
|
||||||
kind: "term" as const,
|
|
||||||
value: Math.min(1, 0.3 + t.definition.length / 400),
|
|
||||||
})),
|
|
||||||
edges: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function GlossaryView({
|
export function GlossaryView({
|
||||||
initialTerms,
|
initialTerms,
|
||||||
}: {
|
}: {
|
||||||
initialTerms?: GlossaryRow[];
|
initialTerms?: GlossaryRow[];
|
||||||
}) {
|
}) {
|
||||||
const { data: terms } = useGlossary(100, initialTerms);
|
const { data: terms } = useGlossary(100, initialTerms);
|
||||||
const publish = useScenePublish();
|
return terms ? <TermGlossary terms={terms} /> : <SkeletonPanel rows={6} />;
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
const { state } = useSceneGraph();
|
|
||||||
const [query, setQuery] = useState("");
|
|
||||||
|
|
||||||
const rows = terms ?? [];
|
|
||||||
const filtered = useMemo(() => {
|
|
||||||
const q = query.trim().toLowerCase();
|
|
||||||
if (!q) return rows;
|
|
||||||
return rows.filter(
|
|
||||||
(t) =>
|
|
||||||
t.term.toLowerCase().includes(q) ||
|
|
||||||
t.definition.toLowerCase().includes(q),
|
|
||||||
);
|
|
||||||
}, [rows, query]);
|
|
||||||
|
|
||||||
const graph = useMemo(() => termsToGraph(filtered), [filtered]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(() => () => setFocus(null), [setFocus]);
|
|
||||||
|
|
||||||
const selectedId = state?.focus ?? null;
|
|
||||||
const selectedTerm = useMemo(
|
|
||||||
() =>
|
|
||||||
selectedId
|
|
||||||
? (rows.find((t) => `term:${t.term}` === selectedId) ?? null)
|
|
||||||
: null,
|
|
||||||
[rows, selectedId],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="min-h-full">
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-64"
|
|
||||||
aria-label="Filter terms"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">Glossary · {filtered.length} terms</p>
|
|
||||||
<input
|
|
||||||
type="search"
|
|
||||||
value={query}
|
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
|
||||||
placeholder="filter terms…"
|
|
||||||
className="w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-3.5 py-1.5 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
|
|
||||||
/>
|
|
||||||
{!terms ? (
|
|
||||||
<div className="mt-3">
|
|
||||||
<SkeletonPanel rows={3} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{selectedTerm ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 w-[min(34rem,92vw)] -translate-x-1/2 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-4 backdrop-blur-xl md:left-auto md:right-5 md:translate-x-0"
|
|
||||||
aria-label={`Definition of ${selectedTerm.term}`}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setFocus(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
<p className="eyebrow">term kb</p>
|
|
||||||
<h3 className="display mt-1 text-xl leading-tight text-ink glow-signal">
|
|
||||||
{selectedTerm.term}
|
|
||||||
</h3>
|
|
||||||
<p className="mt-2 max-h-40 overflow-y-auto text-sm text-pretty text-ink-soft">
|
|
||||||
{selectedTerm.definition}
|
|
||||||
</p>
|
|
||||||
</aside>
|
|
||||||
) : (
|
|
||||||
<p className="pointer-events-none absolute inset-x-0 bottom-24 hidden justify-center font-mono text-xs text-[var(--color-ink-faint)] md:flex">
|
|
||||||
klik sebuah satelit untuk membuka definisi
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
import { AmbientProvider } from "@/components/ambient/ambient-context";
|
||||||
import { Chatbot } from "@/components/chatbot/chatbot";
|
import { Chatbot } from "@/components/chatbot/chatbot";
|
||||||
import { CommandPalette } from "@/components/command/command-palette";
|
import { CommandPalette } from "@/components/command/command-palette";
|
||||||
import { ConstellationFrame } from "@/components/shell";
|
import { AppFrame } from "@/components/shell";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
|
|
||||||
export default function DashboardLayout({
|
export default function DashboardLayout({
|
||||||
@@ -10,7 +10,7 @@ export default function DashboardLayout({
|
|||||||
return (
|
return (
|
||||||
<AmbientProvider>
|
<AmbientProvider>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<ConstellationFrame>{children}</ConstellationFrame>
|
<AppFrame>{children}</AppFrame>
|
||||||
<Chatbot />
|
<Chatbot />
|
||||||
<CommandPalette />
|
<CommandPalette />
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Media scene — the queue becomes a spiral of nodes on the stage; the
|
|
||||||
* player console (disc + transport + URL bar) floats bottom-left, and
|
|
||||||
* the up-next list docks right.
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
ListMusic,
|
ListMusic,
|
||||||
Play,
|
Play,
|
||||||
@@ -14,14 +9,15 @@ import {
|
|||||||
Square,
|
Square,
|
||||||
Volume2,
|
Volume2,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Button, Input, toast } from "@/components/primitives";
|
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
|
||||||
import { ErrorState, SkeletonHero } from "@/components/shared";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
ErrorState,
|
||||||
useScenePublish,
|
SectionHeader,
|
||||||
} from "@/components/shell/scene-graph-context";
|
SkeletonHero,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
import {
|
import {
|
||||||
useMediaLoop,
|
useMediaLoop,
|
||||||
useMediaQueue,
|
useMediaQueue,
|
||||||
@@ -30,48 +26,25 @@ import {
|
|||||||
useMediaStop,
|
useMediaStop,
|
||||||
useMediaWsSync,
|
useMediaWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { formatDuration } from "@/lib/format";
|
import { formatDuration } from "@/lib/format";
|
||||||
import type { MediaState } from "@/lib/types";
|
import type { MediaState } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
function queueGraph(media: MediaState | undefined): ConstellationGraph {
|
|
||||||
const items = (media?.queue ?? []).slice(0, 24);
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{
|
|
||||||
id: "now-playing",
|
|
||||||
label: media?.current?.title ?? "media",
|
|
||||||
kind: "guild",
|
|
||||||
value: 1,
|
|
||||||
},
|
|
||||||
...items.map((it, i) => ({
|
|
||||||
id: `track:${i}:${it.source}`,
|
|
||||||
label: it.title,
|
|
||||||
kind: "media" as const,
|
|
||||||
value: Math.max(0.2, 0.8 - i * 0.05),
|
|
||||||
href: undefined,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
edges: items.map((it, i) => ({
|
|
||||||
source: "now-playing",
|
|
||||||
target: `track:${i}:${it.source}`,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: media, isLoading, error } = useMediaState(initialStatus);
|
const {
|
||||||
|
data: media,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
} = useMediaState(initialStatus);
|
||||||
const queue = useMediaQueue();
|
const queue = useMediaQueue();
|
||||||
const skip = useMediaSkip();
|
const skip = useMediaSkip();
|
||||||
const stop = useMediaStop();
|
const stop = useMediaStop();
|
||||||
const loop = useMediaLoop();
|
const loop = useMediaLoop();
|
||||||
useMediaWsSync(ws);
|
useMediaWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const [url, setUrl] = useState("");
|
const [url, setUrl] = useState("");
|
||||||
|
|
||||||
@@ -83,19 +56,6 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
0,
|
0,
|
||||||
);
|
);
|
||||||
|
|
||||||
const graph = useMemo(() => queueGraph(media), [media]);
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -124,20 +84,25 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !media) return <ErrorState error={error} />;
|
if (error && !media)
|
||||||
if (!media && isLoading) return <SkeletonHero />;
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
|
if (!media && isLoading)
|
||||||
|
return (
|
||||||
|
<div className="space-y-5">
|
||||||
|
<SkeletonHero />
|
||||||
|
<SkeletonPanel rows={4} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Now playing — top-center whisper */}
|
<GlassPanel glow className="relative overflow-hidden">
|
||||||
<section
|
<div className="scan-line absolute inset-x-0 top-0" />
|
||||||
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center px-6 md:flex"
|
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
|
||||||
aria-label="Now playing"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-[52vw] items-center gap-3 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 px-4 py-2 backdrop-blur-md">
|
|
||||||
<div
|
<div
|
||||||
className={`flex size-10 shrink-0 items-center justify-center overflow-hidden rounded-full border border-[var(--color-hairline)] ${playing ? "animate-spin-disc" : ""}`}
|
className={`flex size-32 shrink-0 items-center justify-center rounded-full border border-hairline bg-gradient-to-br from-white/10 to-white/[0.02] ${playing ? "animate-spin-disc" : "animate-spin-disc paused"}`}
|
||||||
>
|
>
|
||||||
|
<div className="flex size-28 items-center justify-center overflow-hidden rounded-full bg-canvas/60">
|
||||||
{current?.thumbnailUrl ? (
|
{current?.thumbnailUrl ? (
|
||||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
<img
|
<img
|
||||||
@@ -147,55 +112,59 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<ListMusic className="size-4 text-signal" />
|
<ListMusic className="size-10 text-signal" />
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
<div className="min-w-0">
|
</div>
|
||||||
<p className="eyebrow flex items-center gap-2">
|
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="eyebrow mb-1 flex items-center gap-2">
|
||||||
{playing ? (
|
{playing ? (
|
||||||
<span className="flex h-3 items-end gap-[2px]" aria-hidden>
|
<>
|
||||||
|
<span aria-hidden className="flex h-3 items-end gap-[2px]">
|
||||||
{[0, 1, 2].map((i) => (
|
{[0, 1, 2].map((i) => (
|
||||||
<span
|
<span
|
||||||
key={`eq-${i}`}
|
key={`eq-${i}`}
|
||||||
className="w-[3px] animate-eq rounded-full bg-signal"
|
className="w-[3px] animate-eq rounded-full bg-signal"
|
||||||
style={{ animationDelay: `${i * 160}ms`, height: "100%" }}
|
style={{
|
||||||
|
animationDelay: `${i * 160}ms`,
|
||||||
|
height: "100%",
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
))}
|
))}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
<span className="text-signal">Now playing</span>
|
||||||
{playing ? "Now playing" : current ? "Paused" : "Nothing queued"}
|
</>
|
||||||
</p>
|
) : current ? (
|
||||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
"Paused"
|
||||||
|
) : (
|
||||||
|
"Nothing queued"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<h2 className="display text-balance text-2xl text-ink">
|
||||||
{current?.title ?? "Nothing queued"}
|
{current?.title ?? "Nothing queued"}
|
||||||
{formatDuration(current?.durationMs)
|
</h2>
|
||||||
? ` · ${formatDuration(current?.durationMs)}`
|
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
|
||||||
: ""}
|
{current?.source && (
|
||||||
</p>
|
<span className="mono truncate">{current.source}</span>
|
||||||
|
)}
|
||||||
|
{current?.mode && (
|
||||||
|
<span className="pill capitalize">{current.mode}</span>
|
||||||
|
)}
|
||||||
|
{formatDuration(current?.durationMs) && (
|
||||||
|
<span className="mono">
|
||||||
|
{formatDuration(current?.durationMs)}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="mt-4 flex flex-wrap items-center gap-2">
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Console — bottom-left */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(26rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
|
|
||||||
aria-label="Player console"
|
|
||||||
>
|
|
||||||
<div className="relative">
|
|
||||||
<Input
|
|
||||||
placeholder="Paste a YouTube / music URL…"
|
|
||||||
value={url}
|
|
||||||
onChange={(e) => setUrl(e.target.value)}
|
|
||||||
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap items-center gap-2">
|
|
||||||
<Button
|
<Button
|
||||||
variant="primary"
|
variant="primary"
|
||||||
size="sm"
|
size="sm"
|
||||||
onClick={onPlay}
|
onClick={onPlay}
|
||||||
disabled={queue.isPending}
|
disabled={queue.isPending}
|
||||||
>
|
>
|
||||||
<Play className="size-4" /> Queue & play
|
<Play className="size-4" /> Queue & play
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -221,9 +190,22 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
>
|
>
|
||||||
<Repeat className="size-4" /> Loop
|
<Repeat className="size-4" /> Loop
|
||||||
</Button>
|
</Button>
|
||||||
<div className="ml-auto flex items-center gap-2">
|
</div>
|
||||||
<Volume2 className="size-4 text-[var(--color-ink-faint)]" />
|
</div>
|
||||||
<div className="h-1.5 w-20 overflow-hidden rounded-full bg-white/10">
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex flex-wrap items-center gap-3">
|
||||||
|
<div className="relative min-w-0 flex-1">
|
||||||
|
<Input
|
||||||
|
placeholder="Paste a YouTube / music URL…"
|
||||||
|
value={url}
|
||||||
|
onChange={(e) => setUrl(e.target.value)}
|
||||||
|
onKeyDown={(e) => e.key === "Enter" && onPlay()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div className="flex shrink-0 items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2.5">
|
||||||
|
<Volume2 className="size-4 text-ink-faint" />
|
||||||
|
<div className="h-1.5 w-24 overflow-hidden rounded-full bg-white/10">
|
||||||
<div
|
<div
|
||||||
className="h-full rounded-full bg-signal/70"
|
className="h-full rounded-full bg-signal/70"
|
||||||
style={{
|
style={{
|
||||||
@@ -231,53 +213,47 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<span className="w-9 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
|
||||||
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
{Math.round((media?.musicVolume ?? 0) * 100)}%
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</GlassPanel>
|
||||||
|
|
||||||
{/* Up next — right dock */}
|
<GlassPanel>
|
||||||
<section
|
<SectionHeader
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(24rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
eyebrow="up next"
|
||||||
aria-label="Up next"
|
title="Queue"
|
||||||
>
|
action={
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
<span className="mono text-xs text-ink-faint">
|
||||||
<span className="eyebrow">up next</span>
|
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
{queueList.length} track{queueList.length === 1 ? "" : "s"}
|
||||||
{queueTotal ? ` · ${formatDuration(queueTotal)}` : ""}
|
{queueTotal && ` · ${formatDuration(queueTotal)}`}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2">
|
/>
|
||||||
{queueList.length === 0 ? (
|
{queueList.length === 0 ? (
|
||||||
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
<div className="flex flex-col items-center gap-2 py-10 text-center">
|
||||||
<Radio className="size-6 text-[var(--color-ink-faint)]" />
|
<Radio className="size-6 text-ink-faint" />
|
||||||
<p className="text-sm text-[var(--color-ink-soft)]">
|
<div className="text-sm text-ink-soft">Queue is empty</div>
|
||||||
Queue is empty
|
<div className="text-xs text-ink-faint">
|
||||||
</p>
|
Paste a URL above to start playback.
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
</div>
|
||||||
Paste a URL to start playback.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-2">
|
||||||
{queueList.map((item, i) => {
|
{queueList.map((item, i) => {
|
||||||
const isNext = i === 0 && playing;
|
const isNext = i === 0 && playing;
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={`${item.source}-${i}`}
|
key={`${item.source}-${i}`}
|
||||||
className={`animate-stagger flex items-center gap-3 rounded-xl border px-3 py-2 ${
|
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
|
||||||
isNext
|
isNext
|
||||||
? "border-signal/40 bg-signal/[0.07]"
|
? "border-signal/40 bg-signal/[0.07]"
|
||||||
: "border-[var(--color-hairline)]"
|
: "border-hairline bg-white/5"
|
||||||
}`}
|
}`}
|
||||||
style={staggerDelay(i)}
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<span className="w-5 font-mono text-xs text-[var(--color-ink-faint)]">
|
<span className="mono w-5 text-ink-faint">{i + 1}</span>
|
||||||
{i + 1}
|
|
||||||
</span>
|
|
||||||
{item.thumbnailUrl ? (
|
{item.thumbnailUrl ? (
|
||||||
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
|
||||||
<img
|
<img
|
||||||
@@ -287,33 +263,37 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
|
|||||||
loading="lazy"
|
loading="lazy"
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-[var(--color-hairline)]">
|
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
|
||||||
<ListMusic className="size-4 text-[var(--color-ink-faint)]" />
|
<ListMusic className="size-4 text-ink-faint" />
|
||||||
</span>
|
</span>
|
||||||
)}
|
)}
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm text-[var(--color-ink)]">
|
<div className="truncate text-sm text-ink">
|
||||||
{item.title}
|
{item.title}
|
||||||
</p>
|
|
||||||
<p className="truncate font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{item.source}
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
{isNext ? (
|
<div className="mono truncate text-[0.65rem] text-ink-faint">
|
||||||
<span className="shrink-0 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 font-mono text-[0.6rem] text-signal">
|
{item.source}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{isNext && (
|
||||||
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-signal/40 bg-signal/10 px-2 py-0.5 text-[0.6rem] font-medium text-signal">
|
||||||
up next
|
up next
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
<span className="w-10 text-right font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="pill hidden capitalize sm:inline-flex">
|
||||||
|
{item.mode ?? "music"}
|
||||||
|
</span>
|
||||||
|
{formatDuration(item.durationMs) && (
|
||||||
|
<span className="mono w-10 text-right text-[0.65rem] text-ink-faint">
|
||||||
{formatDuration(item.durationMs)}
|
{formatDuration(item.durationMs)}
|
||||||
</span>
|
</span>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,11 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Messages scene — the live feed becomes an orbital belt of nodes on the
|
|
||||||
* stage; the functional console (picker/search/modes) floats top-left,
|
|
||||||
* the stream itself is a translucent dossier column, and a selected
|
|
||||||
* message opens its inspection dossier bottom-center.
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Calendar,
|
Calendar,
|
||||||
@@ -21,13 +15,20 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|||||||
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { EditHistory } from "@/components/EditHistory";
|
import { EditHistory } from "@/components/EditHistory";
|
||||||
import { Avatar, Badge, Skeleton } from "@/components/primitives";
|
|
||||||
import { EmptyState, ErrorState, SkeletonRows } from "@/components/shared";
|
|
||||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
Avatar,
|
||||||
useScenePublish,
|
Badge,
|
||||||
} from "@/components/shell/scene-graph-context";
|
GlassPanel,
|
||||||
|
Input,
|
||||||
|
Skeleton,
|
||||||
|
} from "@/components/primitives";
|
||||||
|
import {
|
||||||
|
EmptyState,
|
||||||
|
ErrorState,
|
||||||
|
SectionHeader,
|
||||||
|
SkeletonRows,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||||
import {
|
import {
|
||||||
useLoadMore,
|
useLoadMore,
|
||||||
useMessageActivity,
|
useMessageActivity,
|
||||||
@@ -38,10 +39,10 @@ import {
|
|||||||
useMessagesStream,
|
useMessagesStream,
|
||||||
useMessagesWsSync,
|
useMessagesWsSync,
|
||||||
useRecentEdits,
|
useRecentEdits,
|
||||||
|
useReviewWsSync,
|
||||||
useSemanticSearch,
|
useSemanticSearch,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
import {
|
||||||
formatBytes,
|
formatBytes,
|
||||||
formatDuration,
|
formatDuration,
|
||||||
@@ -59,24 +60,6 @@ import type {
|
|||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
/** Recent messages → orbital belt (chained edges give the belt shape). */
|
|
||||||
function beltGraph(list: MessageRecord[]): ConstellationGraph {
|
|
||||||
const recent = list.slice(0, 36);
|
|
||||||
const nodes = recent.map((m, i) => ({
|
|
||||||
id: `msg:${m.id}`,
|
|
||||||
label: m.username,
|
|
||||||
kind:
|
|
||||||
m.ai_status === "flagged" ? ("flagged" as const) : ("message" as const),
|
|
||||||
value: Math.max(0.15, 1 - i / Math.max(1, recent.length)),
|
|
||||||
href: undefined,
|
|
||||||
}));
|
|
||||||
const edges = nodes.slice(0, -1).map((n, i) => ({
|
|
||||||
source: n.id,
|
|
||||||
target: nodes[i + 1]?.id ?? n.id,
|
|
||||||
}));
|
|
||||||
return { nodes, edges };
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessagesView({
|
export function MessagesView({
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
initialGuildId,
|
initialGuildId,
|
||||||
@@ -98,22 +81,31 @@ export function MessagesView({
|
|||||||
const [channelId, setChannelId] = useState<string | null>(null);
|
const [channelId, setChannelId] = useState<string | null>(null);
|
||||||
const [selected, setSelected] = useState<string | null>(null);
|
const [selected, setSelected] = useState<string | null>(null);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
|
// Search mode: "exact" (substring match over captured messages) or
|
||||||
|
// "semantic" (vector similarity over the persistent Qdrant archive).
|
||||||
const [semanticMode, setSemanticMode] = useState(false);
|
const [semanticMode, setSemanticMode] = useState(false);
|
||||||
|
// feed | timeline: "timeline" groups messages into date-grouped cards.
|
||||||
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
const [viewMode, setViewMode] = useState<"feed" | "timeline">("feed");
|
||||||
|
// Guard against loading the entire history on a long scroll: cap how many
|
||||||
|
// older pages we append. Each page is 50 messages (backend limit default).
|
||||||
const MAX_OLDER_PAGES = 10;
|
const MAX_OLDER_PAGES = 10;
|
||||||
const [loadedPages, setLoadedPages] = useState(0);
|
const [loadedPages, setLoadedPages] = useState(0);
|
||||||
const [showIntel, setShowIntel] = useState(false);
|
|
||||||
|
|
||||||
const {
|
const {
|
||||||
data: messages,
|
data: messages,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
refetch,
|
||||||
} = useMessages(
|
} = useMessages(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
initialMessages ?? undefined,
|
initialMessages ?? undefined,
|
||||||
);
|
);
|
||||||
|
// Stream history one message per WS frame (replaces the 50-row batched fetch).
|
||||||
|
// Drives snapshots into the SWR list above as they arrive; falls back to the
|
||||||
|
// SSR `initialMessages` seed if WS is unavailable.
|
||||||
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
|
||||||
|
// Cursor to the next (older) page + whether more history exists.
|
||||||
const { data: pageInfo } = useMessagesHasMore(
|
const { data: pageInfo } = useMessagesHasMore(
|
||||||
guildId ?? "",
|
guildId ?? "",
|
||||||
channelId ?? undefined,
|
channelId ?? undefined,
|
||||||
@@ -122,6 +114,7 @@ export function MessagesView({
|
|||||||
const hasMore = pageInfo?.hasMore ?? false;
|
const hasMore = pageInfo?.hasMore ?? false;
|
||||||
const loadMore = useLoadMore();
|
const loadMore = useLoadMore();
|
||||||
useMessagesWsSync(ws, guildId ?? "");
|
useMessagesWsSync(ws, guildId ?? "");
|
||||||
|
useReviewWsSync(ws);
|
||||||
const search = useMessageSearch(
|
const search = useMessageSearch(
|
||||||
query,
|
query,
|
||||||
query.trim().length >= 2 && !semanticMode,
|
query.trim().length >= 2 && !semanticMode,
|
||||||
@@ -134,12 +127,10 @@ export function MessagesView({
|
|||||||
const edits = useRecentEdits(50, undefined, initialEdits);
|
const edits = useRecentEdits(50, undefined, initialEdits);
|
||||||
const detail = useMessageDetail(selected);
|
const detail = useMessageDetail(selected);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const scrollRef = useRef<HTMLDivElement>(null);
|
|
||||||
const nearBottomRef = useRef(true);
|
|
||||||
|
|
||||||
|
// Fetch the next (older) page via cursor and bump the loaded-page counter.
|
||||||
|
// Older messages prepend at the top, so preserve the viewport by offsetting
|
||||||
|
// scrollTop by the height added above (Discord keeps your place while loading).
|
||||||
const loadOlder = useCallback(async () => {
|
const loadOlder = useCallback(async () => {
|
||||||
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
if (!guildId || !hasMore || loadedPages >= MAX_OLDER_PAGES) return;
|
||||||
const el = scrollRef.current;
|
const el = scrollRef.current;
|
||||||
@@ -164,32 +155,21 @@ export function MessagesView({
|
|||||||
const searching = query.trim().length >= 2 && !semanticMode;
|
const searching = query.trim().length >= 2 && !semanticMode;
|
||||||
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
const semanticSearching = query.trim().length >= 2 && semanticMode;
|
||||||
const list = searching ? (search.data ?? []) : (messages ?? []);
|
const list = searching ? (search.data ?? []) : (messages ?? []);
|
||||||
|
// Discord-style order: oldest at the top, newest at the bottom. The backend
|
||||||
|
// returns DESC (newest first); reverse so the feed reads top→bottom like DC.
|
||||||
const display = useMemo(() => [...list].reverse(), [list]);
|
const display = useMemo(() => [...list].reverse(), [list]);
|
||||||
|
|
||||||
const graph = useMemo(
|
// Timeline mode: inject date-separator headers above the first message of
|
||||||
() => (searching ? { nodes: [], edges: [] } : beltGraph(list)),
|
// each day. Messages are sorted oldest→newest (display is reversed), so a
|
||||||
[list, searching],
|
// date change means a new group. Produces an array of either "date" or "msg"
|
||||||
);
|
// nodes so the render loop can switch easily.
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: selected ? `msg:${selected}` : null });
|
|
||||||
}, [graph, selected, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const timelineNodes = useMemo(() => {
|
const timelineNodes = useMemo(() => {
|
||||||
if (viewMode !== "timeline") return null;
|
if (viewMode !== "timeline") return null;
|
||||||
const out: Array<
|
const out: Array<
|
||||||
| { type: "date"; label: string; iso: string }
|
| { type: "date"; label: string; iso: string }
|
||||||
| { type: "msg"; m: (typeof display)[number] }
|
| { type: "msg"; m: (typeof display)[number] }
|
||||||
> = [];
|
> = [];
|
||||||
let prevDate = "";
|
let prev = "";
|
||||||
for (const m of display) {
|
for (const m of display) {
|
||||||
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
const d = new Date(m.created_at).toLocaleDateString(undefined, {
|
||||||
weekday: "short",
|
weekday: "short",
|
||||||
@@ -197,15 +177,24 @@ export function MessagesView({
|
|||||||
day: "numeric",
|
day: "numeric",
|
||||||
});
|
});
|
||||||
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
const iso = new Date(m.created_at).toISOString().slice(0, 10);
|
||||||
if (d !== prevDate) {
|
if (d !== prev) {
|
||||||
out.push({ type: "date", label: d, iso });
|
out.push({ type: "date", label: d, iso });
|
||||||
prevDate = d;
|
prev = d;
|
||||||
}
|
}
|
||||||
out.push({ type: "msg", m });
|
out.push({ type: "msg", m });
|
||||||
}
|
}
|
||||||
return out;
|
return out;
|
||||||
}, [display, viewMode]);
|
}, [display, viewMode]);
|
||||||
|
|
||||||
|
// Ref to the scroll container so we can manage scroll position like Discord:
|
||||||
|
// open at the bottom (newest), keep the viewport stable when prepending older
|
||||||
|
// messages at the top, and follow new live messages only when already near
|
||||||
|
// the bottom.
|
||||||
|
const scrollRef = useRef<HTMLDivElement>(null);
|
||||||
|
const nearBottomRef = useRef(true);
|
||||||
|
|
||||||
|
// Scroll to the bottom on the first load / when switching guild-channel, so
|
||||||
|
// the newest messages are visible (Discord behaviour).
|
||||||
const firstLoadRef = useRef(true);
|
const firstLoadRef = useRef(true);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (firstLoadRef.current && display.length > 0) {
|
if (firstLoadRef.current && display.length > 0) {
|
||||||
@@ -215,6 +204,8 @@ export function MessagesView({
|
|||||||
}
|
}
|
||||||
}, [display.length]);
|
}, [display.length]);
|
||||||
|
|
||||||
|
// When a new live message lands (list grows, still searching off), follow it
|
||||||
|
// to the bottom only if the user was already near the bottom.
|
||||||
const prevLen = useRef(list.length);
|
const prevLen = useRef(list.length);
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (searching) return;
|
if (searching) return;
|
||||||
@@ -227,12 +218,8 @@ export function MessagesView({
|
|||||||
}, [list.length, searching]);
|
}, [list.length, searching]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-4">
|
||||||
{/* Console whisper — top-left */}
|
<GlassPanel className="flex flex-wrap items-center gap-3">
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute left-5 top-16 z-20 w-[min(22rem,90vw)] space-y-2"
|
|
||||||
aria-label="Stream controls"
|
|
||||||
>
|
|
||||||
<GuildChannelPicker
|
<GuildChannelPicker
|
||||||
mode="text"
|
mode="text"
|
||||||
guildsInitial={initialGuilds}
|
guildsInitial={initialGuilds}
|
||||||
@@ -246,24 +233,22 @@ export function MessagesView({
|
|||||||
firstLoadRef.current = true;
|
firstLoadRef.current = true;
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
<div className="flex items-center gap-2">
|
<div className="relative ml-auto w-full sm:w-64">
|
||||||
<div className="relative flex-1">
|
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
|
||||||
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-[var(--color-ink-faint)]" />
|
<Input
|
||||||
<input
|
className="pl-9"
|
||||||
type="search"
|
placeholder="Search messages…"
|
||||||
value={query}
|
value={query}
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
placeholder="Search messages…"
|
|
||||||
className="h-9 w-full rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 pl-9 pr-3 font-mono text-xs text-[var(--color-ink)] backdrop-blur-md outline-none placeholder:text-[var(--color-ink-faint)] focus:border-[var(--color-signal)]"
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => setSemanticMode((v) => !v)}
|
onClick={() => setSemanticMode((v) => !v)}
|
||||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
semanticMode
|
semanticMode
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
title="Toggle semantic (vector) search over the message archive"
|
title="Toggle semantic (vector) search over the message archive"
|
||||||
>
|
>
|
||||||
@@ -274,51 +259,91 @@ export function MessagesView({
|
|||||||
onClick={() =>
|
onClick={() =>
|
||||||
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
|
||||||
}
|
}
|
||||||
className={`rounded-full border px-3 py-1.5 font-mono text-xs transition-colors ${
|
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
|
||||||
viewMode === "timeline"
|
viewMode === "timeline"
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
title="Toggle timeline (date-grouped) view"
|
title="Toggle timeline (date-grouped) view"
|
||||||
>
|
>
|
||||||
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
{viewMode === "timeline" ? "Timeline" : "Feed"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Stream dossier — right column */}
|
<div className="grid gap-4 lg:grid-cols-5">
|
||||||
<section
|
{semanticSearching && (
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-28 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
<GlassPanel className="lg:col-span-5">
|
||||||
aria-label="Message stream"
|
<SectionHeader
|
||||||
>
|
eyebrow="semantic"
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
title={`“${query}”`}
|
||||||
<span className="eyebrow">
|
action={
|
||||||
{searching ? `“${query}”` : "live stream"}
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{semantic.data?.length ?? 0} matches
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
}
|
||||||
|
/>
|
||||||
|
{semantic.isLoading ? (
|
||||||
|
<SkeletonRows rows={4} />
|
||||||
|
) : semantic.data && semantic.data.length > 0 ? (
|
||||||
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
|
{semantic.data.map((r, i) => (
|
||||||
|
<div
|
||||||
|
key={r.message_id ?? i}
|
||||||
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
|
style={staggerDelay(i)}
|
||||||
|
>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="mono text-[0.6rem] text-signal">
|
||||||
|
{(r.score * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
|
||||||
|
{r.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<EmptyState
|
||||||
|
icon={<Search className="size-7" />}
|
||||||
|
title="No semantic matches"
|
||||||
|
description="Try different wording — semantic search finds meaning, not exact text."
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow={searching ? "results" : "live feed"}
|
||||||
|
title={searching ? `“${query}”` : "Messages"}
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
{list.length} shown
|
{list.length} shown
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
|
/>
|
||||||
<div className="min-h-0 flex-1 overflow-hidden p-2">
|
{error && !messages ? (
|
||||||
{semanticSearching ? (
|
<ErrorState error={error} onRetry={() => refetch()} />
|
||||||
<SemanticResults semantic={semantic} />
|
|
||||||
) : error && !messages ? (
|
|
||||||
<ErrorState error={error} />
|
|
||||||
) : isLoading && !messages ? (
|
) : isLoading && !messages ? (
|
||||||
<SkeletonRows rows={8} />
|
<SkeletonRows rows={8} />
|
||||||
) : list.length === 0 ? (
|
) : list.length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<MessageSquare className="size-6" />}
|
icon={<MessageSquare className="size-7" />}
|
||||||
title="No messages"
|
title="No messages"
|
||||||
description="Pick a guild to begin, or run a search."
|
description="Pick a guild to begin, or run a search."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full flex-col">
|
<div>
|
||||||
{!searching && (
|
{!searching && (
|
||||||
<div className="mb-1.5 flex items-center justify-center gap-2">
|
<div className="mb-2 flex items-center justify-center gap-2">
|
||||||
{loadMore.isPending ? (
|
{loadMore.isPending ? (
|
||||||
<span className="flex items-center gap-1.5 font-mono text-xs text-[var(--color-ink-soft)]">
|
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
Loading older…
|
Loading older…
|
||||||
</span>
|
</span>
|
||||||
@@ -326,19 +351,19 @@ export function MessagesView({
|
|||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={loadOlder}
|
onClick={loadOlder}
|
||||||
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||||
>
|
>
|
||||||
↑ Load older messages
|
↑ Load older messages
|
||||||
</button>
|
</button>
|
||||||
) : loadedPages >= MAX_OLDER_PAGES ? (
|
) : loadedPages >= MAX_OLDER_PAGES ? (
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
capped at {MAX_OLDER_PAGES} older pages · use search for
|
capped at {MAX_OLDER_PAGES} older pages · use search for
|
||||||
more
|
more
|
||||||
</span>
|
</span>
|
||||||
) : (
|
) : (
|
||||||
messages &&
|
messages &&
|
||||||
messages.length > 0 && (
|
messages.length > 0 && (
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
beginning of history
|
beginning of history
|
||||||
</span>
|
</span>
|
||||||
)
|
)
|
||||||
@@ -347,9 +372,11 @@ export function MessagesView({
|
|||||||
)}
|
)}
|
||||||
<div
|
<div
|
||||||
ref={scrollRef}
|
ref={scrollRef}
|
||||||
className="min-h-0 flex-1 space-y-1.5 overflow-y-auto pr-1"
|
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
|
||||||
onScroll={(e) => {
|
onScroll={(e) => {
|
||||||
const el = e.currentTarget;
|
const el = e.currentTarget;
|
||||||
|
// Track whether the user is near the bottom (to follow live
|
||||||
|
// messages) and auto-load older messages when scrolled to top.
|
||||||
nearBottomRef.current =
|
nearBottomRef.current =
|
||||||
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
|
||||||
if (searching || !hasMore || loadMore.isPending) return;
|
if (searching || !hasMore || loadMore.isPending) return;
|
||||||
@@ -360,11 +387,11 @@ export function MessagesView({
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{viewMode === "timeline" && timelineNodes
|
{viewMode === "timeline" && timelineNodes
|
||||||
? timelineNodes.map((node) =>
|
? timelineNodes.map((node, _i) =>
|
||||||
node.type === "date" ? (
|
node.type === "date" ? (
|
||||||
<div
|
<div
|
||||||
key={`date-${node.iso}`}
|
key={`date-${node.iso}`}
|
||||||
className="flex items-center gap-2 px-1 font-mono text-[0.65rem] text-[var(--color-ink-faint)]"
|
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
|
||||||
>
|
>
|
||||||
<Calendar className="size-3" />
|
<Calendar className="size-3" />
|
||||||
{node.label}
|
{node.label}
|
||||||
@@ -378,7 +405,7 @@ export function MessagesView({
|
|||||||
/>
|
/>
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
: display.map((m) => (
|
: display.map((m, _i) => (
|
||||||
<MessageRow
|
<MessageRow
|
||||||
key={m.id}
|
key={m.id}
|
||||||
m={m}
|
m={m}
|
||||||
@@ -389,49 +416,19 @@ export function MessagesView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mobile stream — full-width sheet under the console */}
|
<GlassPanel className="lg:col-span-2">
|
||||||
<section
|
<SectionHeader eyebrow="inspect" title="Detail" />
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2 backdrop-blur-xl md:hidden"
|
{!selected ? (
|
||||||
aria-label="Message stream mobile"
|
<EmptyState
|
||||||
>
|
title="Select a message"
|
||||||
{error && !messages ? (
|
description="Click any message to inspect AI analysis, attachments and edit history."
|
||||||
<ErrorState error={error} />
|
|
||||||
) : isLoading && !messages ? (
|
|
||||||
<SkeletonRows rows={6} />
|
|
||||||
) : display.length === 0 ? (
|
|
||||||
<EmptyState title="No messages" description="Pick a guild first." />
|
|
||||||
) : (
|
|
||||||
display.map((m) => (
|
|
||||||
<MessageRow
|
|
||||||
key={m.id}
|
|
||||||
m={m}
|
|
||||||
selected={selected}
|
|
||||||
onSelect={setSelected}
|
|
||||||
/>
|
/>
|
||||||
))
|
) : detail.loading ? (
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Inspection dossier — bottom-center */}
|
|
||||||
{selected ? (
|
|
||||||
<aside
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-1/2 z-30 max-h-[52vh] w-[min(34rem,92vw)] -translate-x-1/2 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/85 p-4 backdrop-blur-xl"
|
|
||||||
aria-label="Message detail"
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="absolute right-3 top-3 font-mono text-xs text-[var(--color-ink-faint)] hover:text-[var(--color-ink)]"
|
|
||||||
onClick={() => setSelected(null)}
|
|
||||||
>
|
|
||||||
esc
|
|
||||||
</button>
|
|
||||||
{detail.loading ? (
|
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Skeleton className="h-16" />
|
<Skeleton className="h-20" />
|
||||||
<Skeleton className="h-10" />
|
<Skeleton className="h-12" />
|
||||||
</div>
|
</div>
|
||||||
) : detail.message ? (
|
) : detail.message ? (
|
||||||
<MessageDetail
|
<MessageDetail
|
||||||
@@ -441,71 +438,14 @@ export function MessagesView({
|
|||||||
) : (
|
) : (
|
||||||
<EmptyState title="Not found" />
|
<EmptyState title="Not found" />
|
||||||
)}
|
)}
|
||||||
</aside>
|
</GlassPanel>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
{/* Intel strip — bottom-left */}
|
{activity.data && activity.data.length > 0 && (
|
||||||
<div className="pointer-events-auto absolute bottom-20 left-5 hidden w-72 lg:block">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => setShowIntel((v) => !v)}
|
|
||||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
|
||||||
showIntel
|
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
|
||||||
: "border-[var(--color-hairline)] text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
intel {showIntel ? "▲" : "▼"}
|
|
||||||
</button>
|
|
||||||
{showIntel ? (
|
|
||||||
<div className="mt-2 max-h-[38vh] space-y-3 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl">
|
|
||||||
{activity.data && activity.data.length > 0 ? (
|
|
||||||
<ActivityHeatmap buckets={activity.data} />
|
<ActivityHeatmap buckets={activity.data} />
|
||||||
) : null}
|
)}
|
||||||
{edits.data ? <EditHistory edits={edits.data} /> : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function SemanticResults({
|
{edits.data && <EditHistory edits={edits.data} />}
|
||||||
semantic,
|
|
||||||
}: {
|
|
||||||
semantic: ReturnType<typeof useSemanticSearch>;
|
|
||||||
}) {
|
|
||||||
if (semantic.isLoading) return <SkeletonRows rows={4} />;
|
|
||||||
if (!semantic.data || semantic.data.length === 0) {
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
icon={<Search className="size-6" />}
|
|
||||||
title="No semantic matches"
|
|
||||||
description="Try different wording — semantic search finds meaning, not exact text."
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="h-full space-y-1.5 overflow-y-auto pr-1">
|
|
||||||
{semantic.data.map((r, i) => (
|
|
||||||
<div
|
|
||||||
key={r.message_id ?? i}
|
|
||||||
className="animate-stagger rounded-xl border border-[var(--color-hairline)] p-2.5"
|
|
||||||
style={staggerDelay(i)}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="font-mono text-[0.6rem] text-signal">
|
|
||||||
{(r.score * 100).toFixed(0)}%
|
|
||||||
</span>
|
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
|
||||||
{formatRelativeTime(r.created_at)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="mt-0.5 line-clamp-3 text-sm text-[var(--color-ink-soft)]">
|
|
||||||
{r.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -556,12 +496,10 @@ function MessageDetail({
|
|||||||
return (
|
return (
|
||||||
<div className="space-y-3 text-sm">
|
<div className="space-y-3 text-sm">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={36} />
|
<Avatar src={m.avatar_url} name={m.username} size={40} />
|
||||||
<div>
|
<div>
|
||||||
<div className="font-semibold text-[var(--color-ink)]">
|
<div className="font-semibold text-ink">{m.username}</div>
|
||||||
{m.username}
|
<div className="mono text-[0.65rem] text-ink-faint">
|
||||||
</div>
|
|
||||||
<div className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -573,21 +511,21 @@ function MessageDetail({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||||
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
|
||||||
"(no text)"}
|
"(no text)"}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{m.ai_analysis ? (
|
{m.ai_analysis && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1">AI analysis</div>
|
<div className="eyebrow mb-1">AI analysis</div>
|
||||||
<div className="rounded-xl border border-[var(--color-hairline)] p-3 text-[var(--color-ink-soft)]">
|
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
|
||||||
{m.ai_analysis}
|
{m.ai_analysis}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{flags.length > 0 || cats.length > 0 ? (
|
{(flags.length > 0 || cats.length > 0) && (
|
||||||
<div className="flex flex-wrap gap-1.5">
|
<div className="flex flex-wrap gap-1.5">
|
||||||
{flags.map((f) => (
|
{flags.map((f) => (
|
||||||
<Badge key={f} tone="vermilion">
|
<Badge key={f} tone="vermilion">
|
||||||
@@ -600,9 +538,9 @@ function MessageDetail({
|
|||||||
</Badge>
|
</Badge>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
|
||||||
{attachments.length > 0 ? (
|
{attachments.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
<div className="eyebrow mb-1 flex items-center gap-1.5">
|
||||||
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
<Paperclip className="size-3" /> Attachments ({attachments.length})
|
||||||
@@ -614,22 +552,23 @@ function MessageDetail({
|
|||||||
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
href={a.discord_url ?? a.uploaded_url ?? "#"}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="flex items-center gap-2 rounded-xl border border-[var(--color-hairline)] px-3 py-2 font-mono text-xs text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft hover:text-ink"
|
||||||
>
|
>
|
||||||
<ImageIcon className="size-3.5 text-signal" />
|
<ImageIcon className="size-3.5 text-signal" />
|
||||||
<span className="flex-1 truncate">{a.filename}</span>
|
<span className="flex-1 truncate">{a.filename}</span>
|
||||||
<span className="text-[var(--color-ink-faint)]">
|
<span className="mono text-ink-faint">
|
||||||
{formatBytes(a.size)}
|
{formatBytes(a.size)}
|
||||||
</span>
|
</span>
|
||||||
</a>
|
</a>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single message card used by both the live feed and the date-grouped timeline. */
|
||||||
function MessageRow({
|
function MessageRow({
|
||||||
m,
|
m,
|
||||||
selected,
|
selected,
|
||||||
@@ -641,32 +580,31 @@ function MessageRow({
|
|||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
|
key={m.id}
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => onSelect(m.id)}
|
onClick={() => onSelect(m.id)}
|
||||||
className={`animate-stagger flex w-full items-start gap-3 rounded-xl border p-2.5 text-left transition-colors ${
|
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
|
||||||
selected === m.id
|
selected === m.id
|
||||||
? "border-signal/40 bg-signal/8"
|
? "border-signal/40 bg-signal/8"
|
||||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<Avatar src={m.avatar_url} name={m.username} size={32} />
|
<Avatar src={m.avatar_url} name={m.username} size={34} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
<span className="truncate text-sm font-semibold text-ink">
|
||||||
{m.username}
|
{m.username}
|
||||||
</span>
|
</span>
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
{getMessageChannelLabel(m)}
|
{getMessageChannelLabel(m)}
|
||||||
</span>
|
</span>
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{formatRelativeTime(m.created_at)}
|
{formatRelativeTime(m.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-0.5 line-clamp-2 text-sm text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
|
||||||
{renderMessageContent(m.content, m.metadata) || (
|
{renderMessageContent(m.content, m.metadata) || (
|
||||||
<span className="italic text-[var(--color-ink-faint)]">
|
<span className="italic text-ink-faint">(empty / embed)</span>
|
||||||
(empty / embed)
|
|
||||||
</span>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,34 +1,40 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Moderation scene — flagged actions orbit a verdict hub on the stage
|
|
||||||
* (vermilion stars); the overlay carries the metric whisper (left),
|
|
||||||
* the live feed as a bottom ribbon, and an intel dossier (right) with
|
|
||||||
* trends / coverage / domains / heatmap / drilldown.
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
Ban,
|
Ban,
|
||||||
|
CheckCircle2,
|
||||||
|
Clock,
|
||||||
Filter,
|
Filter,
|
||||||
MessageSquareWarning,
|
MessageSquareWarning,
|
||||||
MicOff,
|
MicOff,
|
||||||
ShieldAlert,
|
ShieldAlert,
|
||||||
Trash2,
|
Trash2,
|
||||||
UserX,
|
UserX,
|
||||||
|
XCircle,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
import { CategoryDrilldown } from "@/components/CategoryDrilldown";
|
||||||
import { CoverageTiles } from "@/components/CoverageTiles";
|
import { CoverageTiles } from "@/components/CoverageTiles";
|
||||||
import { Donut } from "@/components/charts";
|
import { Donut } from "@/components/charts";
|
||||||
|
import { LiveModerationFeed } from "@/components/LiveModerationFeed";
|
||||||
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
import { ModerationHeatmap } from "@/components/ModerationHeatmap";
|
||||||
import { Badge, Select, type SelectOption } from "@/components/primitives";
|
|
||||||
import { ScamDomains } from "@/components/ScamDomains";
|
|
||||||
import { ErrorState } from "@/components/shared";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
Badge,
|
||||||
useScenePublish,
|
GlassPanel,
|
||||||
} from "@/components/shell/scene-graph-context";
|
Select,
|
||||||
|
type SelectOption,
|
||||||
|
} from "@/components/primitives";
|
||||||
|
import { ScamDomains } from "@/components/ScamDomains";
|
||||||
|
import {
|
||||||
|
ErrorState,
|
||||||
|
MetricTile,
|
||||||
|
SectionHeader,
|
||||||
|
SkeletonMetricRow,
|
||||||
|
SkeletonPanel,
|
||||||
|
SkeletonRows,
|
||||||
|
} from "@/components/shared";
|
||||||
import { TopChannels } from "@/components/TopChannels";
|
import { TopChannels } from "@/components/TopChannels";
|
||||||
import { TopicTrends } from "@/components/TopicTrends";
|
import { TopicTrends } from "@/components/TopicTrends";
|
||||||
import {
|
import {
|
||||||
@@ -43,7 +49,6 @@ import {
|
|||||||
useTopFlaggedDomains,
|
useTopFlaggedDomains,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import { aiTone } from "@/lib/ai-status";
|
import { aiTone } from "@/lib/ai-status";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { downloadCsv } from "@/lib/csv";
|
import { downloadCsv } from "@/lib/csv";
|
||||||
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
import { formatNumber, formatRelativeTime } from "@/lib/format";
|
||||||
import type {
|
import type {
|
||||||
@@ -51,6 +56,7 @@ import type {
|
|||||||
ModerationActionType,
|
ModerationActionType,
|
||||||
ModerationStats,
|
ModerationStats,
|
||||||
} from "@/lib/types";
|
} from "@/lib/types";
|
||||||
|
import { staggerDelay } from "@/lib/utils";
|
||||||
|
|
||||||
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
const ACTION_ICON: Record<ModerationActionType, React.ReactNode> = {
|
||||||
delete_message: <Trash2 className="size-3.5" />,
|
delete_message: <Trash2 className="size-3.5" />,
|
||||||
@@ -68,35 +74,6 @@ const ACTION_LABEL: Record<ModerationActionType, string> = {
|
|||||||
ban_user: "Ban",
|
ban_user: "Ban",
|
||||||
};
|
};
|
||||||
|
|
||||||
/** Flagged actions → vermilion satellites around a verdict hub. */
|
|
||||||
function moderationGraph(
|
|
||||||
liveActions: ModerationAction[],
|
|
||||||
stats?: ModerationStats,
|
|
||||||
): ConstellationGraph {
|
|
||||||
const recent = liveActions.slice(0, 24);
|
|
||||||
const nodes = [
|
|
||||||
{ id: "verdict", label: "verdict hub", kind: "guild" as const, value: 1 },
|
|
||||||
...recent.map((a) => ({
|
|
||||||
id: `mod:${a.id}`,
|
|
||||||
label: a.username ?? (a.user_id ?? "unknown").slice(0, 8),
|
|
||||||
kind:
|
|
||||||
a.status === "failed"
|
|
||||||
? ("flagged" as const)
|
|
||||||
: a.status === "pending"
|
|
||||||
? ("message" as const)
|
|
||||||
: ("channel" as const),
|
|
||||||
value: a.status === "executed" ? 0.55 : 0.35,
|
|
||||||
href: undefined,
|
|
||||||
meta: { mod_status: a.status },
|
|
||||||
})),
|
|
||||||
];
|
|
||||||
void stats;
|
|
||||||
return {
|
|
||||||
nodes,
|
|
||||||
edges: recent.map((a) => ({ source: "verdict", target: `mod:${a.id}` })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ModerationView({
|
export function ModerationView({
|
||||||
initialStats,
|
initialStats,
|
||||||
initialActions,
|
initialActions,
|
||||||
@@ -104,7 +81,12 @@ export function ModerationView({
|
|||||||
initialStats?: ModerationStats;
|
initialStats?: ModerationStats;
|
||||||
initialActions?: ModerationAction[];
|
initialActions?: ModerationAction[];
|
||||||
}) {
|
}) {
|
||||||
const { data: stats, isLoading, error } = useModerationStats(initialStats);
|
const {
|
||||||
|
data: stats,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate: mutateStats,
|
||||||
|
} = useModerationStats(initialStats);
|
||||||
const [statusFilter, setStatusFilter] = useState<string>("");
|
const [statusFilter, setStatusFilter] = useState<string>("");
|
||||||
const [typeFilter, setTypeFilter] = useState<string>("");
|
const [typeFilter, setTypeFilter] = useState<string>("");
|
||||||
const { data: actions } = useModerationActions(
|
const { data: actions } = useModerationActions(
|
||||||
@@ -119,17 +101,13 @@ export function ModerationView({
|
|||||||
const { data: hourly } = useHourlyModeration(30);
|
const { data: hourly } = useHourlyModeration(30);
|
||||||
const { data: coverage } = useModerationCoverage(30);
|
const { data: coverage } = useModerationCoverage(30);
|
||||||
const [drilldown, setDrilldown] = useState<string | null>(null);
|
const [drilldown, setDrilldown] = useState<string | null>(null);
|
||||||
const [intelOpen, setIntelOpen] = useState(false);
|
|
||||||
const { data: categoryActions, isValidating: categoryLoading } =
|
const { data: categoryActions, isValidating: categoryLoading } =
|
||||||
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
useModerationByCategory(drilldown ? 30 : 0, drilldown);
|
||||||
|
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
const failedRate = stats ? stats.failed_rate * 100 : 0;
|
||||||
|
|
||||||
const byAction = stats?.by_action ?? {};
|
const byAction = stats?.by_action ?? {};
|
||||||
const segments = Object.entries(byAction).map(([k]) => ({
|
const segments = Object.entries(byAction).map(([k, _v]) => ({
|
||||||
value: 1,
|
value: 1,
|
||||||
color:
|
color:
|
||||||
k === "ban_user" || k === "kick_user"
|
k === "ban_user" || k === "kick_user"
|
||||||
@@ -140,23 +118,6 @@ export function ModerationView({
|
|||||||
label: k,
|
label: k,
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const graph = useMemo(
|
|
||||||
() => moderationGraph(liveActions, stats),
|
|
||||||
[liveActions, stats],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set(
|
ambient.set(
|
||||||
@@ -166,14 +127,23 @@ export function ModerationView({
|
|||||||
);
|
);
|
||||||
}, [failedRate, ambient]);
|
}, [failedRate, ambient]);
|
||||||
|
|
||||||
if (error && !stats) return <ErrorState error={error} />;
|
if (error && !stats)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
|
||||||
if (!stats && isLoading)
|
if (!stats && isLoading)
|
||||||
return (
|
return (
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
<div className="space-y-5">
|
||||||
menghubungkan verdict hub…
|
<SkeletonMetricRow cols={4} />
|
||||||
</p>
|
<SkeletonPanel rows={5} />
|
||||||
|
<SkeletonRows rows={6} />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
if (!stats)
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
error={error ?? new Error("No data")}
|
||||||
|
onRetry={() => void mutateStats()}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
if (!stats) return <ErrorState error={error ?? new Error("No data")} />;
|
|
||||||
|
|
||||||
const statusOpts: SelectOption[] = [
|
const statusOpts: SelectOption[] = [
|
||||||
{ value: "", label: "All statuses" },
|
{ value: "", label: "All statuses" },
|
||||||
@@ -190,84 +160,76 @@ export function ModerationView({
|
|||||||
];
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Metric whisper — left */}
|
<div className="grid grid-cols-2 gap-3 md:grid-cols-4">
|
||||||
<section
|
<MetricTile
|
||||||
className="pointer-events-auto absolute left-5 top-16 w-56"
|
label="Total actions"
|
||||||
aria-label="Moderation metrics"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2 flex items-center gap-1.5">
|
|
||||||
<ShieldAlert className="size-3.5 text-signal" /> Enforcement
|
|
||||||
</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<Whisper
|
|
||||||
label="total actions"
|
|
||||||
value={formatNumber(stats.total)}
|
value={formatNumber(stats.total)}
|
||||||
tone="ink"
|
tone="signal"
|
||||||
|
icon={<ShieldAlert className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="executed"
|
label="Executed"
|
||||||
value={formatNumber(stats.executed)}
|
value={formatNumber(stats.executed)}
|
||||||
tone="signal"
|
tone="signal"
|
||||||
|
icon={<CheckCircle2 className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="failed"
|
label="Failed"
|
||||||
value={formatNumber(stats.failed)}
|
value={formatNumber(stats.failed)}
|
||||||
tone={stats.failed > 0 ? "vermilion" : "ink"}
|
tone={stats.failed > 0 ? "vermilion" : "neutral"}
|
||||||
|
icon={<XCircle className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
<Whisper
|
<MetricTile
|
||||||
label="pending"
|
label="Pending"
|
||||||
value={formatNumber(stats.pending)}
|
value={formatNumber(stats?.pending)}
|
||||||
tone={stats.pending > 0 ? "amber" : "ink"}
|
tone={stats?.pending > 0 ? "amber" : "neutral"}
|
||||||
|
icon={<Clock className="size-3.5" />}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="mt-4">
|
|
||||||
<Donut
|
|
||||||
segments={
|
|
||||||
segments.length
|
|
||||||
? segments
|
|
||||||
: [{ value: 1, color: "var(--color-ink-faint)", label: "none" }]
|
|
||||||
}
|
|
||||||
centerLabel={`${Math.round(failedRate)}%`}
|
|
||||||
centerSub="fail rate"
|
|
||||||
/>
|
|
||||||
<div className="mt-2 space-y-1 font-mono text-xs text-[var(--color-ink-soft)]">
|
|
||||||
{Object.entries(byAction).map(([k, v]) => {
|
|
||||||
const count = typeof v === "number" ? v : null;
|
|
||||||
return (
|
|
||||||
<p key={k} className="flex items-center gap-2">
|
|
||||||
<span>{ACTION_ICON[k as ModerationActionType]}</span>
|
|
||||||
<span className="flex-1">
|
|
||||||
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
|
||||||
</span>
|
|
||||||
{count !== null ? <span>{count}</span> : null}
|
|
||||||
</p>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Intel dossier — right */}
|
<div className="grid gap-5 lg:grid-cols-5">
|
||||||
<div className="pointer-events-auto absolute right-5 top-16 hidden w-[min(24rem,90vw)] md:block">
|
<div className="lg:col-span-2">
|
||||||
<button
|
{trends ? (
|
||||||
type="button"
|
<TopicTrends trends={trends} />
|
||||||
onClick={() => setIntelOpen((v) => !v)}
|
) : (
|
||||||
className={`rounded-full border px-3 py-1 font-mono text-xs transition-colors ${
|
<SkeletonPanel rows={6} />
|
||||||
intelOpen
|
)}
|
||||||
? "border-signal/40 bg-signal/10 text-signal"
|
</div>
|
||||||
: "border-[var(--color-hairline)] bg-[var(--color-canvas)]/60 text-[var(--color-ink-soft)] backdrop-blur-md hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
<div className="lg:col-span-5">
|
||||||
>
|
<LiveModerationFeed actions={liveActions} />
|
||||||
intel {intelOpen ? "▲" : "▼"}
|
</div>
|
||||||
</button>
|
|
||||||
{intelOpen ? (
|
{coverage ? (
|
||||||
<div className="mt-2 max-h-[64vh] space-y-4 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/78 p-3 backdrop-blur-xl">
|
<CoverageTiles coverage={coverage} />
|
||||||
{trends ? <TopicTrends trends={trends} /> : null}
|
) : (
|
||||||
{coverage ? <CoverageTiles coverage={coverage} /> : null}
|
<SkeletonPanel rows={3} className="lg:col-span-5" />
|
||||||
{domains ? <ScamDomains domains={domains} /> : null}
|
)}
|
||||||
{hourly ? <ModerationHeatmap hours={hourly} /> : null}
|
|
||||||
{channels ? <TopChannels channels={channels} /> : null}
|
<div className="lg:col-span-2">
|
||||||
|
{domains ? (
|
||||||
|
<ScamDomains domains={domains} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-2">
|
||||||
|
{hourly ? (
|
||||||
|
<ModerationHeatmap hours={hourly} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="lg:col-span-1">
|
||||||
|
{channels ? (
|
||||||
|
<TopChannels channels={channels} />
|
||||||
|
) : (
|
||||||
|
<SkeletonPanel rows={6} />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="lg:col-span-3">
|
||||||
<CategoryDrilldown
|
<CategoryDrilldown
|
||||||
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
trends={trends ?? { categories: [], severities: [], actions: [] }}
|
||||||
selected={drilldown}
|
selected={drilldown}
|
||||||
@@ -276,33 +238,71 @@ export function ModerationView({
|
|||||||
onSelect={setDrilldown}
|
onSelect={setDrilldown}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Live feed ribbon — bottom */}
|
<GlassPanel className="lg:col-span-2">
|
||||||
<section
|
<SectionHeader eyebrow="health" title="Breakdown" />
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-20 max-h-[46vh] overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 backdrop-blur-xl lg:left-80 lg:right-[26rem]"
|
<div className="flex items-center gap-5">
|
||||||
aria-label="Live moderation feed"
|
<Donut
|
||||||
>
|
segments={
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2">
|
segments.length
|
||||||
<span className="eyebrow flex items-center gap-2">
|
? segments
|
||||||
<Filter className="size-3 text-[var(--color-ink-faint)]" />
|
: [
|
||||||
action log
|
{
|
||||||
|
value: 1,
|
||||||
|
color: "var(--color-ink-faint)",
|
||||||
|
label: "none",
|
||||||
|
},
|
||||||
|
]
|
||||||
|
}
|
||||||
|
centerLabel={`${Math.round(failedRate)}%`}
|
||||||
|
centerSub="fail rate"
|
||||||
|
/>
|
||||||
|
<div className="flex-1 space-y-2 text-sm">
|
||||||
|
{Object.entries(byAction).map(([k, v]) => {
|
||||||
|
const count = typeof v === "number" ? v : null;
|
||||||
|
return (
|
||||||
|
<div key={k} className="flex items-center gap-2.5">
|
||||||
|
<span className="text-ink-soft">
|
||||||
|
{ACTION_ICON[k as ModerationActionType]}
|
||||||
</span>
|
</span>
|
||||||
|
<span className="flex-1 text-ink-soft">
|
||||||
|
{ACTION_LABEL[k as ModerationActionType] ?? k}
|
||||||
|
</span>
|
||||||
|
{count !== null && (
|
||||||
|
<span className="mono text-ink">{count}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
{Object.keys(byAction).length === 0 && (
|
||||||
|
<div className="text-xs text-ink-faint">
|
||||||
|
No actions recorded yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel className="lg:col-span-3">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="filter"
|
||||||
|
title="Action log"
|
||||||
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
<Filter className="size-3.5 text-ink-faint" />
|
||||||
<Select
|
<Select
|
||||||
value={typeFilter}
|
value={typeFilter}
|
||||||
onChange={setTypeFilter}
|
onChange={setTypeFilter}
|
||||||
options={typeOpts}
|
options={typeOpts}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-32"
|
className="w-36"
|
||||||
/>
|
/>
|
||||||
<Select
|
<Select
|
||||||
value={statusFilter}
|
value={statusFilter}
|
||||||
onChange={setStatusFilter}
|
onChange={setStatusFilter}
|
||||||
options={statusOpts}
|
options={statusOpts}
|
||||||
size="sm"
|
size="sm"
|
||||||
className="w-28"
|
className="w-32"
|
||||||
/>
|
/>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@@ -323,68 +323,27 @@ export function ModerationView({
|
|||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
className="rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] transition-colors hover:text-[var(--color-ink)]"
|
className="rounded-full border border-hairline bg-white/[0.03] px-3 py-1 text-xs text-ink-soft transition-colors hover:bg-white/[0.06]"
|
||||||
title="Download moderation actions as CSV"
|
title="Download moderation actions as CSV"
|
||||||
>
|
>
|
||||||
CSV
|
CSV
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
<div className="max-h-[40vh] overflow-y-auto p-2">
|
|
||||||
<ActionRows actions={actions ?? []} liveCount={liveActions.length} />
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
/>
|
||||||
function ActionRows({
|
<div className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1">
|
||||||
actions,
|
{(actions ?? []).map((a, i) => (
|
||||||
liveCount,
|
|
||||||
}: {
|
|
||||||
actions: ModerationAction[];
|
|
||||||
liveCount: number;
|
|
||||||
}) {
|
|
||||||
if (actions.length === 0 && liveCount === 0) {
|
|
||||||
return (
|
|
||||||
<div className="py-6 text-center font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
No matching actions.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{actions.map((a, i) => (
|
|
||||||
<ActionRow key={a.id} a={a} index={i} />
|
<ActionRow key={a.id} a={a} index={i} />
|
||||||
))}
|
))}
|
||||||
|
{(actions ?? []).length === 0 && (
|
||||||
|
<div className="py-10 text-center text-xs text-ink-faint">
|
||||||
|
No matching actions.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Whisper({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
tone,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
tone: "ink" | "signal" | "vermilion" | "amber";
|
|
||||||
}) {
|
|
||||||
const color =
|
|
||||||
tone === "vermilion"
|
|
||||||
? "text-vermilion"
|
|
||||||
: tone === "amber"
|
|
||||||
? "text-amber"
|
|
||||||
: tone === "signal"
|
|
||||||
? "text-signal"
|
|
||||||
: "text-ink";
|
|
||||||
return (
|
|
||||||
<p className="flex items-baseline gap-2">
|
|
||||||
<span className="font-mono text-[10px] uppercase tracking-wider text-[var(--color-ink-faint)]">
|
|
||||||
{label}
|
|
||||||
</span>
|
|
||||||
<span className={`display text-lg leading-none ${color}`}>{value}</span>
|
|
||||||
</p>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -398,6 +357,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
const icon = ACTION_ICON[a.action_type] ?? (
|
const icon = ACTION_ICON[a.action_type] ?? (
|
||||||
<AlertTriangle className="size-3.5" />
|
<AlertTriangle className="size-3.5" />
|
||||||
);
|
);
|
||||||
|
// Map moderation severity → design-system tone (reuse aiTone with a
|
||||||
|
// severity→status projection so "none" reads as clean/signal).
|
||||||
const severityTone =
|
const severityTone =
|
||||||
a.severity == null
|
a.severity == null
|
||||||
? null
|
? null
|
||||||
@@ -410,8 +371,8 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
);
|
);
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className="animate-stagger flex items-start gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
className="animate-stagger flex items-start gap-3 rounded-[10px] border border-hairline bg-white/[0.03] p-3"
|
||||||
style={{ animationDelay: `${index * 30}ms` }}
|
style={staggerDelay(index)}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
className={`mt-0.5 ${tone === "vermilion" ? "text-vermilion" : tone === "amber" ? "text-amber" : "text-signal"}`}
|
||||||
@@ -420,29 +381,27 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
</span>
|
</span>
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="text-sm font-semibold text-[var(--color-ink)]">
|
<span className="text-sm font-semibold text-ink">
|
||||||
{a.username ?? "unknown"}
|
{a.username ?? "unknown"}
|
||||||
</span>
|
</span>
|
||||||
<Badge tone={tone}>{a.status}</Badge>
|
<Badge tone={tone}>{a.status}</Badge>
|
||||||
<span className="ml-auto font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
|
||||||
{formatRelativeTime(a.created_at)}
|
{formatRelativeTime(a.created_at)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{a.reason ? (
|
{a.reason && (
|
||||||
<div className="mt-0.5 text-xs text-[var(--color-ink-soft)]">
|
<div className="mt-0.5 text-xs text-ink-soft">“{a.reason}”</div>
|
||||||
“{a.reason}”
|
)}
|
||||||
</div>
|
{severityTone && a.severity && (
|
||||||
) : null}
|
|
||||||
{severityTone && a.severity ? (
|
|
||||||
<div className="mt-1 flex flex-wrap items-center gap-1">
|
<div className="mt-1 flex flex-wrap items-center gap-1">
|
||||||
<Badge tone={severityTone}>{a.severity}</Badge>
|
<Badge tone={severityTone}>{a.severity}</Badge>
|
||||||
{a.confidence != null ? (
|
{a.confidence != null && (
|
||||||
<span className="font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<span className="mono text-[0.6rem] text-ink-faint">
|
||||||
conf {(a.confidence * 100).toFixed(0)}%
|
conf {(a.confidence * 100).toFixed(0)}%
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.flags?.length ? (
|
{a.flags?.length ? (
|
||||||
<div className="mt-1 flex flex-wrap gap-1">
|
<div className="mt-1 flex flex-wrap gap-1">
|
||||||
{a.flags.slice(0, 6).map((f) => (
|
{a.flags.slice(0, 6).map((f) => (
|
||||||
@@ -453,24 +412,24 @@ function ActionRow({ a, index = 0 }: { a: ModerationAction; index?: number }) {
|
|||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{a.evidence?.length ? (
|
{a.evidence?.length ? (
|
||||||
<div className="mt-1 border-l-2 border-[var(--color-hairline)] pl-2 text-xs text-[var(--color-ink-faint)]">
|
<div className="mt-1 border-l-2 border-hairline pl-2 text-xs text-ink-faint">
|
||||||
“{a.evidence[0]}”
|
“{a.evidence[0]}”
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
{a.executed_by ? (
|
{a.executed_by && (
|
||||||
<div className="mt-0.5 font-mono text-[0.6rem] text-[var(--color-ink-faint)]">
|
<div className="mono mt-0.5 text-[0.6rem] text-ink-faint">
|
||||||
by {a.executed_by}
|
by {a.executed_by}
|
||||||
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
{a.executed_at ? ` · ${formatRelativeTime(a.executed_at)}` : ""}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.content ? (
|
{a.content && (
|
||||||
<div className="mt-1 line-clamp-2 rounded-lg bg-white/[0.03] px-2 py-1 text-xs text-[var(--color-ink-faint)]">
|
<div className="mt-1 line-clamp-2 rounded-[8px] bg-white/[0.03] px-2 py-1 text-xs text-ink-faint">
|
||||||
{a.content}
|
{a.content}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
{a.error ? (
|
{a.error && (
|
||||||
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
<div className="mt-1 text-xs text-vermilion">{a.error}</div>
|
||||||
) : null}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,25 +1,18 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Recordings scene — voice clips orbit as a timeline ring on the stage;
|
|
||||||
* the clip archive docks right with inline audio players, playing clip
|
|
||||||
* highlighted both in the dock and on the sky.
|
|
||||||
*/
|
|
||||||
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
import { Download, Hash, Headphones, Loader2, Trash2 } from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import {
|
import {
|
||||||
Avatar,
|
Avatar,
|
||||||
Badge,
|
Badge,
|
||||||
Button,
|
Button,
|
||||||
|
GlassCard,
|
||||||
|
GlassPanel,
|
||||||
Skeleton,
|
Skeleton,
|
||||||
toast,
|
toast,
|
||||||
} from "@/components/primitives";
|
} from "@/components/primitives";
|
||||||
import { EmptyState, ErrorState } from "@/components/shared";
|
import { EmptyState, ErrorState, SectionHeader } from "@/components/shared";
|
||||||
import {
|
|
||||||
useSceneFocusSetter,
|
|
||||||
useScenePublish,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
import {
|
import {
|
||||||
NowPlayingChip,
|
NowPlayingChip,
|
||||||
RecordingAudioPlayer,
|
RecordingAudioPlayer,
|
||||||
@@ -29,58 +22,23 @@ import {
|
|||||||
useRecordings,
|
useRecordings,
|
||||||
useRecordingsWsSync,
|
useRecordingsWsSync,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
import { formatBytes, formatRelativeTime } from "@/lib/format";
|
||||||
import type { VoiceRecording } from "@/lib/types";
|
import type { VoiceRecording } from "@/lib/types";
|
||||||
import { staggerDelay } from "@/lib/utils";
|
import { staggerDelay } from "@/lib/utils";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
function recordingsGraph(items: VoiceRecording[]): ConstellationGraph {
|
|
||||||
const recent = items.slice(0, 28);
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{ id: "archive", label: "archive", kind: "guild", value: 1 },
|
|
||||||
...recent.map((r) => ({
|
|
||||||
id: `rec:${r.id}`,
|
|
||||||
label: r.username,
|
|
||||||
kind: "media" as const,
|
|
||||||
value: 0.5,
|
|
||||||
href: undefined,
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
edges: recent.map((r) => ({ source: "archive", target: `rec:${r.id}` })),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecordingsView({
|
export function RecordingsView({
|
||||||
initialItems,
|
initialItems,
|
||||||
}: {
|
}: {
|
||||||
initialItems?: VoiceRecording[];
|
initialItems?: VoiceRecording[];
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: items, isLoading, error } = useRecordings(initialItems);
|
const { data: items, isLoading, error, mutate } = useRecordings(initialItems);
|
||||||
const del = useDeleteRecording();
|
const del = useDeleteRecording();
|
||||||
useRecordingsWsSync(ws);
|
useRecordingsWsSync(ws);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
const [playingId, setPlayingId] = useState<string | null>(null);
|
const [playingId, setPlayingId] = useState<string | null>(null);
|
||||||
|
|
||||||
const list = items ?? [];
|
|
||||||
|
|
||||||
const graph = useMemo(() => recordingsGraph(list), [list]);
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: playingId ? `rec:${playingId}` : null });
|
|
||||||
}, [graph, playingId, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
ambient.set("signal", 0.3, "recordings");
|
ambient.set("signal", 0.3, "recordings");
|
||||||
}, [ambient]);
|
}, [ambient]);
|
||||||
@@ -98,20 +56,18 @@ export function RecordingsView({
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (error && !items) return <ErrorState error={error} />;
|
if (error && !items)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!items && isLoading)
|
if (!items && isLoading)
|
||||||
return (
|
return (
|
||||||
<div className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 p-4 backdrop-blur-xl md:inset-x-auto md:right-5 md:top-28 md:w-[min(30rem,92vw)]">
|
<GlassPanel>
|
||||||
<div className="mb-3 flex items-center justify-between">
|
<div className="mb-3 flex items-center justify-between">
|
||||||
<Skeleton className="h-4 w-28" />
|
<Skeleton className="h-4 w-28" />
|
||||||
<Skeleton className="h-3 w-12" />
|
<Skeleton className="h-3 w-12" />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-3">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{Array.from({ length: 5 }).map((_, i) => (
|
{Array.from({ length: 6 }).map((_, i) => (
|
||||||
<div
|
<GlassCard key={i} className="flex flex-col gap-3">
|
||||||
key={i}
|
|
||||||
className="flex flex-col gap-3 rounded-xl border border-[var(--color-hairline)] p-3"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Skeleton className="size-9 rounded-full" />
|
<Skeleton className="size-9 rounded-full" />
|
||||||
<div className="flex-1 space-y-2">
|
<div className="flex-1 space-y-2">
|
||||||
@@ -120,67 +76,64 @@ export function RecordingsView({
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<Skeleton className="h-9 w-full" />
|
<Skeleton className="h-9 w-full" />
|
||||||
</div>
|
</GlassCard>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</GlassPanel>
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<GlassPanel>
|
||||||
{/* Archive dock — right */}
|
<SectionHeader
|
||||||
<section
|
eyebrow="voice captures"
|
||||||
className="pointer-events-auto absolute bottom-20 right-5 top-16 hidden w-[min(30rem,92vw)] flex-col overflow-hidden rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/70 backdrop-blur-xl md:flex"
|
title="Recordings"
|
||||||
aria-label="Voice captures"
|
action={
|
||||||
>
|
<span className="mono text-xs text-ink-faint">
|
||||||
<div className="flex items-center justify-between border-b border-[var(--color-hairline)] px-4 py-2.5">
|
{(items ?? []).length} clips
|
||||||
<span className="eyebrow">voice captures</span>
|
|
||||||
<span className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
{list.length} clips
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
}
|
||||||
<div className="min-h-0 flex-1 overflow-y-auto p-2.5">
|
/>
|
||||||
{list.length === 0 ? (
|
{(items ?? []).length === 0 ? (
|
||||||
<EmptyState
|
<EmptyState
|
||||||
icon={<Headphones className="size-6" />}
|
icon={<Headphones className="size-7" />}
|
||||||
title="No recordings"
|
title="No recordings"
|
||||||
description="Voice clips captured by the bot appear here."
|
description="Voice clips captured by the bot appear here."
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="space-y-2">
|
<div className="grid gap-3 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{list.map((r, i) => {
|
{(items ?? []).map((r, i) => {
|
||||||
const up = uploadStatus(r);
|
const up = uploadStatus(r);
|
||||||
const isPlaying = playingId === r.id;
|
const isPlaying = playingId === r.id;
|
||||||
return (
|
return (
|
||||||
<article
|
<GlassCard
|
||||||
key={r.id}
|
key={r.id}
|
||||||
className={`animate-stagger flex flex-col gap-2.5 rounded-xl border p-3 transition-colors ${
|
className={`animate-stagger flex flex-col gap-3 transition-colors hover:bg-white/[0.06] ${
|
||||||
isPlaying
|
isPlaying
|
||||||
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
? "border-signal/40 shadow-[0_0_36px_-16px_var(--color-signal-glow)]"
|
||||||
: "border-[var(--color-hairline)] hover:bg-white/[0.04]"
|
: ""
|
||||||
}`}
|
}`}
|
||||||
style={staggerDelay(i)}
|
style={staggerDelay(i)}
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<Avatar src={r.avatar_url} name={r.username} size={34} />
|
<Avatar src={r.avatar_url} name={r.username} size={38} />
|
||||||
<div className="min-w-0 flex-1">
|
<div className="min-w-0 flex-1">
|
||||||
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
<div className="truncate text-sm font-semibold text-ink">
|
||||||
{r.username}
|
{r.username}
|
||||||
</p>
|
</div>
|
||||||
<p className="flex items-center gap-1.5 font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
<div className="flex items-center gap-1.5 text-[0.65rem] text-ink-faint">
|
||||||
<Hash className="size-3" />
|
<Hash className="size-3" />
|
||||||
<span className="truncate">
|
<span className="truncate">
|
||||||
{r.channel_name ?? "voice"}
|
{r.channel_name ?? "voice"}
|
||||||
</span>
|
</span>
|
||||||
<span>·</span>
|
<span className="text-ink-faint/60">·</span>
|
||||||
<span>{formatRelativeTime(r.created_at)}</span>
|
<span className="mono">
|
||||||
</p>
|
{formatRelativeTime(r.created_at)}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{isPlaying ? <NowPlayingChip /> : null}
|
</div>
|
||||||
{up && !isPlaying ? (
|
{isPlaying && <NowPlayingChip />}
|
||||||
<Badge tone={up.tone}>{up.label}</Badge>
|
{up && !isPlaying && <Badge tone={up.tone}>{up.label}</Badge>}
|
||||||
) : null}
|
<span className="mono text-[0.65rem] text-ink-faint">
|
||||||
<span className="font-mono text-[0.65rem] text-[var(--color-ink-faint)]">
|
|
||||||
{formatBytes(r.size_bytes)}
|
{formatBytes(r.size_bytes)}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
@@ -192,32 +145,33 @@ export function RecordingsView({
|
|||||||
onPlayStateChange={(active) =>
|
onPlayStateChange={(active) =>
|
||||||
setPlayingId((prev) => {
|
setPlayingId((prev) => {
|
||||||
if (active) return r.id;
|
if (active) return r.id;
|
||||||
|
// Only clear if THIS card was the one playing.
|
||||||
return prev === r.id ? null : prev;
|
return prev === r.id ? null : prev;
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
) : (
|
) : (
|
||||||
<p className="flex items-center gap-1.5 rounded-lg px-1 py-1 font-mono text-xs text-[var(--color-ink-faint)]">
|
<div className="flex items-center gap-1.5 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-faint">
|
||||||
<Loader2 className="size-3.5 animate-spin" />
|
<Loader2 className="size-3.5 animate-spin" />
|
||||||
{r.upload_status === "pending"
|
{r.upload_status === "pending"
|
||||||
? "Upload pending…"
|
? "Upload pending…"
|
||||||
: r.upload_error
|
: r.upload_error
|
||||||
? r.upload_error
|
? r.upload_error
|
||||||
: "Processing…"}
|
: "Processing…"}
|
||||||
</p>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{r.download_url ? (
|
{r.download_url && (
|
||||||
<a
|
<a
|
||||||
href={r.download_url}
|
href={r.download_url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center gap-1.5 rounded-full border border-[var(--color-hairline)] px-2.5 py-1 font-mono text-xs text-[var(--color-ink-soft)] hover:border-signal/40 hover:text-[var(--color-ink)]"
|
className="inline-flex items-center gap-1.5 rounded-[9px] border border-hairline px-2.5 py-1.5 text-xs text-ink-soft hover:text-ink hover:border-signal/40"
|
||||||
>
|
>
|
||||||
<Download className="size-3.5" /> Download
|
<Download className="size-3.5" /> Download
|
||||||
</a>
|
</a>
|
||||||
) : null}
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -228,60 +182,12 @@ export function RecordingsView({
|
|||||||
<Trash2 className="size-3.5" /> Delete
|
<Trash2 className="size-3.5" /> Delete
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</GlassCard>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</GlassPanel>
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Mobile dock */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-24 top-44 overflow-y-auto rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/80 p-2.5 backdrop-blur-xl md:hidden"
|
|
||||||
aria-label="Voice captures mobile"
|
|
||||||
>
|
|
||||||
{list.length === 0 ? (
|
|
||||||
<EmptyState title="No recordings" />
|
|
||||||
) : (
|
|
||||||
list.map((r) => (
|
|
||||||
<article
|
|
||||||
key={r.id}
|
|
||||||
className={`mb-2 flex flex-col gap-2 rounded-xl border p-3 ${
|
|
||||||
playingId === r.id
|
|
||||||
? "border-signal/40"
|
|
||||||
: "border-[var(--color-hairline)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
<p className="truncate text-sm font-semibold text-[var(--color-ink)]">
|
|
||||||
{r.username} · {formatRelativeTime(r.created_at)}
|
|
||||||
</p>
|
|
||||||
{r.download_url ? (
|
|
||||||
<RecordingAudioPlayer
|
|
||||||
src={r.download_url}
|
|
||||||
label={`Voice recording by ${r.username}`}
|
|
||||||
onPlayStateChange={(active) =>
|
|
||||||
setPlayingId((prev) =>
|
|
||||||
active ? r.id : prev === r.id ? null : prev,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
processing…
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</article>
|
|
||||||
))
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Hint */}
|
|
||||||
<p className="pointer-events-none absolute bottom-24 left-5 hidden max-w-xs font-mono text-xs text-[var(--color-ink-faint)] lg:block">
|
|
||||||
setiap klip adalah simpel di cincin timeline — yang sedang play menyala
|
|
||||||
di langit
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,10 +1,5 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
/**
|
|
||||||
* Voice scene — the stage node sits center; live speakers orbit it with
|
|
||||||
* glow (published to the constellation). Controls float bottom-left,
|
|
||||||
* connections whisper right, and the mic/listen meters stay docked.
|
|
||||||
*/
|
|
||||||
import {
|
import {
|
||||||
Headphones,
|
Headphones,
|
||||||
Mic,
|
Mic,
|
||||||
@@ -14,16 +9,17 @@ import {
|
|||||||
Volume2,
|
Volume2,
|
||||||
Waves,
|
Waves,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { useAmbient } from "@/components/ambient/ambient-context";
|
import { useAmbient } from "@/components/ambient/ambient-context";
|
||||||
import { Equalizer } from "@/components/charts";
|
import { Equalizer } from "@/components/charts";
|
||||||
import { Button, toast } from "@/components/primitives";
|
import { Button, GlassPanel, toast } from "@/components/primitives";
|
||||||
import { EmptyState, ErrorState } from "@/components/shared";
|
|
||||||
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
|
||||||
import {
|
import {
|
||||||
useSceneFocusSetter,
|
EmptyState,
|
||||||
useScenePublish,
|
ErrorState,
|
||||||
} from "@/components/shell/scene-graph-context";
|
SectionHeader,
|
||||||
|
SkeletonPanel,
|
||||||
|
} from "@/components/shared";
|
||||||
|
import { GuildChannelPicker } from "@/components/shared/guild-picker";
|
||||||
import { VoiceStage } from "@/components/voice/voice-stage";
|
import { VoiceStage } from "@/components/voice/voice-stage";
|
||||||
import {
|
import {
|
||||||
useMicTransmit,
|
useMicTransmit,
|
||||||
@@ -33,37 +29,9 @@ import {
|
|||||||
useVoiceListen,
|
useVoiceListen,
|
||||||
useVoiceStatus,
|
useVoiceStatus,
|
||||||
} from "@/hooks";
|
} from "@/hooks";
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import type { Guild, VoiceStatus } from "@/lib/types";
|
import type { Guild, VoiceStatus } from "@/lib/types";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
function speakerGraph(
|
|
||||||
speakers: {
|
|
||||||
userId: string;
|
|
||||||
username: string;
|
|
||||||
speaking: boolean;
|
|
||||||
level?: number;
|
|
||||||
}[],
|
|
||||||
): ConstellationGraph {
|
|
||||||
return {
|
|
||||||
nodes: [
|
|
||||||
{ id: "stage", label: "voice stage", kind: "guild", value: 1 },
|
|
||||||
...speakers.map((sp) => ({
|
|
||||||
id: `speaker:${sp.userId}`,
|
|
||||||
label: sp.username,
|
|
||||||
kind: "speaker" as const,
|
|
||||||
value: sp.speaking ? 0.8 : 0.4,
|
|
||||||
href: undefined,
|
|
||||||
meta: { speaking: sp.speaking },
|
|
||||||
})),
|
|
||||||
],
|
|
||||||
edges: speakers.map((sp) => ({
|
|
||||||
source: "stage",
|
|
||||||
target: `speaker:${sp.userId}`,
|
|
||||||
})),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function VoiceView({
|
export function VoiceView({
|
||||||
initialStatus,
|
initialStatus,
|
||||||
initialGuilds,
|
initialGuilds,
|
||||||
@@ -72,15 +40,18 @@ export function VoiceView({
|
|||||||
initialGuilds?: Guild[];
|
initialGuilds?: Guild[];
|
||||||
}) {
|
}) {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { data: status, isLoading, error } = useVoiceStatus(initialStatus);
|
const {
|
||||||
|
data: status,
|
||||||
|
isLoading,
|
||||||
|
error,
|
||||||
|
mutate,
|
||||||
|
} = useVoiceStatus(initialStatus);
|
||||||
const connect = useVoiceConnect();
|
const connect = useVoiceConnect();
|
||||||
const disconnect = useVoiceDisconnect();
|
const disconnect = useVoiceDisconnect();
|
||||||
const mic = useMicTransmit(ws);
|
const mic = useMicTransmit(ws);
|
||||||
const listen = useVoiceListen(ws);
|
const listen = useVoiceListen(ws);
|
||||||
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
const { speakers, subscribe } = useSpeakers(initialStatus?.activeSpeakers);
|
||||||
const ambient = useAmbient();
|
const ambient = useAmbient();
|
||||||
const publish = useScenePublish();
|
|
||||||
const setFocus = useSceneFocusSetter();
|
|
||||||
|
|
||||||
const [guildId, setGuildId] = useState<string | null>(
|
const [guildId, setGuildId] = useState<string | null>(
|
||||||
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
initialStatus?.activeGuildId ?? initialGuilds?.[0]?.id ?? null,
|
||||||
@@ -97,30 +68,22 @@ export function VoiceView({
|
|||||||
return unsub;
|
return unsub;
|
||||||
}, [subscribe, ws]);
|
}, [subscribe, ws]);
|
||||||
|
|
||||||
const graph = useMemo(() => speakerGraph(speakers), [speakers]);
|
|
||||||
useEffect(() => {
|
|
||||||
publish({ graph, focus: null });
|
|
||||||
}, [graph, publish]);
|
|
||||||
|
|
||||||
useEffect(
|
|
||||||
() => () => {
|
|
||||||
publish({ graph: { nodes: [], edges: [] }, focus: null });
|
|
||||||
setFocus(null);
|
|
||||||
},
|
|
||||||
[publish, setFocus],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
if (status?.connected) ambient.set("signal", 0.55, "voice live");
|
||||||
else ambient.set("vermilion", 0.35, "voice idle");
|
else ambient.set("vermilion", 0.35, "voice idle");
|
||||||
}, [status?.connected, ambient]);
|
}, [status?.connected, ambient]);
|
||||||
|
|
||||||
if (error && !status) return <ErrorState error={error} />;
|
if (error && !status)
|
||||||
|
return <ErrorState error={error} onRetry={() => void mutate()} />;
|
||||||
if (!status && isLoading)
|
if (!status && isLoading)
|
||||||
return (
|
return (
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
<div className="space-y-5">
|
||||||
menyetel ulang stage…
|
<SkeletonPanel rows={2} />
|
||||||
</p>
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<SkeletonPanel className="lg:col-span-2" rows={4} />
|
||||||
|
<SkeletonPanel rows={4} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
|
|
||||||
const connected = status?.connected ?? false;
|
const connected = status?.connected ?? false;
|
||||||
@@ -161,64 +124,9 @@ export function VoiceView({
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="min-h-full">
|
<div className="space-y-5">
|
||||||
{/* Stage presence — top-center whisper */}
|
<GlassPanel>
|
||||||
<section
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
className="pointer-events-none absolute inset-x-0 top-16 hidden justify-center md:flex"
|
|
||||||
aria-label="Live speakers"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-[60vw] flex-wrap justify-center gap-2">
|
|
||||||
{speakers.length === 0 ? (
|
|
||||||
<EmptyState
|
|
||||||
icon={<MicOff className="size-6" />}
|
|
||||||
title={connected ? "Silent right now" : "Not connected"}
|
|
||||||
description={
|
|
||||||
connected
|
|
||||||
? "Speakers appear as they talk."
|
|
||||||
: "Connect to a voice channel to see presence."
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<VoiceStage speakers={speakers} />
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Connections — right */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute right-5 top-16 hidden w-64 md:block"
|
|
||||||
aria-label="Connections"
|
|
||||||
>
|
|
||||||
<p className="eyebrow mb-2">links · {speakers.length} present</p>
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
{(status?.connections ?? []).map((c) => (
|
|
||||||
<div
|
|
||||||
key={`${c.guildId}-${c.channelId}`}
|
|
||||||
className="flex items-center gap-2 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-3 py-1.5 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-md"
|
|
||||||
>
|
|
||||||
<span className="size-2 rounded-full bg-signal" />
|
|
||||||
<span className="flex-1 truncate">{c.channelName}</span>
|
|
||||||
<span className="text-[var(--color-ink-faint)]">
|
|
||||||
{new Date(c.connectedAt).toLocaleTimeString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{(status?.connections ?? []).length === 0 ? (
|
|
||||||
<p className="font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
no active links
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<p className="pt-1 font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
channel: {status?.activeChannelName ?? "—"}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Console — bottom-left */}
|
|
||||||
<section
|
|
||||||
className="pointer-events-auto absolute bottom-20 left-5 z-20 w-[min(24rem,92vw)] space-y-2.5 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/75 p-3 backdrop-blur-xl lg:bottom-24"
|
|
||||||
aria-label="Voice console"
|
|
||||||
>
|
|
||||||
<GuildChannelPicker
|
<GuildChannelPicker
|
||||||
mode="voice"
|
mode="voice"
|
||||||
guildsInitial={initialGuilds}
|
guildsInitial={initialGuilds}
|
||||||
@@ -249,6 +157,10 @@ export function VoiceView({
|
|||||||
<Radio className="size-4" /> Connect
|
<Radio className="size-4" /> Connect
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
<Button
|
<Button
|
||||||
variant={micOn ? "primary" : "outline"}
|
variant={micOn ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -259,6 +171,16 @@ export function VoiceView({
|
|||||||
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
{micOn ? <Mic className="size-4" /> : <MicOff className="size-4" />}
|
||||||
{micOn ? "Mic live" : "Push-to-talk"}
|
{micOn ? "Mic live" : "Push-to-talk"}
|
||||||
</Button>
|
</Button>
|
||||||
|
{micOn && (
|
||||||
|
<div
|
||||||
|
className="flex items-center gap-2 rounded-[10px] border border-signal/30 bg-signal/[0.06] px-3 py-1.5"
|
||||||
|
role="status"
|
||||||
|
aria-label="Microphone level meter"
|
||||||
|
>
|
||||||
|
<Mic className="size-4 text-signal" />
|
||||||
|
<Equalizer bars={micBars} className="w-28" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<Button
|
<Button
|
||||||
variant={listen.active ? "primary" : "outline"}
|
variant={listen.active ? "primary" : "outline"}
|
||||||
size="sm"
|
size="sm"
|
||||||
@@ -271,27 +193,14 @@ export function VoiceView({
|
|||||||
)}
|
)}
|
||||||
{listen.active ? "Listening" : "Listen in"}
|
{listen.active ? "Listening" : "Listen in"}
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
{listen.active && (
|
||||||
|
<div className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-1.5">
|
||||||
{micOn ? (
|
<Waves className="size-4 text-signal" />
|
||||||
<div
|
|
||||||
className="flex items-center gap-2 px-1"
|
|
||||||
role="status"
|
|
||||||
aria-label="Microphone level meter"
|
|
||||||
>
|
|
||||||
<Mic className="size-3.5 text-signal" />
|
|
||||||
<Equalizer bars={micBars} className="w-28" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
{listen.active ? (
|
|
||||||
<div className="flex items-center gap-2 px-1">
|
|
||||||
<Waves className="size-3.5 text-signal" />
|
|
||||||
<Equalizer bars={listenBars} className="w-40" />
|
<Equalizer bars={listenBars} className="w-40" />
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
)}
|
||||||
|
<div className="ml-auto flex items-center gap-3">
|
||||||
<div className="flex items-center gap-4 pt-1">
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
|
||||||
<MicOff className="size-3.5" />
|
<MicOff className="size-3.5" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -304,11 +213,11 @@ export function VoiceView({
|
|||||||
mic.setVolume(v);
|
mic.setVolume(v);
|
||||||
}}
|
}}
|
||||||
aria-label="Mic transmit volume"
|
aria-label="Mic transmit volume"
|
||||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right">{micVol}%</span>
|
<span className="mono w-8 text-right">{micVol}%</span>
|
||||||
</label>
|
</label>
|
||||||
<label className="flex items-center gap-2 font-mono text-xs text-[var(--color-ink-faint)]">
|
<label className="flex items-center gap-2 text-xs text-ink-faint">
|
||||||
<Volume2 className="size-3.5" />
|
<Volume2 className="size-3.5" />
|
||||||
<input
|
<input
|
||||||
type="range"
|
type="range"
|
||||||
@@ -321,12 +230,88 @@ export function VoiceView({
|
|||||||
listen.setVolume(v);
|
listen.setVolume(v);
|
||||||
}}
|
}}
|
||||||
aria-label="Listen volume"
|
aria-label="Listen volume"
|
||||||
className="h-1 w-20 cursor-pointer accent-[var(--color-signal)]"
|
className="h-1 w-24 cursor-pointer accent-[var(--color-signal)]"
|
||||||
/>
|
/>
|
||||||
<span className="w-8 text-right">{listenVol}%</span>
|
<span className="mono w-8 text-right">{listenVol}%</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</section>
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<div className="grid gap-5 lg:grid-cols-3">
|
||||||
|
<GlassPanel className="lg:col-span-2">
|
||||||
|
<SectionHeader
|
||||||
|
eyebrow="stage"
|
||||||
|
title="Live speakers"
|
||||||
|
action={
|
||||||
|
<span className="mono text-xs text-ink-faint">
|
||||||
|
{speakers.length} present
|
||||||
|
</span>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
{speakers.length === 0 ? (
|
||||||
|
<EmptyState
|
||||||
|
icon={<MicOff className="size-7" />}
|
||||||
|
title={connected ? "Silent right now" : "Not connected"}
|
||||||
|
description={
|
||||||
|
connected
|
||||||
|
? "Speakers appear as they talk."
|
||||||
|
: "Connect to a voice channel to see presence."
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<VoiceStage speakers={speakers} />
|
||||||
|
<div className="mt-2 flex flex-wrap justify-center gap-2">
|
||||||
|
{speakers.map((sp) => (
|
||||||
|
<span
|
||||||
|
key={sp.userId}
|
||||||
|
className={`mono flex items-center gap-1.5 rounded-full border px-2.5 py-1 text-xs ${
|
||||||
|
sp.speaking
|
||||||
|
? "border-signal/40 bg-signal/10 text-signal"
|
||||||
|
: "border-hairline bg-white/5 text-ink-soft"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`size-1.5 rounded-full ${sp.speaking ? "bg-signal animate-breathe" : "bg-ink-faint"}`}
|
||||||
|
/>
|
||||||
|
{sp.username}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</GlassPanel>
|
||||||
|
|
||||||
|
<GlassPanel>
|
||||||
|
<SectionHeader eyebrow="links" title="Connections" />
|
||||||
|
<div className="space-y-2">
|
||||||
|
{(status?.connections ?? []).map((c) => (
|
||||||
|
<div
|
||||||
|
key={`${c.guildId}-${c.channelId}`}
|
||||||
|
className="flex items-center gap-2 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-sm"
|
||||||
|
>
|
||||||
|
<span className="size-2 rounded-full bg-signal" />
|
||||||
|
<span className="flex-1 truncate text-ink-soft">
|
||||||
|
{c.channelName}
|
||||||
|
</span>
|
||||||
|
<span className="mono text-[0.6rem] text-ink-faint">
|
||||||
|
{new Date(c.connectedAt).toLocaleTimeString()}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
{(status?.connections ?? []).length === 0 && (
|
||||||
|
<div className="py-6 text-center text-xs text-ink-faint">
|
||||||
|
No active links
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="mt-3 rounded-[10px] border border-hairline bg-white/5 px-3 py-2 text-xs text-ink-soft">
|
||||||
|
<span className="mono text-ink-faint">channel</span>{" "}
|
||||||
|
{status?.activeChannelName ?? "—"}
|
||||||
|
</div>
|
||||||
|
</GlassPanel>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -17,7 +17,7 @@
|
|||||||
/* ── Surfaces (dark-first; light overrides below) ── */
|
/* ── Surfaces (dark-first; light overrides below) ── */
|
||||||
--color-canvas: oklch(0.12 0.014 70);
|
--color-canvas: oklch(0.12 0.014 70);
|
||||||
--color-canvas-2: oklch(0.16 0.02 70);
|
--color-canvas-2: oklch(0.16 0.02 70);
|
||||||
--color-surface: oklch(0.2 0.022 70 / 0.55);
|
--color-surface: oklch(0.2 0.022 70 / 0.45);
|
||||||
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
--color-surface-2: oklch(0.26 0.024 70 / 0.45);
|
||||||
|
|
||||||
/* ── Ink ── */
|
/* ── Ink ── */
|
||||||
@@ -29,13 +29,13 @@
|
|||||||
--color-hairline: oklch(1 0 0 / 0.1);
|
--color-hairline: oklch(1 0 0 / 0.1);
|
||||||
--hairline-w: 1px;
|
--hairline-w: 1px;
|
||||||
|
|
||||||
/* ── Semantic signals ── */
|
/* ── Semantic signals (monochrome: tone = luminance steps) ── */
|
||||||
--color-signal: oklch(0.86 0.19 128);
|
--color-signal: oklch(0.97 0 0);
|
||||||
--color-signal-ink: oklch(0.18 0.03 70);
|
--color-signal-ink: oklch(0.1 0 0);
|
||||||
--color-signal-glow: oklch(0.86 0.19 128 / 0.4);
|
--color-signal-glow: oklch(1 0 0 / 0.35);
|
||||||
--color-amber: oklch(0.85 0.15 72);
|
--color-amber: oklch(0.72 0 0);
|
||||||
--color-vermilion: oklch(0.68 0.21 25);
|
--color-vermilion: oklch(0.85 0 0);
|
||||||
--color-vermilion-glow: oklch(0.68 0.21 25 / 0.4);
|
--color-vermilion-glow: oklch(1 0 0 / 0.45);
|
||||||
|
|
||||||
--color-ring: var(--color-signal);
|
--color-ring: var(--color-signal);
|
||||||
|
|
||||||
@@ -74,13 +74,8 @@
|
|||||||
background: oklch(0.22 0.02 70 / 0.34);
|
background: oklch(0.22 0.02 70 / 0.34);
|
||||||
}
|
}
|
||||||
.light ::selection {
|
.light ::selection {
|
||||||
background: var(--color-signal-glow);
|
background: oklch(0.2 0 0 / 0.9);
|
||||||
color: var(--color-ink);
|
color: oklch(1 0 0);
|
||||||
}
|
|
||||||
/* Constellation stage: soften glow layers so stars stay legible on pale sky */
|
|
||||||
.light .glass-glow,
|
|
||||||
.light [class*="shadow-[0_0"] {
|
|
||||||
--tw-shadow-color: oklch(0.5 0.1 150 / 0.18);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@layer base {
|
@layer base {
|
||||||
@@ -305,6 +300,144 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ═══ Game-menu nav (monochrome) ═══
|
||||||
|
Corner brackets + one-shot light sweep + staggered entrance.
|
||||||
|
Pure transform/opacity — compositor only. */
|
||||||
|
|
||||||
|
.game-nav-item {
|
||||||
|
--i: 0;
|
||||||
|
position: relative;
|
||||||
|
isolation: isolate;
|
||||||
|
animation: nav-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both;
|
||||||
|
animation-delay: calc(var(--i) * 45ms);
|
||||||
|
}
|
||||||
|
/* corner brackets on hover/active */
|
||||||
|
.game-nav-item::before,
|
||||||
|
.game-nav-item::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s ease;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.game-nav-item::before {
|
||||||
|
border-left: 2px solid currentColor;
|
||||||
|
border-top: 2px solid currentColor;
|
||||||
|
border-top-left-radius: 13px;
|
||||||
|
clip-path: polygon(0 0, 62% 0, 62% 30%, 30% 30%, 30% 100%, 0 100%);
|
||||||
|
}
|
||||||
|
.game-nav-item::after {
|
||||||
|
border-right: 2px solid currentColor;
|
||||||
|
border-bottom: 2px solid currentColor;
|
||||||
|
border-bottom-right-radius: 13px;
|
||||||
|
clip-path: polygon(100% 100%, 38% 100%, 38% 70%, 70% 70%, 70% 0, 100% 0);
|
||||||
|
}
|
||||||
|
.game-nav-item:hover::before,
|
||||||
|
.game-nav-item:hover::after,
|
||||||
|
.game-nav-item.is-active::before,
|
||||||
|
.game-nav-item.is-active::after {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
/* one-shot sweep */
|
||||||
|
.game-nav-item .game-sweep {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
.game-nav-item .game-sweep > i {
|
||||||
|
position: absolute;
|
||||||
|
top: -20%;
|
||||||
|
bottom: -20%;
|
||||||
|
width: 34%;
|
||||||
|
left: -60%;
|
||||||
|
background: linear-gradient(
|
||||||
|
105deg,
|
||||||
|
transparent,
|
||||||
|
oklch(1 0 0 / 0.16),
|
||||||
|
transparent
|
||||||
|
);
|
||||||
|
transform: skewX(-14deg);
|
||||||
|
}
|
||||||
|
.game-nav-item:hover .game-sweep > i,
|
||||||
|
.game-nav-item.is-active .game-sweep > i {
|
||||||
|
animation: sweep-x 0.55s ease-out both;
|
||||||
|
}
|
||||||
|
/* active triangle marker (game cursor) */
|
||||||
|
.game-marker {
|
||||||
|
position: absolute;
|
||||||
|
top: 50%;
|
||||||
|
width: 0;
|
||||||
|
height: 0;
|
||||||
|
border-block: 7px solid transparent;
|
||||||
|
border-left: 11px solid currentColor;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
filter: drop-shadow(0 0 6px var(--color-signal-glow));
|
||||||
|
animation: marker-nudge 1.6s ease-in-out infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes marker-nudge {
|
||||||
|
0%, 100% { transform: translateY(-50%) translateX(0); }
|
||||||
|
50% { transform: translateY(-50%) translateX(3px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes sweep-x {
|
||||||
|
from { left: -60%; }
|
||||||
|
to { left: 130%; }
|
||||||
|
}
|
||||||
|
@keyframes nav-in {
|
||||||
|
from { opacity: 0; transform: translateX(-10px); }
|
||||||
|
to { opacity: 1; transform: translateX(0); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Game frame: panel with cut corners + drawn-in edge lines ── */
|
||||||
|
.game-frame {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
.game-frame::before {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
border-radius: inherit;
|
||||||
|
pointer-events: none;
|
||||||
|
background:
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) top left /
|
||||||
|
26px 1.5px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) top left / 1.5px
|
||||||
|
26px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) top right /
|
||||||
|
26px 1.5px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) top right / 1.5px
|
||||||
|
26px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) bottom left /
|
||||||
|
26px 1.5px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) bottom left /
|
||||||
|
1.5px 26px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) bottom right /
|
||||||
|
26px 1.5px,
|
||||||
|
linear-gradient(var(--color-hairline), var(--color-hairline)) bottom right /
|
||||||
|
1.5px 26px;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* ── Mobile lightness (<md): cheaper blur, shorter animations ── */
|
||||||
|
@media (max-width: 767px) {
|
||||||
|
.glass {
|
||||||
|
backdrop-filter: blur(8px);
|
||||||
|
-webkit-backdrop-filter: blur(8px);
|
||||||
|
}
|
||||||
|
.game-nav-item {
|
||||||
|
animation-duration: 0.25s;
|
||||||
|
animation-delay: calc(var(--i) * 18ms);
|
||||||
|
}
|
||||||
|
.scan-line::after,
|
||||||
|
.animate-shimmer {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
.scan-line::after,
|
.scan-line::after,
|
||||||
.scan-line,
|
.scan-line,
|
||||||
|
|||||||
@@ -67,6 +67,19 @@ export function AmbientCanvas({
|
|||||||
const mount = mountRef.current;
|
const mount = mountRef.current;
|
||||||
if (!mount) return;
|
if (!mount) return;
|
||||||
|
|
||||||
|
// Lightness gate: skip the WebGL layer entirely on small/coarse/data-saver
|
||||||
|
// devices — the static CSS fallback below stays. Desktop keeps the haze.
|
||||||
|
const nav = navigator as Navigator & {
|
||||||
|
connection?: { saveData?: boolean; deviceMemory?: number };
|
||||||
|
deviceMemory?: number;
|
||||||
|
};
|
||||||
|
const lightDevice =
|
||||||
|
window.matchMedia("(max-width: 767px)").matches ||
|
||||||
|
window.matchMedia("(pointer: coarse)").matches ||
|
||||||
|
nav.connection?.saveData === true ||
|
||||||
|
(nav.deviceMemory ?? 8) <= 4;
|
||||||
|
if (lightDevice) return;
|
||||||
|
|
||||||
let renderer: THREE.WebGLRenderer;
|
let renderer: THREE.WebGLRenderer;
|
||||||
try {
|
try {
|
||||||
renderer = new THREE.WebGLRenderer({
|
renderer = new THREE.WebGLRenderer({
|
||||||
|
|||||||
@@ -12,11 +12,14 @@ import { AmbientCanvas } from "./ambient-canvas";
|
|||||||
|
|
||||||
export type SignalTone = "signal" | "amber" | "vermilion";
|
export type SignalTone = "signal" | "amber" | "vermilion";
|
||||||
|
|
||||||
/** sRGB triplets for the three semantic signals (matches globals.css). */
|
/**
|
||||||
|
* sRGB triplets for the three semantic signals — MONOCHROME build:
|
||||||
|
* tone is expressed as luminance/intensity, not hue (black & white theme).
|
||||||
|
*/
|
||||||
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
|
export const SIGNAL_RGB: Record<SignalTone, [number, number, number]> = {
|
||||||
signal: [0.42, 1.0, 0.52],
|
signal: [0.92, 0.94, 0.96], // nominal — near-white
|
||||||
amber: [1.0, 0.76, 0.28],
|
amber: [0.62, 0.64, 0.68], // warn — mid grey
|
||||||
vermilion: [1.0, 0.34, 0.28],
|
vermilion: [0.78, 0.8, 0.84], // danger — bright flash grey
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface AmbientState {
|
export interface AmbientState {
|
||||||
|
|||||||
@@ -0,0 +1,56 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Component, type ReactNode } from "react";
|
||||||
|
import { ErrorState } from "./states";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* React error boundary — catches render/exception crashes in child trees
|
||||||
|
* (e.g. third-party lib throwing on unexpected payload shape) and surfaces
|
||||||
|
* a consistent ErrorState instead of unmounting the whole app shell.
|
||||||
|
*
|
||||||
|
* Usage: wrap leaf views in <ErrorBoundary><SomeView /></ErrorBoundary>.
|
||||||
|
*/
|
||||||
|
interface ErrorBoundaryProps {
|
||||||
|
children: ReactNode;
|
||||||
|
fallback?: (error: Error, reset: () => void) => ReactNode;
|
||||||
|
onReset?: () => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ErrorBoundaryState {
|
||||||
|
error: Error | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export class ErrorBoundary extends Component<
|
||||||
|
ErrorBoundaryProps,
|
||||||
|
ErrorBoundaryState
|
||||||
|
> {
|
||||||
|
constructor(props: ErrorBoundaryProps) {
|
||||||
|
super(props);
|
||||||
|
this.state = { error: null };
|
||||||
|
}
|
||||||
|
|
||||||
|
static getDerivedStateFromError(error: Error): ErrorBoundaryState {
|
||||||
|
return { error };
|
||||||
|
}
|
||||||
|
|
||||||
|
reset = () => {
|
||||||
|
this.setState({ error: null });
|
||||||
|
this.props.onReset?.();
|
||||||
|
};
|
||||||
|
|
||||||
|
override render() {
|
||||||
|
if (!this.state.error) return this.props.children;
|
||||||
|
|
||||||
|
if (this.props.fallback) {
|
||||||
|
return this.props.fallback(this.state.error, this.reset);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ErrorState
|
||||||
|
title="Something went wrong"
|
||||||
|
error={this.state.error}
|
||||||
|
onRetry={this.reset}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,6 +2,7 @@ export { GuildChannelPicker } from "./guild-picker";
|
|||||||
export { MarkdownLite } from "./markdown";
|
export { MarkdownLite } from "./markdown";
|
||||||
export { PageTransition } from "./page-transition";
|
export { PageTransition } from "./page-transition";
|
||||||
export { MetricTile, SectionHeader } from "./section";
|
export { MetricTile, SectionHeader } from "./section";
|
||||||
|
export { ErrorBoundary } from "./error-boundary";
|
||||||
export {
|
export {
|
||||||
EmptyState,
|
EmptyState,
|
||||||
ErrorState,
|
ErrorState,
|
||||||
|
|||||||
@@ -0,0 +1,30 @@
|
|||||||
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
|
import { MobileNav } from "./mobile-nav";
|
||||||
|
import { NavRail } from "./nav-rail";
|
||||||
|
import { TopBar } from "./topbar";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* App chrome: slim nav rail + sticky top bar + scrollable content region.
|
||||||
|
* Sits above the fixed AmbientCanvas. Providers (Ambient + WS) are mounted in
|
||||||
|
* the route layout so every page shares one live link and signal context.
|
||||||
|
*
|
||||||
|
* < md the side rail collapses (hidden) and a bottom tab bar (MobileNav)
|
||||||
|
* takes over navigation; the content region gains bottom padding so the last
|
||||||
|
* panel never hides behind the dock. A persistent MiniPlayer floats at the
|
||||||
|
* bottom-right whenever a media track is loaded outside /media.
|
||||||
|
*/
|
||||||
|
export function AppFrame({ children }: { children: React.ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="flex h-dvh w-full overflow-hidden">
|
||||||
|
<NavRail />
|
||||||
|
<div className="flex min-w-0 flex-1 flex-col">
|
||||||
|
<TopBar />
|
||||||
|
<main className="min-h-0 flex-1 overflow-y-auto px-4 pb-[calc(2rem+env(safe-area-inset-bottom))] pt-4 sm:px-5 sm:pb-[calc(2.5rem+env(safe-area-inset-bottom))]">
|
||||||
|
{children}
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
<MobileNav />
|
||||||
|
<MiniPlayer />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,81 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConstellationFrame — replaces the classic AppFrame chrome.
|
|
||||||
* The stage canvas sits fixed behind everything; page content is an
|
|
||||||
* overlay layer (no top bar / nav rail / scroll shell). Views publish
|
|
||||||
* their live graph via SceneGraphProvider; the frame renders whatever
|
|
||||||
* the active view published (fallback: route-scenes default builder).
|
|
||||||
* Chatbot + CommandPalette keep mounting at the layout level.
|
|
||||||
*/
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { type ReactNode, useMemo } from "react";
|
|
||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
|
||||||
import { ConstellationStage } from "@/components/shell/constellation-stage";
|
|
||||||
import { FloatingChrome } from "@/components/shell/floating-chrome";
|
|
||||||
import {
|
|
||||||
buildDefaultGraph,
|
|
||||||
resolveScene,
|
|
||||||
type SceneSeed,
|
|
||||||
} from "@/components/shell/route-scenes";
|
|
||||||
import { SceneA11yMirror } from "@/components/shell/scene-a11y-mirror";
|
|
||||||
import {
|
|
||||||
SceneGraphProvider,
|
|
||||||
useSceneGraph,
|
|
||||||
} from "@/components/shell/scene-graph-context";
|
|
||||||
|
|
||||||
function StageFromContext({ seed }: { seed?: SceneSeed }) {
|
|
||||||
const pathname = usePathname() ?? "/";
|
|
||||||
const { state, setFocus } = useSceneGraph();
|
|
||||||
const onChannelsRoute = pathname.startsWith("/channels");
|
|
||||||
|
|
||||||
const graph = useMemo(() => {
|
|
||||||
if (state) return state.graph;
|
|
||||||
const scene = resolveScene(pathname);
|
|
||||||
return scene
|
|
||||||
? buildDefaultGraph(scene, seed ?? {})
|
|
||||||
: { nodes: [], edges: [] };
|
|
||||||
}, [state, pathname, seed]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<ConstellationStage
|
|
||||||
graph={graph}
|
|
||||||
selectedId={state?.focus ?? null}
|
|
||||||
onNodeClick={(id) => {
|
|
||||||
// On /channels/ a click selects the star (opens its dossier).
|
|
||||||
if (onChannelsRoute && id.startsWith("channel:")) {
|
|
||||||
setFocus((prev) => (prev === id ? null : id));
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const meta = state?.graph.nodes.find((n) => n.id === id);
|
|
||||||
if (meta?.href) window.location.assign(meta.href);
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ConstellationFrame({
|
|
||||||
children,
|
|
||||||
sceneSeed,
|
|
||||||
}: ConstellationFrameProps_) {
|
|
||||||
return (
|
|
||||||
<SceneGraphProvider>
|
|
||||||
<div className="relative h-dvh w-full overflow-hidden">
|
|
||||||
<StageFromContext seed={sceneSeed} />
|
|
||||||
<FloatingChrome />
|
|
||||||
<SceneA11yMirror />
|
|
||||||
<MiniPlayer />
|
|
||||||
{/* Overlay content region — scenes place floating panels inside. */}
|
|
||||||
<main className="pointer-events-none absolute inset-0 z-10 overflow-y-auto overscroll-contain">
|
|
||||||
<div className="pointer-events-auto min-h-full">{children}</div>
|
|
||||||
</main>
|
|
||||||
</div>
|
|
||||||
</SceneGraphProvider>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ConstellationFrameProps_ {
|
|
||||||
children: ReactNode;
|
|
||||||
/** Typed SSR seed for the route-scenes fallback builder. */
|
|
||||||
sceneSeed?: SceneSeed;
|
|
||||||
}
|
|
||||||
@@ -1,435 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ConstellationStage — full-bleed interactive star-field renderer.
|
|
||||||
* Renders the route's graph as glowing nodes + hairline edges over a
|
|
||||||
* three.js orthographic view. Pan = drag, zoom = wheel/pinch, click on
|
|
||||||
* an href-bearing node navigates (plain location.assign — router.push
|
|
||||||
* can no-op under standalone+trailingSlash builds).
|
|
||||||
*/
|
|
||||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
||||||
import * as THREE from "three";
|
|
||||||
import {
|
|
||||||
type CameraState,
|
|
||||||
easeInOutCubic,
|
|
||||||
fitCamera,
|
|
||||||
flyTo,
|
|
||||||
} from "@/lib/constellation/camera";
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import { computeLayout, type LayoutNode } from "@/lib/constellation/layout";
|
|
||||||
import { readPalette, type StagePalette } from "@/lib/constellation/palette";
|
|
||||||
|
|
||||||
export interface ConstellationStageProps {
|
|
||||||
graph: ConstellationGraph;
|
|
||||||
seed?: number;
|
|
||||||
/** Node id kept highlighted (scene selection). */
|
|
||||||
selectedId?: string | null;
|
|
||||||
onNodeClick?: (nodeId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
const Z_MIN = 0.35;
|
|
||||||
const Z_MAX = 3;
|
|
||||||
|
|
||||||
export function ConstellationStage({
|
|
||||||
graph,
|
|
||||||
seed = 42,
|
|
||||||
selectedId = null,
|
|
||||||
onNodeClick,
|
|
||||||
}: ConstellationStageProps) {
|
|
||||||
const canvasRef = useRef<HTMLCanvasElement | null>(null);
|
|
||||||
const labelsRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const camRef = useRef<CameraState>({ x: 0, y: 0, z: 1 });
|
|
||||||
const layoutRef = useRef<LayoutNode[]>([]);
|
|
||||||
const nodeByIdRef = useRef(new Map(graph.nodes.map((n) => [n.id, n])));
|
|
||||||
const [size, setSize] = useState({ w: 0, h: 0 });
|
|
||||||
const hoveredRef = useRef<string | null>(null);
|
|
||||||
const [reduced, setReduced] = useState(false);
|
|
||||||
|
|
||||||
nodeByIdRef.current = new Map(graph.nodes.map((n) => [n.id, n]));
|
|
||||||
|
|
||||||
// Viewport size + reduced-motion preference.
|
|
||||||
useEffect(() => {
|
|
||||||
const mq = window.matchMedia("(prefers-reduced-motion: reduce)");
|
|
||||||
const sync = () => setReduced(mq.matches);
|
|
||||||
sync();
|
|
||||||
mq.addEventListener("change", sync);
|
|
||||||
const measure = () =>
|
|
||||||
setSize({ w: window.innerWidth, h: window.innerHeight });
|
|
||||||
measure();
|
|
||||||
window.addEventListener("resize", measure);
|
|
||||||
return () => {
|
|
||||||
mq.removeEventListener("change", sync);
|
|
||||||
window.removeEventListener("resize", measure);
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Deterministic layout whenever graph or viewport changes.
|
|
||||||
// On scene change we FLY the camera to the new fit instead of snapping.
|
|
||||||
const prevSceneRef = useRef("");
|
|
||||||
const animRef = useRef<{
|
|
||||||
from: CameraState;
|
|
||||||
to: CameraState;
|
|
||||||
t0: number;
|
|
||||||
} | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (size.w < 50 || size.h < 50) return;
|
|
||||||
const layout = computeLayout(graph.nodes, graph.edges, {
|
|
||||||
width: size.w,
|
|
||||||
height: size.h,
|
|
||||||
seed,
|
|
||||||
reduced,
|
|
||||||
});
|
|
||||||
layoutRef.current = layout;
|
|
||||||
const target = fitCamera(layout, size.w, size.h);
|
|
||||||
const first = graph.nodes[0]?.id ?? "";
|
|
||||||
const sceneKey = `${graph.nodes.length}:${first}`;
|
|
||||||
if (!reduced && prevSceneRef.current && prevSceneRef.current !== sceneKey) {
|
|
||||||
animRef.current = {
|
|
||||||
from: { ...camRef.current },
|
|
||||||
to: target,
|
|
||||||
t0: performance.now(),
|
|
||||||
};
|
|
||||||
} else {
|
|
||||||
camRef.current = target;
|
|
||||||
}
|
|
||||||
prevSceneRef.current = sceneKey;
|
|
||||||
}, [graph, size.w, size.h, seed, reduced]);
|
|
||||||
|
|
||||||
// Build + run the renderer.
|
|
||||||
useEffect(() => {
|
|
||||||
const canvas = canvasRef.current;
|
|
||||||
if (!canvas || size.w < 50 || size.h < 50 || layoutRef.current.length === 0)
|
|
||||||
return;
|
|
||||||
|
|
||||||
let palette: StagePalette = readPalette();
|
|
||||||
const renderer = new THREE.WebGLRenderer({
|
|
||||||
canvas,
|
|
||||||
antialias: true,
|
|
||||||
alpha: true,
|
|
||||||
});
|
|
||||||
renderer.setPixelRatio(Math.min(window.devicePixelRatio || 1, 2));
|
|
||||||
renderer.setSize(size.w, size.h, false);
|
|
||||||
|
|
||||||
const scene = new THREE.Scene();
|
|
||||||
const camera = new THREE.OrthographicCamera(-1, 1, 1, -1, -200, 200);
|
|
||||||
|
|
||||||
const group = new THREE.Group();
|
|
||||||
scene.add(group);
|
|
||||||
const disposables: { dispose: () => void }[] = [];
|
|
||||||
const track = <T extends { dispose: () => void }>(d: T): T => {
|
|
||||||
disposables.push(d);
|
|
||||||
return d;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Edges first (under nodes).
|
|
||||||
const byId = new Map(layoutRef.current.map((n) => [n.id, n]));
|
|
||||||
const edgeMat = track(
|
|
||||||
new THREE.LineBasicMaterial({
|
|
||||||
color: palette.inkFaint,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.35,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
for (const e of graph.edges) {
|
|
||||||
const a = byId.get(e.source);
|
|
||||||
const b = byId.get(e.target);
|
|
||||||
if (!a || !b) continue;
|
|
||||||
const geo = track(
|
|
||||||
new THREE.BufferGeometry().setFromPoints([
|
|
||||||
new THREE.Vector3(a.x, a.y, 0),
|
|
||||||
new THREE.Vector3(b.x, b.y, 0),
|
|
||||||
]),
|
|
||||||
);
|
|
||||||
group.add(new THREE.Line(geo, edgeMat));
|
|
||||||
}
|
|
||||||
|
|
||||||
// Nodes: glow halo + core disc.
|
|
||||||
interface NodeVisual {
|
|
||||||
id: string;
|
|
||||||
halo: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
|
||||||
core: THREE.Mesh<THREE.CircleGeometry, THREE.MeshBasicMaterial>;
|
|
||||||
r: number;
|
|
||||||
x: number;
|
|
||||||
y: number;
|
|
||||||
}
|
|
||||||
const visuals: NodeVisual[] = [];
|
|
||||||
for (const n of layoutRef.current) {
|
|
||||||
const meta = nodeByIdRef.current.get(n.id);
|
|
||||||
const kind = meta?.kind ?? "message";
|
|
||||||
const flagged =
|
|
||||||
Number(meta?.meta?.flagged_count ?? 0) > 0 && kind === "channel";
|
|
||||||
const color =
|
|
||||||
kind === "guild"
|
|
||||||
? palette.signal
|
|
||||||
: flagged
|
|
||||||
? palette.vermilion
|
|
||||||
: palette.ink;
|
|
||||||
const haloGeo = track(new THREE.CircleGeometry(n.r * 2.4, 32));
|
|
||||||
const haloMat = track(
|
|
||||||
new THREE.MeshBasicMaterial({
|
|
||||||
color,
|
|
||||||
transparent: true,
|
|
||||||
opacity: kind === "guild" ? 0.14 : 0.08,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const coreGeo = track(new THREE.CircleGeometry(n.r, 32));
|
|
||||||
const coreMat = track(
|
|
||||||
new THREE.MeshBasicMaterial({
|
|
||||||
color,
|
|
||||||
transparent: true,
|
|
||||||
opacity: 0.92,
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
const halo = new THREE.Mesh(haloGeo, haloMat);
|
|
||||||
halo.position.set(n.x, n.y, -1);
|
|
||||||
const core = new THREE.Mesh(coreGeo, coreMat);
|
|
||||||
core.position.set(n.x, n.y, 0);
|
|
||||||
group.add(halo, core);
|
|
||||||
visuals.push({ id: n.id, halo, core, r: n.r, x: n.x, y: n.y });
|
|
||||||
}
|
|
||||||
|
|
||||||
const applyCamera = () => {
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
const hw = size.w / (2 * z);
|
|
||||||
const hh = size.h / (2 * z);
|
|
||||||
camera.left = -hw;
|
|
||||||
camera.right = hw;
|
|
||||||
camera.top = hh;
|
|
||||||
camera.bottom = -hh;
|
|
||||||
camera.position.set(x, y, 100);
|
|
||||||
camera.updateProjectionMatrix();
|
|
||||||
};
|
|
||||||
|
|
||||||
// --- interaction state ---
|
|
||||||
type PointerMode =
|
|
||||||
| { t: "idle" }
|
|
||||||
| { t: "drag"; sx: number; sy: number; cx: number; cy: number };
|
|
||||||
let mode: PointerMode = { t: "idle" };
|
|
||||||
let raf = 0;
|
|
||||||
let disposed = false;
|
|
||||||
|
|
||||||
const pointerToWorld = (clientX: number, clientY: number) => {
|
|
||||||
const rect = canvas.getBoundingClientRect();
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
return {
|
|
||||||
wx: x + (clientX - rect.left - size.w / 2) / z,
|
|
||||||
wy: y - (clientY - rect.top - size.h / 2) / z,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const pickNode = (clientX: number, clientY: number): NodeVisual | null => {
|
|
||||||
const { wx, wy } = pointerToWorld(clientX, clientY);
|
|
||||||
let best: NodeVisual | null = null;
|
|
||||||
let bestD = Infinity;
|
|
||||||
for (const v of visuals) {
|
|
||||||
const d = Math.hypot(wx - v.x, wy - v.y);
|
|
||||||
if (d <= v.r + 6 && d < bestD) {
|
|
||||||
best = v;
|
|
||||||
bestD = d;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return best;
|
|
||||||
};
|
|
||||||
|
|
||||||
const onPointerDown = (ev: PointerEvent) => {
|
|
||||||
mode = {
|
|
||||||
t: "drag",
|
|
||||||
sx: ev.clientX,
|
|
||||||
sy: ev.clientY,
|
|
||||||
cx: camRef.current.x,
|
|
||||||
cy: camRef.current.y,
|
|
||||||
};
|
|
||||||
canvas.style.cursor = "grabbing";
|
|
||||||
};
|
|
||||||
const onPointerMove = (ev: PointerEvent) => {
|
|
||||||
if (mode.t === "drag") {
|
|
||||||
const z = camRef.current.z;
|
|
||||||
camRef.current.x = mode.cx - (ev.clientX - mode.sx) / z;
|
|
||||||
camRef.current.y = mode.cy + (ev.clientY - mode.sy) / z;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const hit = pickNode(ev.clientX, ev.clientY);
|
|
||||||
hoveredRef.current = hit ? hit.id : null;
|
|
||||||
canvas.style.cursor = hit ? "pointer" : "grab";
|
|
||||||
};
|
|
||||||
const onPointerUp = (ev: PointerEvent) => {
|
|
||||||
const wasDrag =
|
|
||||||
mode.t === "drag" &&
|
|
||||||
Math.hypot(ev.clientX - mode.sx, ev.clientY - mode.sy) > 4;
|
|
||||||
mode = { t: "idle" };
|
|
||||||
canvas.style.cursor = "grab";
|
|
||||||
if (wasDrag) return;
|
|
||||||
const hit = pickNode(ev.clientX, ev.clientY);
|
|
||||||
if (!hit) return;
|
|
||||||
const meta = nodeByIdRef.current.get(hit.id);
|
|
||||||
onNodeClick?.(hit.id);
|
|
||||||
if (meta?.href) window.location.assign(meta.href);
|
|
||||||
};
|
|
||||||
const onWheel = (ev: WheelEvent) => {
|
|
||||||
ev.preventDefault();
|
|
||||||
const old = camRef.current.z;
|
|
||||||
const next = Math.max(
|
|
||||||
Z_MIN,
|
|
||||||
Math.min(Z_MAX, old * Math.exp(-ev.deltaY * 0.0012)),
|
|
||||||
);
|
|
||||||
camRef.current.z = next;
|
|
||||||
};
|
|
||||||
|
|
||||||
canvas.style.cursor = "grab";
|
|
||||||
canvas.addEventListener("pointerdown", onPointerDown);
|
|
||||||
window.addEventListener("pointermove", onPointerMove);
|
|
||||||
window.addEventListener("pointerup", onPointerUp);
|
|
||||||
canvas.addEventListener("wheel", onWheel, { passive: false });
|
|
||||||
|
|
||||||
// Labels (HTML, imperative transforms — cheap for ≤16 nodes).
|
|
||||||
const labelHost = labelsRef.current;
|
|
||||||
const labelEls = new Map<string, HTMLSpanElement>();
|
|
||||||
const labeled = [...layoutRef.current]
|
|
||||||
.sort((a, b) => b.r - a.r)
|
|
||||||
.slice(0, 14);
|
|
||||||
if (labelHost) {
|
|
||||||
for (const n of labeled) {
|
|
||||||
const el = document.createElement("span");
|
|
||||||
el.textContent = nodeByIdRef.current.get(n.id)?.label ?? n.id;
|
|
||||||
el.dataset.nodeLabel = n.id;
|
|
||||||
el.className =
|
|
||||||
"pointer-events-none absolute whitespace-nowrap font-mono text-[11px] tracking-wide text-[var(--color-ink-soft)] transition-colors";
|
|
||||||
labelHost.appendChild(el);
|
|
||||||
labelEls.set(n.id, el);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Theme sync — tokens flip with the .light class.
|
|
||||||
const themeObserver = new MutationObserver(() => {
|
|
||||||
palette = readPalette();
|
|
||||||
for (const v of visuals) {
|
|
||||||
const meta = nodeByIdRef.current.get(v.id);
|
|
||||||
const flagged =
|
|
||||||
Number(meta?.meta?.flagged_count ?? 0) > 0 &&
|
|
||||||
(meta?.kind ?? "") === "channel";
|
|
||||||
const c =
|
|
||||||
meta?.kind === "guild"
|
|
||||||
? palette.signal
|
|
||||||
: flagged
|
|
||||||
? palette.vermilion
|
|
||||||
: palette.ink;
|
|
||||||
v.core.material.color.setHex(c);
|
|
||||||
v.halo.material.color.setHex(c);
|
|
||||||
}
|
|
||||||
edgeMat.color.setHex(palette.inkFaint);
|
|
||||||
});
|
|
||||||
themeObserver.observe(document.documentElement, {
|
|
||||||
attributes: true,
|
|
||||||
attributeFilter: ["class"],
|
|
||||||
});
|
|
||||||
|
|
||||||
let hidden = document.hidden;
|
|
||||||
const onVis = () => {
|
|
||||||
hidden = document.hidden;
|
|
||||||
};
|
|
||||||
document.addEventListener("visibilitychange", onVis);
|
|
||||||
|
|
||||||
const start = performance.now();
|
|
||||||
const frame = (now: number) => {
|
|
||||||
if (disposed) return;
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
if (hidden) return;
|
|
||||||
|
|
||||||
// Camera fly-to (scene transitions): eased interpolation over ~900ms.
|
|
||||||
const anim = animRef.current;
|
|
||||||
if (anim) {
|
|
||||||
const raw = (now - anim.t0) / 900;
|
|
||||||
if (raw >= 1) {
|
|
||||||
camRef.current = anim.to;
|
|
||||||
animRef.current = null;
|
|
||||||
} else if (raw > 0) {
|
|
||||||
camRef.current = flyTo(anim.from, anim.to, raw, easeInOutCubic);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
applyCamera();
|
|
||||||
|
|
||||||
// Gentle breathing glow (visual only — hit positions stay baked).
|
|
||||||
const t = (now - start) / 1000;
|
|
||||||
let i = 0;
|
|
||||||
for (const v of visuals) {
|
|
||||||
if (!reduced) {
|
|
||||||
const pulse = 0.5 + 0.5 * Math.sin(t * 1.4 + i * 0.7);
|
|
||||||
v.halo.material.opacity =
|
|
||||||
(v.id === "guild" ? 0.14 : 0.08) * (0.7 + 0.6 * pulse);
|
|
||||||
}
|
|
||||||
const hov = v.id === hoveredRef.current || v.id === selectedId;
|
|
||||||
v.core.scale.setScalar(hov ? 1.25 : 1);
|
|
||||||
i += 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
renderer.render(scene, camera);
|
|
||||||
|
|
||||||
if (labelHost) {
|
|
||||||
const { x, y, z } = camRef.current;
|
|
||||||
for (const [id, el] of labelEls) {
|
|
||||||
const v = visuals.find((s) => s.id === id);
|
|
||||||
if (!v) continue;
|
|
||||||
const sx = (v.x - x) * z + size.w / 2;
|
|
||||||
const sy = -(v.y - y) * z + size.h / 2 + v.r + 14;
|
|
||||||
el.style.transform = `translate(${sx}px, ${sy}px) translateX(-50%)`;
|
|
||||||
el.style.color =
|
|
||||||
id === hoveredRef.current || id === selectedId
|
|
||||||
? "var(--color-signal)"
|
|
||||||
: "var(--color-ink-soft)";
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
raf = requestAnimationFrame(frame);
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
disposed = true;
|
|
||||||
cancelAnimationFrame(raf);
|
|
||||||
document.removeEventListener("visibilitychange", onVis);
|
|
||||||
canvas.removeEventListener("pointerdown", onPointerDown);
|
|
||||||
window.removeEventListener("pointermove", onPointerMove);
|
|
||||||
window.removeEventListener("pointerup", onPointerUp);
|
|
||||||
canvas.removeEventListener("wheel", onWheel);
|
|
||||||
themeObserver.disconnect();
|
|
||||||
for (const el of labelEls.values()) el.remove();
|
|
||||||
for (const d of disposables) d.dispose();
|
|
||||||
scene.clear();
|
|
||||||
renderer.dispose();
|
|
||||||
};
|
|
||||||
}, [graph, size.w, size.h, reduced, selectedId, onNodeClick]);
|
|
||||||
|
|
||||||
const emptyGraph = graph.nodes.length === 0;
|
|
||||||
const hint = useMemo(
|
|
||||||
() => (emptyGraph ? "Menunggu data scene…" : null),
|
|
||||||
[emptyGraph],
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleReset = useCallback(() => {
|
|
||||||
if (layoutRef.current.length > 0 && size.w > 0) {
|
|
||||||
camRef.current = fitCamera(layoutRef.current, size.w, size.h);
|
|
||||||
}
|
|
||||||
}, [size.w, size.h]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<canvas
|
|
||||||
ref={canvasRef}
|
|
||||||
className="fixed inset-0 -z-10 h-full w-full touch-none select-none"
|
|
||||||
/>
|
|
||||||
<div ref={labelsRef} aria-hidden="true" className="fixed inset-0 -z-10" />
|
|
||||||
{hint ? (
|
|
||||||
<p className="pointer-events-none absolute inset-x-0 top-1/2 -translate-y-1/2 text-center font-mono text-sm text-[var(--color-ink-faint)]">
|
|
||||||
{hint}
|
|
||||||
</p>
|
|
||||||
) : null}
|
|
||||||
<button
|
|
||||||
className="absolute bottom-24 right-5 rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/60 px-3 py-1 font-mono text-xs text-[var(--color-ink-soft)] backdrop-blur-sm hover:text-[var(--color-ink)] md:bottom-20"
|
|
||||||
onClick={handleReset}
|
|
||||||
type="button"
|
|
||||||
>
|
|
||||||
reset view
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,139 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
/**
|
|
||||||
* FloatingChrome — the only persistent UI over the constellation.
|
|
||||||
* No top bar, no nav rail: brand + live link (top-left), theme toggle
|
|
||||||
* and palette hint (top-right), route switcher (bottom-center).
|
|
||||||
* All internal navigation uses plain <a href> — trailingSlash builds.
|
|
||||||
*/
|
|
||||||
import { Menu, Moon, Sun, X } from "lucide-react";
|
|
||||||
import { usePathname } from "next/navigation";
|
|
||||||
import { useTheme } from "next-themes";
|
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { navItems } from "@/lib/navigation";
|
|
||||||
import { ConnectionStatus } from "./status-dot";
|
|
||||||
|
|
||||||
function ThemeToggle() {
|
|
||||||
const { theme, setTheme } = useTheme();
|
|
||||||
const [mounted, setMounted] = useState(false);
|
|
||||||
useEffect(() => setMounted(true), []);
|
|
||||||
if (!mounted) {
|
|
||||||
return <span className="inline-block size-8" aria-hidden="true" />;
|
|
||||||
}
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label="Toggle theme"
|
|
||||||
className="inline-flex size-8 items-center justify-center rounded-full border border-[var(--color-hairline)] text-[var(--color-ink-soft)] backdrop-blur-sm transition-colors hover:border-[var(--color-signal)] hover:text-[var(--color-signal)]"
|
|
||||||
onClick={() => setTheme(theme === "light" ? "dark" : "light")}
|
|
||||||
>
|
|
||||||
{theme === "light" ? (
|
|
||||||
<Moon className="size-4" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Sun className="size-4" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function FloatingChrome() {
|
|
||||||
const pathname = usePathname();
|
|
||||||
const [menuOpen, setMenuOpen] = useState(false);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Brand + live link */}
|
|
||||||
<div className="pointer-events-auto absolute left-5 top-5 z-20 flex items-center gap-3">
|
|
||||||
<span className="font-display text-lg font-semibold tracking-tight text-[var(--color-ink)]">
|
|
||||||
GMW
|
|
||||||
</span>
|
|
||||||
<span className="hidden font-mono text-[10px] uppercase tracking-[0.2em] text-[var(--color-ink-faint)] sm:inline">
|
|
||||||
constellation
|
|
||||||
</span>
|
|
||||||
<ConnectionStatus compact />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Theme + palette hint */}
|
|
||||||
<div className="pointer-events-auto absolute right-5 top-5 z-20 flex items-center gap-2">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="hidden rounded-full border border-[var(--color-hairline)] px-3 py-1 font-mono text-xs text-[var(--color-ink-faint)] backdrop-blur-sm transition-colors hover:text-[var(--color-ink)] md:block"
|
|
||||||
onClick={() =>
|
|
||||||
window.dispatchEvent(new Event("command-palette:open"))
|
|
||||||
}
|
|
||||||
>
|
|
||||||
⌘K
|
|
||||||
</button>
|
|
||||||
<ThemeToggle />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Route switcher */}
|
|
||||||
<nav
|
|
||||||
aria-label="Routes"
|
|
||||||
className="pointer-events-none absolute inset-x-0 bottom-5 z-20 hidden justify-center gap-1 px-4 md:flex"
|
|
||||||
>
|
|
||||||
<div className="pointer-events-auto flex max-w-full items-center gap-1 overflow-x-auto rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/55 px-2 py-1.5 backdrop-blur-md">
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active =
|
|
||||||
pathname === item.matchPrefix ||
|
|
||||||
pathname.startsWith(item.matchPrefix);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
data-active={active || undefined}
|
|
||||||
className={`rounded-full px-3 py-1 font-mono text-xs whitespace-nowrap transition-colors ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)] hover:text-[var(--color-ink)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{/* Mobile: sheet menu instead of a bottom tab bar */}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
aria-label={menuOpen ? "Close menu" : "Open menu"}
|
|
||||||
aria-expanded={menuOpen}
|
|
||||||
className="pointer-events-auto absolute bottom-5 right-5 z-30 inline-flex size-10 items-center justify-center rounded-full border border-[var(--color-hairline)] bg-[var(--color-canvas)]/70 text-[var(--color-ink)] backdrop-blur-md md:hidden"
|
|
||||||
onClick={() => setMenuOpen((o) => !o)}
|
|
||||||
>
|
|
||||||
{menuOpen ? (
|
|
||||||
<X className="size-5" aria-hidden="true" />
|
|
||||||
) : (
|
|
||||||
<Menu className="size-5" aria-hidden="true" />
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
{menuOpen ? (
|
|
||||||
<nav
|
|
||||||
aria-label="Routes mobile"
|
|
||||||
className="pointer-events-auto absolute inset-x-4 bottom-20 z-20 flex flex-col gap-1 rounded-2xl border border-[var(--color-hairline)] bg-[var(--color-canvas-2)]/90 p-2 backdrop-blur-xl md:hidden"
|
|
||||||
>
|
|
||||||
{navItems.map((item) => {
|
|
||||||
const active =
|
|
||||||
pathname === item.matchPrefix ||
|
|
||||||
pathname.startsWith(item.matchPrefix);
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
key={item.href}
|
|
||||||
href={item.href}
|
|
||||||
className={`rounded-xl px-4 py-3 font-mono text-sm ${
|
|
||||||
active
|
|
||||||
? "bg-[var(--color-signal-glow)] text-[var(--color-ink)]"
|
|
||||||
: "text-[var(--color-ink-soft)]"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{item.label}
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</nav>
|
|
||||||
) : null}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,2 +1,5 @@
|
|||||||
export { ConstellationFrame } from "./constellation-frame";
|
export { AppFrame } from "./ambient-app";
|
||||||
|
export { MobileNav } from "./mobile-nav";
|
||||||
|
export { NavRail } from "./nav-rail";
|
||||||
export { ConnectionStatus } from "./status-dot";
|
export { ConnectionStatus } from "./status-dot";
|
||||||
|
export { TopBar } from "./topbar";
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { isActivePath, mobileNavItems } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Mobile bottom tab bar. Shown only < md (the side NavRail is hidden there).
|
||||||
|
* Mirrors the desktop nav items but as a thumb-friendly dock with labels and a
|
||||||
|
* top active indicator. Safe-area aware for notched devices.
|
||||||
|
*/
|
||||||
|
export function MobileNav() {
|
||||||
|
const path = usePathname() ?? "/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="glass fixed inset-x-0 bottom-0 z-40 flex items-stretch justify-around rounded-t-[20px] px-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] pt-2 md:hidden">
|
||||||
|
{mobileNavItems.map((item) => {
|
||||||
|
const active = isActivePath(path, item.matchPrefix);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
aria-label={item.label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
className={cn(
|
||||||
|
"game-nav-item relative flex min-h-[44px] flex-1 flex-col items-center justify-center rounded-t-[20px] px-2 pt-2 pb-[calc(0.4rem+env(safe-area-inset-bottom))] md:hidden",
|
||||||
|
active
|
||||||
|
? "is-active text-white"
|
||||||
|
: "text-ink-faint hover:text-ink-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && (
|
||||||
|
<span
|
||||||
|
aria-hidden="true"
|
||||||
|
className="absolute inset-x-4 top-0 h-[2px] bg-white shadow-[0_0_8px_var(--color-signal-glow)]"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<span className="game-sweep" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<item.icon
|
||||||
|
className="relative z-10 size-[20px]"
|
||||||
|
strokeWidth={active ? 2.4 : 2}
|
||||||
|
/>
|
||||||
|
{item.label}
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,69 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { usePathname } from "next/navigation";
|
||||||
|
import { isActivePath, navItems } from "@/lib/navigation";
|
||||||
|
import { cn } from "@/lib/utils";
|
||||||
|
|
||||||
|
function NavItem({
|
||||||
|
href,
|
||||||
|
label,
|
||||||
|
active,
|
||||||
|
Icon,
|
||||||
|
index,
|
||||||
|
}: {
|
||||||
|
href: string;
|
||||||
|
label: string;
|
||||||
|
active: boolean;
|
||||||
|
Icon: React.ComponentType<React.SVGProps<SVGSVGElement>>;
|
||||||
|
index: number;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<a
|
||||||
|
href={href}
|
||||||
|
aria-label={label}
|
||||||
|
aria-current={active ? "page" : undefined}
|
||||||
|
style={{ "--i": index } as React.CSSProperties}
|
||||||
|
className={cn(
|
||||||
|
"game-nav-item group flex size-11 items-center justify-center rounded-[13px] transition-colors",
|
||||||
|
active
|
||||||
|
? "is-active bg-white/10 text-white"
|
||||||
|
: "text-ink-faint hover:bg-white/5 hover:text-ink-soft",
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{active && <span className="game-marker -left-3.5" />}
|
||||||
|
<span className="game-sweep" aria-hidden="true">
|
||||||
|
<i />
|
||||||
|
</span>
|
||||||
|
<Icon
|
||||||
|
className="relative z-10 size-[18px]"
|
||||||
|
strokeWidth={active ? 2.4 : 2}
|
||||||
|
/>
|
||||||
|
{/* hover tooltip — labels are hidden in the rail, so surface on hover */}
|
||||||
|
<span className="pointer-events-none absolute left-full z-50 ml-3 hidden whitespace-nowrap rounded-[9px] border border-hairline bg-canvas-2 px-2.5 py-1.5 font-mono text-xs font-medium text-ink-soft opacity-0 shadow-lg transition-opacity group-hover:opacity-100 md:block">
|
||||||
|
{label}
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function NavRail() {
|
||||||
|
const pathname = usePathname();
|
||||||
|
const path = pathname ?? "/";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<nav className="glass mb-[calc(0.75rem+env(safe-area-inset-bottom))] ml-[calc(0.75rem+env(safe-area-inset-left))] mt-[calc(0.75rem+env(safe-area-inset-top))] hidden w-[68px] flex-col items-center gap-1 rounded-[18px] py-4 md:flex">
|
||||||
|
<div className="flex flex-1 flex-col gap-1">
|
||||||
|
{navItems.map((item, i) => (
|
||||||
|
<NavItem
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
label={item.label}
|
||||||
|
index={i}
|
||||||
|
active={isActivePath(path, item.matchPrefix)}
|
||||||
|
Icon={item.icon}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,59 +0,0 @@
|
|||||||
/**
|
|
||||||
* Scene config: maps pathname → constellation scene definition.
|
|
||||||
* Pure data; the stage reads it to build graphs and place overlays.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { ConstellationGraph } from "@/lib/constellation/graph";
|
|
||||||
import {
|
|
||||||
channelsToGraph,
|
|
||||||
culturesToGraph,
|
|
||||||
statsToGraph,
|
|
||||||
} from "@/lib/constellation/graph";
|
|
||||||
|
|
||||||
export interface SceneDef {
|
|
||||||
route: string;
|
|
||||||
label: string;
|
|
||||||
/** Build the graph from typed SSR seed data. */
|
|
||||||
build: (seed: SceneSeed) => ConstellationGraph;
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Everything a scene may need — all optional, scenes pick what they use. */
|
|
||||||
export interface SceneSeed {
|
|
||||||
stats?: import("@/lib/types").DashboardStats;
|
|
||||||
channels?: import("@/lib/types").DashboardChannel[];
|
|
||||||
cultures?: import("@/lib/types").ChannelCultureRow[];
|
|
||||||
guildLabel?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const SCENES: SceneDef[] = [
|
|
||||||
{
|
|
||||||
route: "/dashboard/",
|
|
||||||
label: "Deck",
|
|
||||||
build: (s) => (s.stats ? statsToGraph(s.stats) : { nodes: [], edges: [] }),
|
|
||||||
},
|
|
||||||
{
|
|
||||||
route: "/channels/",
|
|
||||||
label: "Channels",
|
|
||||||
build: (s) => {
|
|
||||||
if (s.cultures && s.cultures.length > 0)
|
|
||||||
return culturesToGraph(s.cultures);
|
|
||||||
if (s.channels) return channelsToGraph(s.channels);
|
|
||||||
return { nodes: [], edges: [] };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export function resolveScene(pathname: string): SceneDef | undefined {
|
|
||||||
return SCENES.find((sc) => sc.route === pathname);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function buildDefaultGraph(
|
|
||||||
scene: SceneDef,
|
|
||||||
seed: SceneSeed,
|
|
||||||
): ConstellationGraph {
|
|
||||||
try {
|
|
||||||
return scene.build(seed);
|
|
||||||
} catch {
|
|
||||||
return { nodes: [], edges: [] };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Loaded 50 of 69 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user