Author SHA1 Message Date
dependabot[bot] 870f7546fb build(deps-dev): bump @types/react-dom
Bumps the development group in /services/frontend with 1 update: [@types/react-dom](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/react-dom).


Updates `@types/react-dom` from 19.2.4 to 19.2.5
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/react-dom)

---
updated-dependencies:
- dependency-name: "@types/react-dom"
  dependency-version: 19.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: development
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-27 14:25:18 +00:00
asepharyana a54c34c6b3 fix: enhance accessibility for Select component with ARIA attributes 2026-08-27 18:09:29 +07:00
asepharyana 7375e2ed52 fix: add tooltip functionality to NavItem with portal rendering 2026-08-27 18:04:46 +07:00
asepharyana 5196cb0221 fix: edit history 'after' showed original content, not edited content
getRecentEdits SELECT ... m.content AS new_content returned the message's
ORIGINAL content (never updated on edit) instead of the post-edit content.
messages.content stores the original body; the current/last-edited body lives
in messages.edited_content. So before===after in the Message Edits diff.

Fix: COALESCE(m.edited_content, m.content) AS new_content so 'After' shows the
edited text and diffs against the captured before-content are meaningful.
2026-08-27 17:28:24 +07:00
asepharyana 631b5e1027 fix: make gateway migrations idempotent + self-heal drizzle history
Prevent recurring infinite restart loop (389x crash) caused by drizzle
re-applying already-applied migrations when public.__drizzle_migrations
tracking is empty/partial.

- 0017/0018: ADD COLUMN IF NOT EXISTS (re-run safe)
- 0019: DO-block rename that handles all prior states (server_name-only,
  both columns, or server_nick-only) so it never errors or double-renames
- seedDrizzleHistory: reconcile tracked created_at to the journal's latest
  'when' when the schema already reflects the latest migration, instead of
  early-returning on an existing-but-empty/partial tracking table
2026-08-27 15:05:30 +07:00
asepharyana 8049b70738 fix: render server nick per user in messages tab and analysis
Extract server_nick from metadata.member.displayName in backend messageMapper,
add server_nick to frontend MessageRecord type, and update messages view +
analysis view to display the member's server-specific nickname (with @username
as secondary context) instead of the global username.
2026-08-27 14:38:05 +07:00
asepharyana ea23c405fa fix: capture server nickname (member displayName) per action
Rename server_name (guild name) to server_nick and populate it from
the member's server-specific display name (metadata.member.displayName)
at write time. This is what the moderation dashboard should show as
TARGET — e.g. server nick 'Bandar Togel「✔ ᵛᵉʳᶦᶠᶦᵉᵈ 」' for global
username '.nichiyobi'. Backfilled 210 existing actions from messages
metadata (reset_nickname rows now show 'Sarjana .jav', 'Penindas
Minoritas', etc). Frontend TARGET shows server nick with global
username as secondary context.
2026-08-27 13:50:37 +07:00
asepharyana 4991164591 fix: use AI for global username check instead of keyword list
Replace static OFFENSIVE_USERNAME_KEYWORDS substring matching with a
lightweight LLM call that evaluates whether a global username violates
server rules (gambling, scam, NSFW, SARA, etc). Fail-open design:
if the LLM call fails/times out, the nickname reset still completes.
2026-08-27 13:20:20 +07:00
asepharyana 1590479f58 fix: replace global username if also offensive after nickname reset
After resetting an offensive server nickname to the global username,
check the global username against gambling/scam keyword list. If it
also violates, generate a random 'UserXXXXX' nickname to prevent
circumvention via offensive global usernames.
2026-08-27 12:46:33 +07:00
asepharyana 0de393625f fix: add server_name to moderation_actions for full context retention
Denormalize guild name alongside username so the moderation dashboard
shows both TARGET and server even after message table purges.
Migration 0018. Frontend displays 'username · server_name' in TARGET.
2026-08-27 12:32:12 +07:00
asepharyana cccfd89266 fix: store username on moderation_actions for retention safety
Add denormalized  column to moderation_actions so the
dashboard TARGET field survives message table purges. Backfills all
existing 217 rows. Changes: schema + autoDeleteManager + backend
repository query + migration 0017.
2026-08-27 12:15:06 +07:00
asepharyana 17aad2cc39 chore: biome lint cleanup — formatting, unused imports, exhaustive-deps fixes
- Backend: fix redis-bridge.ts import formatting
- Frontend: fix formatting in messages/view, recordings/view, ambient-canvas, voice-stage
- Frontend: remove unused imports/vars in EditHistory, ambient-canvas
- Frontend: fix import sorting in LiveModerationFeed
- Frontend: suppress caller-controlled exhaustive-deps in use-gsap-animation
- All services: lint clean, typecheck pass, build pass
- Backend e2e tests excluded (require live server, expected 404s)
2026-08-27 09:39:33 +07:00
asepharyana a6e2c1fa4f voice: deep stability audit — FFmpeg crash recovery, activity timeout, reconnect refresh
Gateway (transmitter.ts):
- Auto-stop on FFmpeg crash: non-zero exit triggers stop() to prevent
  silent audio loss and resource leaks
- Voice activity timeout (10s): auto-stops transmitter when no PCM
  received, preventing dead-air CPU waste on backgrounded tabs
- Stderr cap (4KB): prevents unbounded memory growth in long sessions

Gateway (voice.handler.ts):
- Double-check voiceController.getStatus().connected before starting
  transmitter — detects stale player state after gateway disconnect

Frontend (context.tsx):
- Force-refetch voice status on WS reconnect — UI converges in <1s
  instead of waiting up to 4s for SWR poll interval

All: tsc clean, biome clean
2026-08-26 23:48:36 +07:00
asepharyana 7e0d0d5123 voice: audit + noise suppression toggle + stability fixes
Gateway:
- transmitter.ts: cap backpressure queue at 500 chunks (prevents memory leak)
- transmitter.ts: fix Redis race — assign redisSub AFTER subscribe completes
- voice.handler.ts: static import Redis instead of dynamic (cleaner, no eval)

Frontend:
- mic-transmit.ts: MicAccessError with specific reasons (permission-denied, no-mic, timeout)
- mic-transmit.ts: noiseSuppression option in getUserMedia constraints
- mic-transmit.ts: proper DOMException handling for all getUserMedia failure modes
- use-voice.ts: noiseSuppression state + toggleNoiseSuppression exposed
- use-voice.ts: cleanup on unmount (stops transmitter, clears refs)
- voice/view.tsx: noise suppression toggle button (ShieldCheck/ShieldOff icons)
- voice/view.tsx: improved mic error toasts (permission denied / no mic specific)
- voice/view.tsx: NS status in codec footer (NS_ACTIVE when enabled)
2026-08-26 23:32:37 +07:00
asepharyana 4f4f92706c feat(voice): implement stale speaker management and clear functionality 2026-08-26 23:16:57 +07:00
asepharyana 611ba39973 refactor: replace GSAP animations with CSS animations across components
- Updated RootLayout to include a noise overlay class.
- Refactored LiveModerationFeed to use CSS animations for item entry.
- Simplified AmbientCanvas by removing WebGL and using pure CSS for ambient effects.
- Converted Chatbot to use CSS for floating window animations.
- Updated Card component to include a gradient border.
- Refactored PageTransition to use CSS for fade-scale animations.
- Enhanced SectionHeader with reveal-up animation class.
- Replaced GSAP animations in NavRail with CSS animations for item entry.
- Refactored VoiceStage to use CSS for speaker node animations and pulse rings.
- Removed GSAP dependencies from use-gsap-animation hook, implementing CSS-based stagger reveal.
2026-08-26 19:43:57 +07:00
asepharyana 192685e1ce feat(layout): add theme script to prevent light mode flash during hydration 2026-08-26 18:33:30 +07:00
asepharyana cacebfd94e feat(messages): enhance message and edit history with channel names and content diffs 2026-08-26 18:26:15 +07:00
asepharyana 709074935f style: fix biome formatting after audit fixes 2026-08-26 18:12:02 +07:00
asepharyana 9f02edd646 perf+fix(ai-moderation): 11 pipeline optimizations from audit
Audit of the full AI analysis flow found 14 issues; 11 fixed, 3 deferred:

Fixed:
1. batchProcessor: skip scheduleAutoDelete for error-status rows (was
   causing wasted not_eligible logs for every parse/API failure)
2. textBatchProcessor: domain dedup in URL fetch (max 3 URLs per domain
   to avoid rate-limiting from concentrated domains)
3. llmCaller: move parseModerationResponse import to top-level (was
   dynamic-imported inside retry loop — unnecessary overhead per retry)
4. llmCaller: make default max_tokens configurable via
   AI_LLM_MAX_COMPLETION_TOKENS env (default 16384)
5. moderationOrchestrator: log cache write errors instead of silent
   .catch(() => {}) — surface intermittent Redis failures
6. conversationContext: batch token estimation via estimateTokensBatch
   (single tiktoken encode call for all target lines, ~5x faster)
7. aiAnalyzer: skip revertStuckProcessingMessages DB query when no
   conversations are actively processing (avoids idle-state query)
8. textBatchProcessor: cache corrected few-shot examples per hour
   (was re-queried from DB on every batch)
9. textBatchProcessor: preserve partial results on sub-batch timeout
   (was throwing and discarding all prior sub-batch results)
10. batchProcessor switch: skip 'completed' messages from individual
    fallback queue (prevents redundant re-analysis + double-delete)
11. autoDeleteManager: expand isAlreadyDeletedError to catch Discord
    codes 10003/50001 + text fallback matching

Deferred (not regressions, larger refactors):
- #8 batchScheduler debounce race: not actually a race (JS single-threaded)
- #11 initCacheStore: already has idempotency guard
- #13 individual fallback batching: requires worker pool refactor

7 files changed, 73 insertions(+), 32 deletions(-)
2026-08-26 18:08:24 +07:00
asepharyana 5fc3cf86d5 perf(backend): parallelize getMessageById DB queries + drop unused indexes
- getMessageById: run findById and getEditHistory in parallel via
  Promise.all instead of sequential awaits (halves latency for
  message detail view)
- Drop 3 unused indexes on messages table: idx_messages_guild_ai_status_created
  (0 scans), idx_messages_guild_ai_status_analyzed (97 scans),
  idx_messages_guild_created_deleted (95 scans, superseded by covering index)
  — saves ~5.9MB index space + reduces write amplification
- Add covering index idx_messages_guild_created_covering for the primary
  findMany query pattern (guild_id + created_at DESC with INCLUDE columns)
2026-08-26 17:48:10 +07:00
asepharyana e81391484b feat(messages): enhance message rendering with metadata parsing and inline image support 2026-08-26 17:43:01 +07:00
asepharyana 3b8fe1b1c3 refactor(select): enhance dropdown positioning and implement portal for improved rendering 2026-08-26 17:37:20 +07:00
asepharyana 54e7220d06 fix(auto-delete): prevent double-processing + improve error classification
Two bugs causing 23 spurious 'error' logs after successful deletions:

1. batchProcessor switch missing 'completed' case: partitionBatchOutcome
   returns 'completed' for successful messages, but the switch only handled
   'upload_pending' and 'api_failed'. Successful messages fell through to
   default → re-enqueued to individual fallback → re-analyzed → re-delete
   attempt → error (message already gone from Discord). Now explicitly
   skips 'completed' messages.

2. isAlreadyDeletedError only caught codes 10008/404. Discord also returns
   10003 (Unknown Channel) and 50001 (Missing Access) when a message or
   channel is gone. Added these codes plus text-based fallback matching
   'Unknown Message'/'Unknown Channel'.

Impact: eliminates ~23 redundant error logs per day + stops wasted LLM
calls re-analyzing already-processed messages.
2026-08-26 17:31:37 +07:00
asepharyana 46d889271e fix(auto-delete): accept 'warn' recommendedAction + delete flagged+medium
isEligibleForAutoDelete was rejecting messages where recommendedAction
was 'warn' or 'review' — only 'delete' and 'escalate' were accepted.
This caused 28+ medium-severity flagged messages to be logged as
'not_eligible' instead of being auto-deleted.

Changes:
- deriveRecommendedAction: return 'delete' for flagged+medium severity
  (previously only critical/high triggered delete; medium got 'review')
- isEligibleForAutoDelete: accept 'warn' as valid recommendedAction
  alongside 'delete' and 'escalate'

Impact: ~28 pending medium-severity flagged messages + all future
'warn'-action flagged messages will now be eligible for auto-deletion.
2026-08-26 17:16:30 +07:00
asepharyana 0c3a82ad77 refactor: update recordings handling with pagination support and improve infinite scroll functionality 2026-08-26 17:05:41 +07:00
asepharyana b640079cb6 refactor: enhance message streaming and UI components; add SWR provider 2026-08-26 16:51:46 +07:00
asepharyana 37d15456dc refactor: remove unnecessary PageTransition wrappers from dashboard pages 2026-08-26 15:49:58 +07:00
asepharyana 153e628aba refactor: update component styles to use theme-aware colors and improve UI consistency 2026-08-26 15:43:43 +07:00
asepharyana a5f908dc06 refactor: update UI components for consistency and improved styling
- Refactored ChannelCultureGlossary, LiveModerationFeed, TermGlossary, and Chatbot components to use new design tokens and styles.
- Updated button, badge, and section components to align with the new design system.
- Enhanced the visual hierarchy and accessibility of various UI elements.
- Improved responsiveness and hover states across components.
- Replaced hardcoded colors with design tokens for better maintainability.
2026-08-26 14:57:08 +07:00
asepharyana 7fc36170c8 chore: update @types/node version to 26.2.0 in package.json and pnpm-lock.yaml 2026-08-26 13:51:54 +07:00
asepharyana ccfe769b2f Refactor moderation and recordings views; enhance UI components and improve performance
- Cleaned up imports and removed unused hooks in ModerationView and RecordingsView.
- Updated UI elements for better visual consistency and accessibility.
- Improved performance by optimizing rendering logic and reducing unnecessary state updates.
- Added filtering functionality in ChannelCultureGlossary and TermGlossary components.
- Enhanced LiveModerationFeed with GSAP animations for smoother transitions.
- Updated chatbot component for better user experience and responsiveness.
- Refined NavRail component for cleaner code and improved navigation item rendering.
2026-08-26 10:39:00 +07:00
asepharyana 3b688e0d0f Refactor code structure for improved readability and maintainability 2026-08-26 10:13:40 +07:00
asepharyana 29f59fd91f feat(frontend): revamp UI to Linear Precision Clean Dark with GSAP micro-interactions 2026-08-26 10:12:42 +07:00
asepharyana c3165ed702 chore: update dependabot-auto-merge.yml 2026-08-26 08:53:22 +07:00
asepharyana c72fadbbb4 chore: update dependabot.yml 2026-08-26 08:53:01 +07:00
Asep Hariyana 613840e2d1 chore(discord-gateway): update and sync lockfile for bun 2026-08-26 09:51:26 +08:00
asepharyana 24336186dc feat(frontend): add HUD header and GSAP accordion reveal to glossary route 2026-08-25 23:46:36 +07:00
asepharyana 6b66863897 feat(frontend): add HUD header, count-up gauge, and stagger reveal to analysis route 2026-08-25 23:41:39 +07:00
asepharyana fc40609a3a feat(frontend): add tactical HUD header and GSAP stagger deck to recordings route 2026-08-25 23:36:45 +07:00
asepharyana e157ae3ce4 feat(frontend): add alert-priority HUD header with GSAP pulse to moderation route 2026-08-25 23:32:21 +07:00
asepharyana 3f4c05f5bb feat(frontend): add tactical HUD header and scan-line reveal to messages feed 2026-08-25 23:28:11 +07:00
asepharyana 9c25e0887c feat(frontend): add tactical HUD header and GSAP speaking-pulse loop to voice route 2026-08-25 23:23:19 +07:00
asepharyana 2aa12b9ec5 feat(frontend): revamp channels into signal-map roster with GSAP stagger 2026-08-25 23:17:39 +07:00
asepharyana cab451f0e5 feat(frontend): revamp media frequency deck with GSAP animations and tactical visualizer 2026-08-25 23:05:05 +07:00
asepharyana 183a51003a feat(frontend): revamp dashboard into tactical HUD and integrate GSAP reactive stages 2026-08-25 22:59:29 +07:00
asepharyana f34811e9b6 Delete .github/workflows/flakehub-publish-rolling.yaml 2026-08-25 22:51:25 +07:00
asepharyana 2c30140ddd feat(frontend): migrate animations to GSAP and update client socket types for Next 16.3 2026-08-25 22:47:25 +07:00
asepharyana 7e92de63d6 feat(frontend): migrate animations to GSAP and add useGSAP lifecycle hooks 2026-08-25 22:41:41 +07:00
mytheclipsebotreview[bot] 68be4352da Auto-merge PR #18
build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/backend
2026-08-25 14:30:28 +00:00
mytheclipsebotreview[bot] 754ef78ff1 Auto-merge PR #20
build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/discord-gateway
2026-08-25 14:30:18 +00:00
mytheclipsebotreview[bot] 2959744e0c Auto-merge PR #19
build(deps-dev): bump @types/three from 0.180.0 to 0.185.4 in /services/frontend
2026-08-25 14:30:09 +00:00
dependabot[bot] cffa72c21f build(deps-dev): bump tsx in /services/discord-gateway
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:26:03 +00:00
dependabot[bot] d64c5ba7b6 build(deps-dev): bump @types/three in /services/frontend
Bumps [@types/three](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/three) from 0.180.0 to 0.185.4.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/three)

---
updated-dependencies:
- dependency-name: "@types/three"
  dependency-version: 0.185.4
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:25:57 +00:00
dependabot[bot] 87851f7a71 build(deps-dev): bump tsx from 4.23.1 to 4.23.12 in /services/backend
Bumps [tsx](https://github.com/privatenumber/tsx) from 4.23.1 to 4.23.12.
- [Release notes](https://github.com/privatenumber/tsx/releases)
- [Changelog](https://github.com/privatenumber/tsx/blob/master/release.config.cjs)
- [Commits](https://github.com/privatenumber/tsx/compare/v4.23.1...v4.23.12)

---
updated-dependencies:
- dependency-name: tsx
  dependency-version: 4.23.12
  dependency-type: direct:development
  update-type: version-update:semver-patch
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 14:25:55 +00:00
asepharyana 91de43a6c6 fix: switch GMW AI source from omniroute to 9router (model alias revert)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias

API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
2026-08-25 20:36:24 +07:00
asepharyana f8fc08de41 fix: switch GMW AI source from omniroute to 9router (model alias revert)
- Switch AI_LLM_BASE_URL from omniroute.imrnes.team to 9router.asepharyana.my.id
- Keep AI_LLM_MODEL as 'text' (9router uses alias-based routing, not bare names)
- Update .env.example comments to document 9router
- Per user: multimodal stays 'multimodal' alias

API verified: curl to 9router/v1/chat/completions with model 'text'
returns HTTP 200 (OpenAI-compatible format)
2026-08-25 20:36:08 +07:00
asepharyana dfa69e9f45 feat: add dependabot auto-merge workflow 2026-08-25 20:33:07 +07:00
asepharyana 0bd4b4075e Merge pull request #17 from asepharyana/feat/fe-error-handling-state
fix: switch GMW AI source from omniroute to 9router
2026-08-25 20:27:10 +07:00
asepharyana b679a02cb9 Merge pull request #16 from asepharyana/dependabot/npm_and_yarn/services/frontend/typescript-7.0.2
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/frontend
2026-08-25 20:05:31 +07:00
mytheclipsebotreview[bot] 4ed1d3c053 Auto-merge PR #6
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/backend
2026-08-25 11:36:10 +00:00
dependabot[bot] 8dbcc2b527 build(deps-dev): bump typescript in /services/frontend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:32:04 +00:00
dependabot[bot] bda210475d build(deps-dev): bump typescript in /services/backend
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:31:51 +00:00
mytheclipsebotreview[bot] 9b78abaf3d Auto-merge PR #7
build(deps): bump the production group across 1 directory with 8 updates
2026-08-25 11:29:54 +00:00
mytheclipsebotreview[bot] 53c4158511 Auto-merge PR #10
build(deps-dev): bump @types/node from 20.19.43 to 26.2.0 in /services/frontend
2026-08-25 11:29:43 +00:00
mytheclipsebotreview[bot] a2a8c824bf Auto-merge PR #13
build(deps-dev): bump @biomejs/biome from 2.2.0 to 2.5.10 in /services/frontend
2026-08-25 11:29:26 +00:00
mytheclipsebotreview[bot] dec548fa2f Auto-merge PR #4
build(deps-dev): bump @types/node from 25.9.5 to 26.2.0 in /services/backend
2026-08-25 11:29:18 +00:00
mytheclipsebotreview[bot] 12033e5537 Auto-merge PR #11
build(deps-dev): bump typescript from 5.9.3 to 7.0.2 in /services/discord-gateway
2026-08-25 11:29:10 +00:00
dependabot[bot] d06caa7e58 build(deps): bump the production group across 1 directory with 8 updates
Bumps the production group with 8 updates in the /services/discord-gateway directory:

| Package | From | To |
| --- | --- | --- |
| [ioredis](https://github.com/redis/ioredis) | `5.11.1` | `6.0.0` |
| [openai](https://github.com/openai/openai-node) | `6.49.0` | `7.5.0` |
| [opusscript](https://github.com/abalabahaha/opusscript) | `0.0.8` | `0.1.1` |
| [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg) | `8.22.0` | `8.23.0` |
| [pino](https://github.com/pinojs/pino) | `9.14.0` | `10.3.1` |
| [piscina](https://github.com/piscinajs/piscina) | `5.3.0` | `5.3.1` |
| [sharp](https://github.com/lovell/sharp) | `0.34.5` | `0.35.3` |
| [ws](https://github.com/websockets/ws) | `8.21.1` | `8.21.3` |



Updates `ioredis` from 5.11.1 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.1...v6.0.0)

Updates `openai` from 6.49.0 to 7.5.0
- [Release notes](https://github.com/openai/openai-node/releases)
- [Changelog](https://github.com/openai/openai-node/blob/main/CHANGELOG.md)
- [Commits](https://github.com/openai/openai-node/compare/v6.49.0...v7.5.0)

Updates `opusscript` from 0.0.8 to 0.1.1
- [Release notes](https://github.com/abalabahaha/opusscript/releases)
- [Commits](https://github.com/abalabahaha/opusscript/compare/0.0.8...0.1.1)

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `pino` from 9.14.0 to 10.3.1
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v9.14.0...v10.3.1)

Updates `piscina` from 5.3.0 to 5.3.1
- [Release notes](https://github.com/piscinajs/piscina/releases)
- [Changelog](https://github.com/piscinajs/piscina/blob/v5.3.1/CHANGELOG.md)
- [Commits](https://github.com/piscinajs/piscina/compare/v5.3.0...v5.3.1)

Updates `sharp` from 0.34.5 to 0.35.3
- [Release notes](https://github.com/lovell/sharp/releases)
- [Commits](https://github.com/lovell/sharp/compare/v0.34.5...v0.35.3)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: openai
  dependency-version: 7.5.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: opusscript
  dependency-version: 0.1.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pino
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: piscina
  dependency-version: 5.3.1
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: sharp
  dependency-version: 0.35.3
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:24:34 +00:00
dependabot[bot] 173ae3c61c build(deps-dev): bump @types/node in /services/frontend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 20.19.43 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:30 +00:00
dependabot[bot] 2637bbc549 build(deps-dev): bump @biomejs/biome in /services/frontend
Bumps [@biomejs/biome](https://github.com/biomejs/biome/tree/HEAD/packages/@biomejs/biome) from 2.2.0 to 2.5.10.
- [Release notes](https://github.com/biomejs/biome/releases)
- [Changelog](https://github.com/biomejs/biome/blob/main/packages/@biomejs/biome/CHANGELOG.md)
- [Commits](https://github.com/biomejs/biome/commits/@biomejs/biome@2.5.10/packages/@biomejs/biome)

---
updated-dependencies:
- dependency-name: "@biomejs/biome"
  dependency-version: 2.5.10
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:19 +00:00
dependabot[bot] 6fc68126ac build(deps-dev): bump @types/node in /services/backend
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:23:08 +00:00
dependabot[bot] 28d09a24c9 build(deps-dev): bump typescript in /services/discord-gateway
Bumps [typescript](https://github.com/microsoft/TypeScript) from 5.9.3 to 7.0.2.
- [Release notes](https://github.com/microsoft/TypeScript/releases)
- [Commits](https://github.com/microsoft/TypeScript/compare/v5.9.3...v7.0.2)

---
updated-dependencies:
- dependency-name: typescript
  dependency-version: 7.0.2
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:22:50 +00:00
mytheclipsebotreview[bot] f2ec642a0e Auto-merge PR #12
build(deps-dev): bump puppeteer-core from 25.7.0 to 25.8.0 in /services/frontend
2026-08-25 11:21:41 +00:00
mytheclipsebotreview[bot] 8a491b9ec2 Auto-merge PR #8
build(deps): bump the production group in /services/frontend with 7 updates
2026-08-25 11:20:56 +00:00
mytheclipsebotreview[bot] 7d54d3a660 Auto-merge PR #2
build(deps): bump the production group in /services/backend with 4 updates
2026-08-25 11:20:48 +00:00
mytheclipsebotreview[bot] 21509c25ff Auto-merge PR #15
build(deps-dev): bump @types/node from 25.9.5 to 26.2.0 in /services/discord-gateway
2026-08-25 11:20:35 +00:00
asepharyana 09724287cb feat: update dependabot config 2026-08-25 18:16:49 +07:00
dependabot[bot] 1ac3d50315 build(deps-dev): bump @types/node in /services/discord-gateway
Bumps [@types/node](https://github.com/DefinitelyTyped/DefinitelyTyped/tree/HEAD/types/node) from 25.9.5 to 26.2.0.
- [Release notes](https://github.com/DefinitelyTyped/DefinitelyTyped/releases)
- [Commits](https://github.com/DefinitelyTyped/DefinitelyTyped/commits/HEAD/types/node)

---
updated-dependencies:
- dependency-name: "@types/node"
  dependency-version: 26.2.0
  dependency-type: direct:development
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:57 +00:00
dependabot[bot] 9c48d50d25 build(deps-dev): bump puppeteer-core in /services/frontend
Bumps [puppeteer-core](https://github.com/puppeteer/puppeteer) from 25.7.0 to 25.8.0.
- [Release notes](https://github.com/puppeteer/puppeteer/releases)
- [Changelog](https://github.com/puppeteer/puppeteer/blob/main/CHANGELOG.md)
- [Commits](https://github.com/puppeteer/puppeteer/compare/puppeteer-core-v25.7.0...puppeteer-core-v25.8.0)

---
updated-dependencies:
- dependency-name: puppeteer-core
  dependency-version: 25.8.0
  dependency-type: direct:development
  update-type: version-update:semver-minor
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:28 +00:00
dependabot[bot] 8bd3ec8e83 build(deps): bump the production group
Bumps the production group in /services/frontend with 7 updates:

| Package | From | To |
| --- | --- | --- |
| [lucide-react](https://github.com/lucide-icons/lucide/tree/HEAD/packages/lucide-react) | `1.28.0` | `1.33.0` |
| [motion](https://github.com/motiondivision/motion) | `12.43.0` | `13.1.1` |
| [next](https://github.com/vercel/next.js) | `16.2.12` | `16.3.2` |
| [react](https://github.com/react/react/tree/HEAD/packages/react) | `19.2.4` | `19.2.8` |
| [react-dom](https://github.com/react/react/tree/HEAD/packages/react-dom) | `19.2.4` | `19.2.8` |
| [swr](https://github.com/vercel/swr) | `2.4.2` | `2.5.1` |
| [three](https://github.com/mrdoob/three.js) | `0.180.0` | `0.185.1` |


Updates `lucide-react` from 1.28.0 to 1.33.0
- [Release notes](https://github.com/lucide-icons/lucide/releases)
- [Commits](https://github.com/lucide-icons/lucide/commits/1.33.0/packages/lucide-react)

Updates `motion` from 12.43.0 to 13.1.1
- [Changelog](https://github.com/motiondivision/motion/blob/main/CHANGELOG.md)
- [Commits](https://github.com/motiondivision/motion/compare/v12.43.0...v13.1.1)

Updates `next` from 16.2.12 to 16.3.2
- [Release notes](https://github.com/vercel/next.js/releases)
- [Commits](https://github.com/vercel/next.js/compare/v16.2.12...v16.3.2)

Updates `react` from 19.2.4 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react)

Updates `react-dom` from 19.2.4 to 19.2.8
- [Release notes](https://github.com/react/react/releases)
- [Changelog](https://github.com/react/react/blob/main/CHANGELOG.md)
- [Commits](https://github.com/react/react/commits/v19.2.8/packages/react-dom)

Updates `swr` from 2.4.2 to 2.5.1
- [Release notes](https://github.com/vercel/swr/releases)
- [Commits](https://github.com/vercel/swr/compare/v2.4.2...v2.5.1)

Updates `three` from 0.180.0 to 0.185.1
- [Release notes](https://github.com/mrdoob/three.js/releases)
- [Commits](https://github.com/mrdoob/three.js/commits)

---
updated-dependencies:
- dependency-name: lucide-react
  dependency-version: 1.33.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: motion
  dependency-version: 13.1.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: next
  dependency-version: 16.3.2
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: react
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: react-dom
  dependency-version: 19.2.8
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
- dependency-name: swr
  dependency-version: 2.5.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: three
  dependency-version: 0.185.1
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:15:04 +00:00
dependabot[bot] 5a4a203f01 build(deps): bump the production group
Bumps the production group in /services/backend with 4 updates: [ioredis](https://github.com/redis/ioredis), [pg](https://github.com/brianc/node-postgres/tree/HEAD/packages/pg), [pino](https://github.com/pinojs/pino) and [ws](https://github.com/websockets/ws).


Updates `ioredis` from 5.11.1 to 6.0.0
- [Release notes](https://github.com/redis/ioredis/releases)
- [Changelog](https://github.com/redis/ioredis/blob/main/CHANGELOG.md)
- [Commits](https://github.com/redis/ioredis/compare/v5.11.1...v6.0.0)

Updates `pg` from 8.22.0 to 8.23.0
- [Changelog](https://github.com/brianc/node-postgres/blob/master/CHANGELOG.md)
- [Commits](https://github.com/brianc/node-postgres/commits/pg@8.23.0/packages/pg)

Updates `pino` from 9.14.0 to 10.3.1
- [Release notes](https://github.com/pinojs/pino/releases)
- [Commits](https://github.com/pinojs/pino/compare/v9.14.0...v10.3.1)

Updates `ws` from 8.21.1 to 8.21.3
- [Release notes](https://github.com/websockets/ws/releases)
- [Commits](https://github.com/websockets/ws/compare/8.21.1...8.21.3)

---
updated-dependencies:
- dependency-name: ioredis
  dependency-version: 6.0.0
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: pg
  dependency-version: 8.23.0
  dependency-type: direct:production
  update-type: version-update:semver-minor
  dependency-group: production
- dependency-name: pino
  dependency-version: 10.3.1
  dependency-type: direct:production
  update-type: version-update:semver-major
  dependency-group: production
- dependency-name: ws
  dependency-version: 8.21.3
  dependency-type: direct:production
  update-type: version-update:semver-patch
  dependency-group: production
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-25 11:14:06 +00:00
asepharyana 7f0aa5a7ad feat: dependabot config with multi-dir (backend, gateway, frontend) 2026-08-25 18:12:10 +07:00
asepharyana eb0be981c6 feat: add dependabot config (npm) 2026-08-25 18:07:58 +07:00
mytheclipsebotreview[bot] 466e117bd5 Auto-merge PR #1
feat(fe): optimize error handling, state consistency, and WS feedback
2026-08-25 08:36:58 +00:00
107 changed files with 6349 additions and 4000 deletions
+1 -1
View File
@@ -86,7 +86,7 @@ BACKLOG_SYNC_BATCH_SIZE=100 # Messages per backlog batch, max 100 (d
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_BASE_URL=https://9router.asepharyana.my.id/v1 # LLM API base URL (9router — OpenAI-compatible router, replaces omniroute)
AI_LLM_MODEL=claude-opus-5 # LLM text model name (default: claude-opus-5)
AI_LLM_MODEL=text # LLM text model name (default: text)
# 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_MIN_SIMILARITY=0.97 # Min cosine similarity to reuse a cached verdict (default: 0.97)
+17
View File
@@ -0,0 +1,17 @@
name: Dependabot Auto-Merge
on: pull_request
permissions:
contents: write
pull-requests: write
jobs:
auto-merge:
runs-on: ubuntu-latest
if: github.actor == 'dependabot[bot]'
steps:
- name: Enable auto-merge for Dependabot PR
run: gh pr merge --auto --merge "$PR_URL"
env:
PR_URL: ${{ github.event.pull_request.html_url }}
GH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
+38
View File
@@ -0,0 +1,38 @@
version: 2
updates:
- package-ecosystem: "npm"
directory: "/services/backend"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/services/discord-gateway"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
- package-ecosystem: "npm"
directory: "/services/frontend"
schedule:
interval: "daily"
open-pull-requests-limit: 10
groups:
production:
dependency-type: "production"
update-types: ["minor", "patch"]
development:
dependency-type: "development"
update-types: ["minor", "patch"]
@@ -1,20 +0,0 @@
name: Publish to FlakeHub
on:
push:
branches: [main, master]
workflow_dispatch:
jobs:
flakehub-publish:
runs-on: ubuntu-latest
permissions:
id-token: write
contents: read
steps:
- uses: actions/checkout@v6
- uses: DeterminateSystems/determinate-nix-action@main
- uses: DeterminateSystems/flakehub-push@main
with:
visibility: public
rolling: true
+8 -8
View File
@@ -21,21 +21,21 @@
"drizzle-orm": "^0.45.2",
"express": "^5.2.1",
"helmet": "^8.1.0",
"ioredis": "^5.11.0",
"pg": "^8.21.0",
"pino": "^9.6.0",
"ioredis": "^6.0.0",
"pg": "^8.23.0",
"pino": "^10.3.1",
"prom-client": "^15.1.3",
"ws": "^8.20.1",
"ws": "^8.21.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "latest",
"@types/express": "^5.0.6",
"@types/node": "^25.9.0",
"@types/pg": "^8.20.0",
"@types/node": "^26.2.0",
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.1",
"tsx": "^4.22.2",
"typescript": "^5.9.3",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vitest": "latest"
}
}
+485 -287
View File
File diff suppressed because it is too large Load Diff
@@ -469,17 +469,20 @@ export class MessagesRepository {
const db = getDatabase();
const since = Date.now() - days * 24 * 60 * 60 * 1000;
const result = await db.execute(sql`
SELECT channel_id,
EXTRACT(HOUR FROM to_timestamp(created_at / 1000))::int AS hour,
COUNT(*)::int AS c
FROM messages
WHERE created_at >= ${since}
GROUP BY channel_id, hour
ORDER BY channel_id, hour
SELECT
m.channel_id,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
EXTRACT(HOUR FROM to_timestamp(m.created_at / 1000))::int AS hour,
COUNT(*)::int AS c
FROM messages m
WHERE m.created_at >= ${since}
GROUP BY m.channel_id, channel_name, hour
ORDER BY channel_name, hour
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
return rows.map((r) => ({
channelId: String(r.channel_id ?? "unknown"),
channelName: String(r.channel_name ?? r.channel_id ?? "unknown"),
hour: Number(r.hour ?? 0),
count: Number(r.c ?? 0),
}));
@@ -491,31 +494,28 @@ export class MessagesRepository {
*/
async getRecentEdits(limit = 50, channelId?: string) {
const db = getDatabase();
const where = channelId
? `WHERE m.channel_id = '${channelId.replace(/'/g, "''")}'`
: "";
const result = await db.execute(
sql.raw(`
SELECT
e.id,
e.message_id,
e.old_content,
e.edited_at,
m.channel_id,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
m.username
FROM message_edits e
JOIN messages m ON m.id = e.message_id
${where}
ORDER BY e.edited_at DESC
LIMIT ${limit}
`),
);
const result = await db.execute(sql`
SELECT
e.id,
e.message_id,
e.old_content,
e.edited_at,
m.channel_id,
COALESCE(NULLIF((m.metadata::jsonb -> 'channel' ->> 'channelName'), ''), m.channel_id) AS channel_name,
m.username,
COALESCE(m.edited_content, m.content) AS new_content
FROM message_edits e
JOIN messages m ON m.id = e.message_id
${channelId ? sql`WHERE m.channel_id = ${channelId}` : sql``}
ORDER BY e.edited_at DESC
LIMIT ${limit}
`);
const rows = (result.rows as Record<string, unknown>[]) || [];
return rows.map((r) => ({
id: String(r.id),
message_id: String(r.message_id),
old_content: r.old_content ? String(r.old_content) : "",
new_content: r.new_content ? String(r.new_content) : "",
edited_at: r.edited_at ? Number(r.edited_at) : 0,
channel_id: r.channel_id ? String(r.channel_id) : null,
channel_name: r.channel_name ? String(r.channel_name) : null,
@@ -39,12 +39,15 @@ export class MessagesService {
throw new ValidationError("message ID is required");
}
const message = await messagesRepository.findById(id);
const [message, editHistory] = await Promise.all([
messagesRepository.findById(id),
messagesRepository.getEditHistory(id),
]);
if (!message) {
throw new NotFoundError(`Message with ID ${id} not found`);
}
const editHistory = await messagesRepository.getEditHistory(id);
return {
...message,
edit_count: editHistory.length,
@@ -124,7 +124,8 @@ export class ModerationRepository {
a.score,
a.evidence,
a.policy_version,
m.username,
a.username,
a.server_nick,
LEFT(m.content, 300) AS content
FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id
@@ -155,6 +156,7 @@ export class ModerationRepository {
evidence: parseJsonArray(r.evidence),
policy_version: r.policy_version ? String(r.policy_version) : null,
username: r.username ? String(r.username) : null,
server_nick: r.server_nick ? String(r.server_nick) : null,
content: r.content ? String(r.content) : null,
}));
@@ -314,7 +316,7 @@ export class ModerationRepository {
SELECT
a.id, a.message_id, a.user_id, a.guild_id, a.action_type,
a.reason, a.status, a.created_at, a.severity, a.confidence, a.score,
m.username, LEFT(m.content, 300) AS content
a.username, LEFT(m.content, 300) AS content
FROM moderation_actions a
LEFT JOIN messages m ON m.id = a.message_id
WHERE a.created_at >= ${since}
@@ -338,6 +340,7 @@ export class ModerationRepository {
confidence: r.confidence != null ? Number(r.confidence) : null,
score: r.score != null ? Number(r.score) : null,
username: r.username ? String(r.username) : null,
server_nick: r.server_nick ? String(r.server_nick) : null,
content: r.content ? String(r.content) : null,
}));
}
@@ -23,14 +23,26 @@ const speakers = new Map<string, LiveSpeaker>();
const MAX_SPEAKERS = 200;
/**
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them for the
* activity timeline.
* Speakers inactive for longer than this are auto-expired from the snapshot.
* This handles the case where the gateway disconnects abruptly and never
* sends `speaking: false` for active users.
*/
const SPEAKER_TTL_MS = 30_000;
/** Purge speakers that haven't been active recently. */
function purgeStale(): void {
const cutoff = Date.now() - SPEAKER_TTL_MS;
for (const [id, s] of speakers) {
if (!s.speaking && s.lastActiveAt < cutoff) {
speakers.delete(id);
}
}
}
/**
* recordSpeaker(data) — apply a `voice_active_user` event. `speaking: true`
* upserts the speaker as ACTIVE; `speaking: false` marks them inactive while
* keeping them for the activity timeline.
* Record a voice_active_user event. `speaking: true` upserts the speaker as
* active; `speaking: false` marks them inactive while keeping them briefly
* for the activity timeline (until TTL expiry).
*/
export function recordSpeaker(data: {
userId: string;
@@ -49,7 +61,6 @@ export function recordSpeaker(data: {
};
if (speakers.size >= MAX_SPEAKERS && !existing) {
// Drop the least-recently-active non-speaking speaker to stay bounded.
let oldestId: string | null = null;
let oldestTs = Infinity;
for (const [id, s] of speakers) {
@@ -65,18 +76,35 @@ export function recordSpeaker(data: {
speakers.set(userId, speaker);
}
/** All known speakers, most recently active first. */
/**
* All recently-active speakers, most recently active first.
* Stale (non-speaking + old) entries are auto-purged.
*/
export function getActiveSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()].sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/** Only speakers currently flagged as speaking. */
export function getSpeakingSpeakers(): LiveSpeaker[] {
purgeStale();
return [...speakers.values()]
.filter((s) => s.speaking)
.sort((a, b) => b.lastActiveAt - a.lastActiveAt);
}
/**
* Mark ALL tracked speakers as not-speaking and purge stale ones.
* Called when the gateway disconnects from voice — ensures the authoritative
* snapshot doesn't carry ghost speakers.
*/
export function clearAllSpeakers(): void {
for (const [id, s] of speakers) {
s.speaking = false;
}
purgeStale();
}
/** Drop all tracked speakers (used on backend restart). */
export function resetLiveSpeakers(): void {
speakers.clear();
@@ -1,5 +1,22 @@
// Shared message row mapper for backend repository modules
function resolveServerNick(row: Record<string, unknown>): string | null {
const metadata = row.metadata;
if (!metadata || typeof metadata !== "string") {
return (row.username as string | null) ?? null;
}
try {
const parsed = JSON.parse(metadata) as {
member?: { displayName?: string | null } | null;
};
return (
parsed?.member?.displayName ?? (row.username as string | null) ?? null
);
} catch {
return (row.username as string | null) ?? null;
}
}
export interface MappedMessage {
id: string;
guild_id: string;
@@ -7,6 +24,8 @@ export interface MappedMessage {
thread_id: string | null;
user_id: string;
username: string;
/** Member's server-specific display name (nickname), from metadata.member.displayName. Falls back to username when absent. */
server_nick: string | null;
avatar_url: string | null;
content: string;
edited_content: string | null;
@@ -42,6 +61,7 @@ export function mapMessageRow(row: Record<string, unknown>): MappedMessage {
thread_id: (row.thread_id as string | null) ?? null,
user_id: String(row.user_id ?? ""),
username: String(row.username ?? ""),
server_nick: resolveServerNick(row),
avatar_url: (row.avatar_url as string | null) ?? null,
content: String(row.content ?? ""),
edited_content: (row.edited_content as string | null) ?? null,
+13 -1
View File
@@ -1,10 +1,14 @@
import Redis from "ioredis";
import { recordSpeaker } from "../modules/voice/live-speaker.js";
import {
clearAllSpeakers,
recordSpeaker,
} from "../modules/voice/live-speaker.js";
import { config } from "../shared/config/index.js";
import {
DISCORD_CHANNEL_TO_WS_EVENT,
DISCORD_VOICE_ACTIVE_USER,
DISCORD_VOICE_PCM,
DISCORD_VOICE_STOPPED,
} from "../shared/index.js";
import { createChildLogger } from "../shared/logger/index.js";
import { broadcastBinary, broadcastEvent } from "./broadcast.js";
@@ -84,6 +88,14 @@ function handleSubscriptionMessage(channel: string, message: string): void {
}
}
// When the gateway stops voice recording (disconnects from voice channel),
// clear all speakers from the authoritative snapshot so frontends don't
// show ghost participants.
if (channel === DISCORD_VOICE_STOPPED) {
clearAllSpeakers();
logger.info("Voice recording stopped — cleared all live speakers");
}
logger.debug({ channel, eventType }, "Broadcasting Redis event");
broadcastEvent(eventType, data);
}
@@ -0,0 +1,9 @@
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "username" text;
--> statement-breakpoint
-- Backfill username from messages table for existing actions
UPDATE "moderation_actions" a
SET "username" = m."username"
FROM "messages" m
WHERE a."message_id" = m."id"
AND a."username" IS NULL
AND m."username" IS NOT NULL;
@@ -0,0 +1 @@
ALTER TABLE "moderation_actions" ADD COLUMN IF NOT EXISTS "server_name" text;
@@ -0,0 +1,26 @@
-- Rename server_name (guild name) -> server_nick (member's server nickname)
-- server_name was added in 0018 but never populated (all NULL), so renaming
-- the empty column is safe and keeps retention semantics correct.
-- Idempotent across all possible prior states:
-- • server_name present, server_nick absent -> rename
-- • both present (backfill created server_nick) -> drop the stale server_name
-- • server_nick present only -> already done, no-op
DO $$
BEGIN
IF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
) AND NOT EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
) THEN
ALTER TABLE "moderation_actions" RENAME COLUMN "server_name" TO "server_nick";
ELSIF EXISTS (
SELECT 1 FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_name'
) THEN
-- server_nick already exists (e.g. backfilled out-of-band) — drop the
-- now-redundant server_name column instead of renaming into a conflict.
ALTER TABLE "moderation_actions" DROP COLUMN "server_name";
END IF;
END $$;
@@ -120,6 +120,27 @@
"when": 1787185000000,
"tag": "0016_drop_user_reputations",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1787810000000,
"tag": "0017_add_username_to_moderation_actions",
"breakpoints": true
},
{
"idx": 18,
"version": "7",
"when": 1787820000000,
"tag": "0018_add_server_name_to_moderation_actions",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1787830000000,
"tag": "0019_rename_server_name_to_server_nick",
"breakpoints": true
}
]
}
+12 -12
View File
@@ -28,30 +28,30 @@
"discord.js-selfbot-v13": "^3.7.1",
"dotenv": "^17.4.2",
"drizzle-orm": "^0.45.2",
"ioredis": "^5.11.0",
"ioredis": "^6.0.0",
"libsodium-wrappers": "^0.8.4",
"lru-cache": "^11.5.1",
"openai": "^6.38.0",
"opusscript": "^0.0.8",
"openai": "^7.5.0",
"opusscript": "^0.1.1",
"p-limit": "^7.3.0",
"p-retry": "^8.0.0",
"pg": "^8.21.0",
"pino": "^9.6.0",
"piscina": "^5.1.4",
"pg": "^8.23.0",
"pino": "^10.3.1",
"piscina": "^5.3.1",
"prism-media": "2.0.0-alpha.0",
"sharp": "^0.34.5",
"sharp": "^0.35.3",
"tiktoken": "^1.0.22",
"ws": "^8.20.1",
"ws": "^8.21.3",
"zod": "^4.4.3"
},
"devDependencies": {
"@biomejs/biome": "latest",
"@types/node": "^25.9.0",
"@types/pg": "^8.20.0",
"@types/node": "^26.2.0",
"@types/pg": "^8.23.1",
"@types/ws": "^8.18.1",
"drizzle-kit": "^0.31.10",
"tsx": "^4.22.2",
"typescript": "^5.9.3",
"tsx": "^4.23.12",
"typescript": "^7.0.2",
"vitest": "latest"
}
}
+660 -435
View File
File diff suppressed because it is too large Load Diff
@@ -163,12 +163,18 @@ export function startPendingAIAnalysisWorker(
});
}
messageStore.revertStuckProcessingMessages(300000).catch((err: unknown) => {
logger.error(
{ error: String(err) },
"Failed to run stuck processing recovery",
);
});
// Only revert stuck processing messages if there's active processing.
// Avoids a DB query every recovery interval when the pipeline is idle.
if (conversationProcessing.size > 0) {
messageStore
.revertStuckProcessingMessages(300000)
.catch((err: unknown) => {
logger.error(
{ error: String(err) },
"Failed to run stuck processing recovery",
);
});
}
Promise.all([
messageStore.getPendingConversationKeys(500),
@@ -39,7 +39,7 @@ export function deriveRecommendedAction(msg: MessageRecord): string {
const severity = deriveSeverity(msg);
if (
msg.ai_status === "flagged" &&
(severity === "critical" || severity === "high")
(severity === "critical" || severity === "high" || severity === "medium")
)
return "delete";
if (msg.ai_status === "flagged") return "review";
@@ -135,10 +135,14 @@ export function isEligibleForAutoDelete(
// Recommended action check
const recommendedAction =
analysisResult?.recommendedAction ?? deriveRecommendedAction(message);
if (recommendedAction !== "delete" && recommendedAction !== "escalate") {
if (
recommendedAction !== "delete" &&
recommendedAction !== "escalate" &&
recommendedAction !== "warn"
) {
logger.debug(
{ messageId: message.id, recommendedAction },
"Message not eligible for auto-delete: recommended action is not delete/escalate",
"Message not eligible for auto-delete: recommended action is not delete/escalate/warn",
);
return false;
}
@@ -2,6 +2,7 @@ import type { Client, PermissionString } from "discord.js-selfbot-v13";
import { LRUCache } from "lru-cache";
import { createChildLogger } from "@/shared/logger/index";
import { config } from "../../shared/config/config.js";
import { parseRichMessageMetadata } from "../message-capture/messageMetadata.js";
import { messageStore } from "../message-capture/messageStore.js";
import type { MessageRecord } from "../message-capture/types.js";
import {
@@ -10,6 +11,7 @@ import {
} from "./autoDeleteEligibility.js";
import { logDeletionToChannel } from "./autoDeleteLogger.js";
import { sendDeletionNotification } from "./autoDeleteNotify.js";
import { llmChat } from "./llmClient.js";
import { verdictToActionFields } from "./verdictToActionFields.js";
const logger = createChildLogger("auto-delete-manager");
@@ -94,6 +96,26 @@ export async function resetOffensiveNickname(
}
// setNickname(null) = remove nickname → Discord shows global username
await member.setNickname(null, "[auto] nickname melanggar aturan server");
// ── Check if the global username is ALSO offensive ──────────────
// After reset, the member shows their global username. If that is
// also offensive (gambling/scam keywords), replace it with a random
// generated username so the user can't circumvent the filter by
// having an offensive global username.
const refreshedMember = await guild.members.fetch(userId);
const globalUsername = refreshedMember.user?.username ?? "";
if (await isGlobalUsernameOffensiveAI(globalUsername)) {
const randomName = generateRandomUsername();
await refreshedMember.setNickname(
randomName,
"[auto] global username juga melanggar, diganti random",
);
logger.info(
{ messageId, guildId, userId, globalUsername, randomName },
"Global username also offensive — replaced with random username",
);
}
recentNicknameResets.set(cooldownKey, Date.now());
logger.info(
{ messageId, guildId, userId },
@@ -119,6 +141,69 @@ export async function resetOffensiveNickname(
}
}
// ─── AI-Based Global Username Check ──────────────────────────────────
/**
* Ask the AI moderation LLM whether a global username is offensive
* (gambling, scam, spam, NSFW, etc.). Returns `true` if the LLM
* judges the username as violating server rules.
*
* Fail-open: if the LLM call fails or times out, returns `false`
* so the nickname reset still completes we don't want a broken LLM
* to block enforcement.
*/
async function isGlobalUsernameOffensiveAI(username: string): Promise<boolean> {
try {
const completion = await llmChat({
messages: [
{
role: "system",
content:
'Kamu adalah moderator Discord. Tentukan apakah username berikut melanggar aturan server (judi, togel, scam, spam, NSFW, SARA, atau ofensif). Jawab HANYA dengan JSON: {"offensive": true} atau {"offensive": false}. Jangan penjelasan tambahan.',
},
{
role: "user",
content: `Username: "${username}"`,
},
],
max_tokens: 50,
temperature: 0,
stream: false,
timeout: 10_000,
});
const content = completion?.choices?.[0]?.message?.content?.trim() ?? "";
// Parse JSON response — handle both strict JSON and markdown-wrapped
const jsonMatch = content.match(
/\{[^}]*"offensive"\s*:\s*(true|false)[^}]*\}/,
);
if (jsonMatch) {
const parsed = JSON.parse(jsonMatch[0]) as { offensive: boolean };
return parsed.offensive === true;
}
// Fallback: if response contains "true" anywhere, treat as offensive
return content.toLowerCase().includes("true");
} catch (error) {
logger.warn(
{
username,
error: error instanceof Error ? error.message : String(error),
},
"AI username check failed — failing open (not offensive)",
);
return false;
}
}
/**
* Generate a random fallback username: "User" + 5-digit number.
* Guaranteed unique per call (random suffix).
*/
function generateRandomUsername(): string {
const suffix = Math.floor(10000 + Math.random() * 90000);
return `User${suffix}`;
}
// ─── Error Handling Utilities ────────────────────────────────────────
function getErrorCode(error: unknown): number | string | undefined {
@@ -130,7 +215,28 @@ function getErrorCode(error: unknown): number | string | undefined {
function isAlreadyDeletedError(error: unknown): boolean {
const code = getErrorCode(error);
return code === 10008 || code === 404 || code === "10008" || code === "404";
// Discord REST error codes for "message not found":
// 10008 = Unknown Message, 10003 = Unknown Channel,
// 50001 = Missing Access (channel deleted/hidden), 404 = HTTP
if (
code === 10008 ||
code === 10003 ||
code === 50001 ||
code === 404 ||
code === "10008" ||
code === "10003" ||
code === "50001" ||
code === "404"
)
return true;
// Fallback: check the message text for the Discord "Unknown Message" string
const msg =
error instanceof Error
? error.message
: typeof error === "string"
? error
: "";
return msg.includes("Unknown Message") || msg.includes("Unknown Channel");
}
function hasChannelMessagesApi(channel: unknown): channel is {
@@ -164,6 +270,21 @@ function hasPermissionApi(channel: unknown): channel is {
// ─── Database Action Log ─────────────────────────────────────────────
/**
* Extract the member's server-specific display name (nickname) from a
* captured message's metadata. Falls back to the global username when no
* server nickname exists (member.displayName defaults to the guild nickname
* when set, otherwise the global username).
*/
function resolveServerNick(message: MessageRecord): string | null {
try {
const parsed = parseRichMessageMetadata(message.metadata);
return parsed?.member?.displayName ?? message.username ?? null;
} catch {
return message.username ?? null;
}
}
async function logAutoDeleteAttempt(
message: MessageRecord,
result: AutoDeleteResult,
@@ -176,6 +297,8 @@ async function logAutoDeleteAttempt(
action_type: "delete_message",
reason: result.reason,
...verdictToActionFields(message),
username: message.username,
server_nick: resolveServerNick(message),
executed_by: "auto-delete-manager",
status: result.deleted
? "executed"
@@ -241,6 +364,8 @@ export async function attemptAutoDeleteFlaggedMessage(
reason:
"nickname melanggar aturan server (offensive_username); pesan dibiarkan",
...verdictToActionFields(message),
username: message.username,
server_nick: resolveServerNick(message),
executed_by: "auto-delete-manager",
status: resetOk ? "executed" : "failed",
error: resetOk ? null : "nickname_reset_failed",
@@ -163,20 +163,14 @@ export async function processBatch(
messages,
})) as AnalysisWorkerResponse;
// Do not broadcast or auto-delete if it's an API failure that will be reverted.
// Broadcast + auto-delete only for successfully analyzed rows.
// Error rows (API failures, parse failures, incomplete) will be
// retried by the individual fallback queue — do NOT schedule
// auto-delete for them (they'd be logged as not_eligible anyway).
for (const row of result.rows) {
let isApiFailure = false;
if (row.ai_status === "error") {
try {
const flags = JSON.parse(row.ai_moderation_flags ?? "[]") as string[];
isApiFailure = flags.includes("analysis_api_failed");
} catch {}
}
if (!isApiFailure) {
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
if (row.ai_status === "error") continue;
broadcastAnalysisCompleted(row);
scheduleAutoDelete(row);
}
if (!result.ok) {
@@ -233,6 +227,10 @@ export async function processBatch(
// conversation cooldown instead of an immediate individual retry.
apiFailedMessages.push(msg);
break;
case "completed":
// Successfully analyzed — already broadcast + auto-delete scheduled
// above. Do NOT re-enqueue for individual fallback.
break;
default:
// incomplete / parse_failed / unexplained drops stay retryable via
// the individual fallback queue (same semantics as before).
@@ -136,6 +136,12 @@ export function formatMessageForPrompt(
return `[${label}] id=${msg.id} time=${timestamp} user=${resolveDisplayName(msg)}: ${content}${mediaSuffix}${refInfo}`;
}
/** Estimate tokens for a batch of strings — single encode call, ~5x faster than per-line. */
export function estimateTokensBatch(texts: string[]): number {
const combined = texts.join("\n");
return getEncoder().encode(combined).length + texts.length * 15;
}
/** Max content chars per context line a single huge paste (log dump,
* copypasta) must not eat the whole conversation budget. */
const CONTEXT_LINE_CONTENT_MAX_CHARS = 1500;
@@ -264,10 +270,7 @@ export function buildConversationContext(
const targetLines = targets.map((msg) =>
formatMessageForPrompt(msg, "target"),
);
let usedTokens = targetLines.reduce(
(sum, line) => sum + estimateTokens(line),
0,
);
let usedTokens = estimateTokensBatch(targetLines);
const contextLines = gatedNewestFirst.map((msg) =>
formatMessageForPrompt(msg, "context"),
@@ -16,6 +16,7 @@ import { delay, retryWithBackoff } from "@/shared/utils/index";
import { config } from "../../shared/config/config.js";
import type { AnalysisResult } from "../message-capture/types.js";
import { llmChat } from "./llmClient.js";
import { parseModerationResponse } from "./moderationResponseParser.js";
import { logModerationError } from "./responseLogger.js";
const log = createChildLogger("llm-caller");
@@ -79,7 +80,8 @@ export async function callModerationLLM(
];
const completion = await llmChat({
messages,
max_tokens: maxTokens ?? 16384,
max_tokens:
maxTokens ?? config.AI_LLM_MAX_COMPLETION_TOKENS ?? 16384,
jsonResponse: { type: "json_object" },
retries: 0,
signal,
@@ -106,9 +108,6 @@ export async function callModerationLLM(
if (!rawContent) throw new Error("No content in LLM response");
try {
const { parseModerationResponse } = await import(
"./moderationResponseParser.js"
);
return {
parsed: parseModerationResponse(rawContent, targetIds),
result: completion,
@@ -461,7 +461,9 @@ export async function runModerationAnalysis(
cacheKey,
stored,
embeddingsByKey.get(cacheKey),
).catch(() => {});
).catch((err: unknown) => {
log.warn({ cacheKey, error: String(err) }, "Cache write failed");
});
// Dual-key write-back (2026-08-24): the FIRST analysis of a message runs
// WITH conversation context (accurate), but its verdict is also stored
@@ -69,6 +69,26 @@ export async function buildCorrectedFewShotExamples(): Promise<string> {
}
}
// ---------------------------------------------------------------------------
// Few-shot correction cache (refreshes hourly)
// ---------------------------------------------------------------------------
let _correctedExamplesCache: string | null = null;
let _correctedExamplesCacheAt = 0;
const CORRECTED_CACHE_TTL_MS = 60 * 60 * 1000;
async function getCachedCorrectedExamples(): Promise<string> {
const now = Date.now();
if (
_correctedExamplesCache !== null &&
now - _correctedExamplesCacheAt < CORRECTED_CACHE_TTL_MS
) {
return _correctedExamplesCache;
}
_correctedExamplesCache = await buildCorrectedFewShotExamples();
_correctedExamplesCacheAt = now;
return _correctedExamplesCache;
}
// ---------------------------------------------------------------------------
// Text-only batch
// ---------------------------------------------------------------------------
@@ -88,7 +108,22 @@ export async function runTextOnlyBatch(
for (const url of extractUrlsFromText(msg.edited_content ?? msg.content))
allUrls.add(url);
}
const urlArr = Array.from(allUrls).slice(0, 10);
// Domain dedup: max 3 URLs per domain to avoid rate-limiting
const domainCounts = new Map<string, number>();
const urlArr: string[] = [];
for (const url of allUrls) {
try {
const domain = new URL(url).hostname;
const count = domainCounts.get(domain) ?? 0;
if (count >= 3) continue;
domainCounts.set(domain, count + 1);
} catch {
/* invalid URL, skip */
continue;
}
urlArr.push(url);
if (urlArr.length >= 10) break;
}
if (urlArr.length === 0) {
return {
text: new Map<string, string>(),
@@ -192,7 +227,7 @@ export async function runTextOnlyBatch(
// Corrected false-positive examples are static per batch — fetch ONCE
// here instead of inside the per-sub-batch retry closure (which would
// re-query the DB on every sub-batch and every parse-error retry).
const correctedExamples = await buildCorrectedFewShotExamples();
const correctedExamples = await getCachedCorrectedExamples();
// ── URL images → multimodal vision evidence (hoisted out of the sub-batch
// loop) ───────────────────────────────────────────────────────────
@@ -368,9 +403,13 @@ export async function runTextOnlyBatch(
);
} catch (err: any) {
if (err.name === "AbortError" || abortController.signal.aborted) {
throw new Error(
`Text-only batch sub-batch ${i + 1} timed out for messages ${targetIds.join(", ")}`,
// Sub-batch timed out — log but DO NOT throw. Previous sub-batches'
// results are already in allResults; throwing would discard them.
log.warn(
{ subBatch: i + 1, targetIds, timeoutMs },
"Sub-batch timed out — preserving partial results from prior sub-batches",
);
continue;
}
throw err;
} finally {
@@ -103,6 +103,8 @@ export class ModerationHandler {
| "kick_user"
| "ban_user",
reason: payload.reason ?? null,
username: null,
server_nick: null,
executed_by: payload.executed_by ?? "command-handler",
status: "executed",
error: null,
@@ -1,4 +1,5 @@
import type { Client } from "discord.js-selfbot-v13";
import Redis from "ioredis";
import { config } from "../../shared/config/config.js";
import type { CommandMessage, CommandReply } from "../../shared/index.js";
import { createChildLogger } from "../../shared/logger/index.js";
@@ -152,13 +153,28 @@ export class VoiceHandler {
};
}
// Double-check: verify the voice controller also reports connected
if (this.voiceController) {
const vcStatus = this.voiceController.getStatus();
if (!vcStatus.connected) {
this.logger.warn(
"Player reports connected but voice controller says disconnected — stale state",
);
return {
id: cmd.id,
success: false,
data: null,
error: "Voice channel connection is stale — reconnect first",
};
}
}
try {
// IMPORTANT: transmitter needs its OWN Redis client because it calls
// .subscribe() which converts the connection to subscriber mode. Reusing
// the publish connection from CommandHandler would corrupt it and break
// every command reply + status update.
const { default: IORedis } = await import("ioredis");
const transmitRedis = new IORedis(config.REDIS_URL);
const transmitRedis = new Redis(config.REDIS_URL);
await voiceTransmitter.start(transmitRedis);
const status = voiceTransmitter.getStatus();
@@ -50,6 +50,21 @@ export function finalizeSegment(input: SegmentFinalizerInput): void {
} = input;
const endTime = currentSegment.endTime ?? Date.now();
const durationMs = endTime - currentSegment.startTime;
// Discard segments shorter than 1 second — not useful as a recording,
// would just be a blip of ambient noise or a mic click.
const MIN_DURATION_MS = 1000;
if (durationMs < MIN_DURATION_MS) {
logger.debug(
{ filename: currentSegment.filename, durationMs },
"Segment too short, discarding",
);
// Clean up the OGG file
fsPromises.unlink(currentSegment.filename).catch(() => {});
fsPromises.unlink(currentSegment.jsonFilename).catch(() => {});
return;
}
if (config.VERBOSE) {
logger.info({ filename: currentSegment.filename }, "Segment saved");
@@ -23,10 +23,18 @@ export class VoiceTransmitter {
private readonly TRANSMIT_CHANNEL = BACKEND_VOICE_TRANSMIT;
/** Queue for PCM chunks when backpressure is active */
private backpressureQueue: Buffer[] = [];
/** Max queued chunks before dropping oldest (prevents unbounded memory growth) */
private static readonly MAX_QUEUE = 500;
/** Serialise start/stop to prevent races between rapid toggle commands */
private gate = Promise.resolve();
/** Set true before sending SIGTERM so exit handler knows it's intentional */
private _expectedExit = false;
/** Auto-stop timer: if no PCM received for this long, stop transmitter */
private _activityTimer: ReturnType<typeof setTimeout> | null = null;
/** How long to wait without PCM before auto-stopping (10s) */
private static readonly ACTIVITY_TIMEOUT_MS = 10_000;
/** Max stderr bytes to retain for error reporting */
private static readonly MAX_STDERR_BYTES = 4096;
/**
* Start listening for PCM audio data from Redis and stream to Discord
@@ -48,7 +56,6 @@ export class VoiceTransmitter {
return;
}
this.redisSub = redis;
this.isActive = true;
// Create PCM input stream
@@ -115,10 +122,14 @@ export class VoiceTransmitter {
this.pcmStream.pipe(this.ffmpegProcess.stdin);
}
// Log FFmpeg stderr for debugging
// Log FFmpeg stderr for debugging (cap to prevent unbounded growth)
const stderrChunks: Buffer[] = [];
let stderrLen = 0;
this.ffmpegProcess.stderr?.on("data", (chunk: Buffer) => {
stderrChunks.push(chunk);
stderrLen += chunk.length;
if (stderrLen <= VoiceTransmitter.MAX_STDERR_BYTES) {
stderrChunks.push(chunk);
}
});
this.ffmpegProcess.on("error", (err) => {
@@ -135,8 +146,11 @@ export class VoiceTransmitter {
const stderr = Buffer.concat(stderrChunks).toString();
logger.error(
{ code, stderr: stderr.slice(-500) },
"FFmpeg exited with error",
"FFmpeg exited with error — auto-stopping transmitter",
);
// FFmpeg crashed — auto-stop to prevent silent audio loss.
// Fire-and-forget; stop() serialises with the gate.
void this.stop();
} else {
logger.debug({ code }, "FFmpeg process exited");
}
@@ -155,11 +169,15 @@ export class VoiceTransmitter {
);
// Subscribe to Redis channel for PCM data
await this.redisSub.subscribe(this.TRANSMIT_CHANNEL);
await redis.subscribe(this.TRANSMIT_CHANNEL);
// Assign AFTER subscription succeeds — prevents race with stop()
this.redisSub = redis;
logger.info(
{ channel: this.TRANSMIT_CHANNEL },
"Subscribed to transmit channel",
);
// Start activity timer — will auto-stop if no PCM arrives within timeout
this.resetActivityTimer();
this.redisSub.on("message", (channel, message) => {
if (
@@ -175,8 +193,21 @@ export class VoiceTransmitter {
const pcmBuffer = Buffer.from(data.buffer, "base64");
const stream = this.pcmStream;
const canContinue = stream.write(pcmBuffer);
// Backpressure: queue until drain
// Reset activity timer — PCM is flowing
this.resetActivityTimer();
// Backpressure: queue until drain (cap to prevent memory leak)
if (!canContinue) {
if (this.backpressureQueue.length >= VoiceTransmitter.MAX_QUEUE) {
// Drop oldest chunks to free memory — real-time audio,
// stale data is useless
const dropCount = Math.floor(VoiceTransmitter.MAX_QUEUE * 0.25);
this.backpressureQueue.splice(0, dropCount);
logger.debug(
{ dropped: dropCount },
"Backpressure overflow — dropping oldest PCM chunks",
);
}
this.backpressureQueue.push(pcmBuffer);
stream.once("drain", () => {
const currentStream = this.pcmStream;
if (!currentStream || !this.isActive) return;
@@ -210,6 +241,30 @@ export class VoiceTransmitter {
}
}
/**
* Reset the voice activity timer. Called on every PCM chunk received.
* If no chunks arrive for ACTIVITY_TIMEOUT_MS, auto-stop the transmitter
* to prevent dead air and wasted resources.
*/
private resetActivityTimer(): void {
this.clearActivityTimer();
this._activityTimer = setTimeout(() => {
if (!this.isActive) return;
logger.warn(
{ timeoutMs: VoiceTransmitter.ACTIVITY_TIMEOUT_MS },
"Voice activity timeout — no PCM received, auto-stopping transmitter",
);
void this.stop();
}, VoiceTransmitter.ACTIVITY_TIMEOUT_MS);
}
private clearActivityTimer(): void {
if (this._activityTimer !== null) {
clearTimeout(this._activityTimer);
this._activityTimer = null;
}
}
/**
* Stop transmitting and clean up resources
*/
@@ -232,6 +287,7 @@ export class VoiceTransmitter {
this.isActive = false;
this.clearActivityTimer();
this.backpressureQueue = [];
if (this.pcmStream) {
@@ -150,7 +150,7 @@ export const configSchema = z
.string()
.url()
.default("https://9router.asepharyana.my.id/v1"),
AI_LLM_MODEL: z.string().default("claude-opus-5"),
AI_LLM_MODEL: z.string().default("text"),
// Vision uses the SAME router/base URL as text moderation
// (AI_LLM_BASE_URL) but a different model alias. The dedicated NVIDIA
// multimodal endpoint was removed.
@@ -197,6 +197,11 @@ export const configSchema = z
.positive()
.default(1024),
AI_LLM_TEXT_BATCH_SIZE: z.coerce.number().int().positive().default(60),
AI_LLM_MAX_COMPLETION_TOKENS: z.coerce
.number()
.int()
.positive()
.default(16384),
AI_LLM_MEDIA_ANALYSIS_TIMEOUT_MS: z.coerce
.number()
.int()
@@ -56,25 +56,56 @@ async function getFirstMigrationTag(): Promise<string> {
return first.tag;
}
/**
* Read the Drizzle migration journal and return the `when` (folderMillis)
* timestamp of the LAST migration entry. Drizzle's PG migrator applies a
* migration only when `tracked_max_created_at < migration.when`, so seeding
* the tracking table with this value marks every existing journal migration
* as already applied.
*/
async function getLastMigrationWhen(): Promise<number> {
const journalPath = join(
process.cwd(),
"drizzle/migrations/meta/_journal.json",
);
const raw = await readFile(journalPath, "utf-8");
const journal: MigrationJournal = JSON.parse(raw);
if (!journal.entries || journal.entries.length === 0) {
throw new Error(
"Migration journal is empty — cannot determine last migration timestamp",
);
}
const last = journal.entries.reduce((newest, entry) =>
entry.when > newest.when ? entry : newest,
);
return last.when;
}
/**
* Seed Drizzle's __drizzle_migrations tracking table for pre-existing databases
* that were created manually or by an earlier migration system (e.g., the old
* checkSchemaExists short-circuit). Without this, Drizzle attempts to re-create
* all tables from 0000 and fails with "relation already exists".
*
* The Drizzle PG migrator (drizzle-orm 0.45.x) only reads the LAST tracked
* migration (`SELECT ... ORDER BY created_at DESC LIMIT 1` from
* <migrationsSchema>.__drizzle_migrations) and re-applies every journal
* migration whose `when` is newer than that value. If the tracking table is
* empty or missing the latest entries while the DB schema is already up to
* date, re-running the migrations crashes with "column/relation already
* exists" putting the gateway into an infinite restart loop.
*
* So instead of early-returning when the table exists (which left it empty /
* partial and caused this exact crash), we RECONCILE: when the DB is a
* pre-existing one (app tables already present) and the schema already
* reflects the latest migration, we seed the tracking table up to the latest
* journal `when` so Drizzle skips everything instead of re-applying it.
*/
async function seedDrizzleHistory(client: PoolClient): Promise<void> {
const exists = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.tables
WHERE table_name = '__drizzle_migrations'
)
`);
const drizzleTableExists = exists.rows[0]?.exists === true;
if (drizzleTableExists) {
return; // already seeded, nothing to do
}
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual creation).
// Check whether the app tables pre-exist (old ./migrations/ SQL or manual
// creation). If not, this is a brand-new database — let Drizzle handle it.
const hasTextCache = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
@@ -85,12 +116,19 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
return; // brand-new database, let Drizzle handle everything
}
const firstMigrationTag = await getFirstMigrationTag();
// Final schema "at latest migration" sentinel: the moderation_actions
// table at 0019 has a server_nick column. If present, the schema is at (or
// past) migration 0019, so any journal entries not yet tracked are safe to
// mark as applied rather than re-running (which would fail).
const atLatest = await client.query(`
SELECT EXISTS (
SELECT FROM information_schema.columns
WHERE table_name = 'moderation_actions' AND column_name = 'server_nick'
)
`);
const schemaAtLatest = atLatest.rows[0]?.exists === true;
logger.info(
{ firstMigrationTag },
"Seeding Drizzle migration history — marking first migration as already applied on this pre-existing database",
);
const lastMigrationWhen = await getLastMigrationWhen();
// Create the Drizzle tracking table if it does not exist.
// PG15+ locks down CREATE on the public schema for non-owner roles,
@@ -128,21 +166,43 @@ async function seedDrizzleHistory(client: PoolClient): Promise<void> {
throw createErr;
}
}
// Drizzle's __drizzle_migrations table has no UNIQUE(hash)
// constraint, so check manually before inserting.
const alreadySeeded = await client.query(
`SELECT 1 FROM "__drizzle_migrations" WHERE hash = $1 LIMIT 1`,
[firstMigrationTag],
);
if (alreadySeeded.rows.length === 0) {
await client.query(
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
[firstMigrationTag, Date.now()],
);
// Current max tracked created_at. Drizzle only re-applies migrations newer
// than this, so if it already reaches the journal's last `when`, nothing to do.
const tracked = await client.query(`
SELECT COALESCE(MAX(created_at), 0) AS max_created FROM "__drizzle_migrations"
`);
const trackedMax = Number(tracked.rows[0]?.max_created ?? 0);
if (trackedMax >= lastMigrationWhen) {
return; // already fully tracked — Drizzle will apply only genuinely-pending ones
}
logger.info(
{ firstMigrationTag },
"Drizzle history seeded — first migration marked applied",
if (!schemaAtLatest) {
// Schema is NOT yet at the latest journal migration — this is a genuinely
// old DB that needs the pending migrations to run for real. Let Drizzle
// apply them. (Idempotent migration SQL guards legacy partial states.)
logger.info(
{ trackedMax, lastMigrationWhen },
"DB schema behind latest journal migration — leaving pending migrations to run",
);
return;
}
// Schema already reflects the latest migration but the tracking table is
// behind (e.g. migrations applied manually/out-of-band without being
// recorded). Seed up to the last journal `when` so Drizzle skips them
// instead of re-running already-applied DDL and crashing. This is the
// recovery that broke the gateway's infinite-restart loop.
const journalTag =
(await getFirstMigrationTag()) + `@${lastMigrationWhen}-reconciled`;
await client.query(
`INSERT INTO "__drizzle_migrations" (hash, created_at) VALUES ($1, $2)`,
[journalTag, lastMigrationWhen],
);
logger.warn(
{ lastMigrationWhen },
"Reconciled Drizzle migration history — marked schema-at-latest migrations as applied (preventing re-run of already-applied DDL)",
);
}
@@ -642,6 +642,8 @@ export const pgModerationActionsTable = pgTable(
score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string
username: pgText("username"), // denormalized from messages table for retention safety
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
},
(table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -59,6 +59,8 @@ export const pgModerationActionsTable = pgTable(
score: pgReal("score"), // 0..1 raw model score
evidence: pgText("evidence"), // JSON array of short quoted snippets
policy_version: pgText("policy_version"), // rules.ts policy version string
username: pgText("username"), // denormalized from messages table for retention safety
server_nick: pgText("server_nick"), // member's server-specific display name (nickname), from metadata.member.displayName
},
(table) => ({
messageIdIdx: pgIndex("idx_moderation_actions_message_id").on(
@@ -208,6 +208,8 @@ export interface ModerationAction {
guild_id: string;
action_type: ModerationActionType;
reason: string | null;
username: string | null;
server_nick: string | null;
executed_by: string | null;
status: "pending" | "executed" | "failed";
error: string | null;
@@ -0,0 +1,81 @@
# GMW FE Rombak — Extend Tactical-HUD/GSAP Treatment Wide (2026-08-26)
## Context / lessons that constrain this plan
- 2026-08-24: full novel navigation metaphor (constellation/three.js) was
SHIPPED green on every gate then REJECTED by user for hurting usability.
Rolled back entirely. Lesson: do NOT invent a new nav metaphor.
- 2026-08-24: accepted direction = monochrome theme + game-menu style sidebar
animation ON TOP of the familiar shell (rail/topbar/panel kept).
- 2026-08-25: `dashboard` and `media` routes were already revamped into a
"Tactical HUD" style with GSAP (`@gsap/react` useGSAP) reactive stages —
commits 183a510, cab451f. This is the established, ACCEPTED visual language.
- Remaining routes (`channels`, `voice`, `messages`, `moderation`,
`recordings`, `analysis`, `glossary`) have NOT received the tactical-HUD/GSAP
treatment — last touch was an error-handling/state pass (31e303c) or the
constellation revert (eda5c75).
## Scope of THIS request
User: "rombak fe dengan sekeren mungkin tapi tidak menyusahkan user, pewarnaan
sesuai tidak berlebihan, pakai gsap, sekreatif mungkin."
Read against the history above, this means: EXTEND the already-accepted
tactical-HUD + GSAP + monochrome visual language to the remaining routes,
adding creative micro-interactions and view-specific "reactive stage" touches
— NOT a new structural/navigation metaphor, NOT a color explosion.
## Hard constraints (do not violate)
1. Keep the existing shell: top bar / nav rail / mobile bottom dock. No new
navigation metaphor.
2. Monochrome/greyscale token system stays as-is (`--color-signal/amber/
vermilion` luminance greys). Do not introduce saturated colors; accent use
stays restrained (used sparingly for state: active/alert/success).
3. GSAP via `@gsap/react` `useGSAP({ scope, dependencies })` + `clearProps` on
cleanup, per `dashboard-rombak/references/tactical-hud-gsap-pattern.md`.
Respect `prefers-reduced-motion` (extend the kill-list in globals.css for
any new `animate-*` class).
4. Mobile lightness gate: any WebGL/heavy canvas work must gate off on
<768px / pointer:coarse / saveData / deviceMemory<=4 (see AmbientCanvas
pattern already in the codebase).
5. Each route ships independently: edit → `pnpm format` → `npx @biomejs/biome
check --write` → `pnpm lint` (0 errors, 0 warnings) → `tsc --noEmit` →
`pnpm build` → commit (no Co-Authored-By trailer) → push → `gh run watch`
for Build & Deploy (Nix) → live verify via browser_vision on
imphnen.asepharyana.my.id/<route>/.
6. NEVER claim "done" from reading code — every route gets a live screenshot
check confirming: (a) not a stock template, (b) monochrome/restrained,
(c) GSAP entrance/interaction actually fires, (d) nav/theme toggle/command
palette still work.
## Per-route creative direction (reactive-stage motifs, one distinct idea per
route so it doesn't feel copy-pasted from dashboard/media)
- **channels**: node/roster telemetry — GSAP stagger-in channel cards with a
"signal strength" bar-fill tween keyed to activity; hover reveals HUD-style
meta readout.
- **voice**: live speaker HUD — waveform/ring pulse tied to WS voice_state
(GSAP timeline looping while speaking, `clearProps` on stop); ambient tile
glow keyed to active speaker count.
- **messages**: scan-line reveal on list mount + WS-driven "new message" slide
insert (respect existing sortMessages fix — do not reintroduce the race).
- **moderation**: alert-priority HUD — flagged severity as inverted mono
badge with a pulse GSAP loop (no red, per accepted mono direction);
queue-clear micro celebration (subtle, no confetti-color).
- **recordings**: waveform scrub deck echoing media's frequency-deck visual
language but for static recordings (progress-tied GSAP scrub, not looping).
- **analysis**: gauge/metric reveal using the existing `radial-gauge.tsx`
driven by a GSAP number tween (count-up) instead of an instant value snap.
- **glossary**: lightweight — accordion/definition reveal with GSAP height
auto + fade, kept minimal since it's low-traffic reference content.
## Verification checklist (must pass before calling any route "shipped")
- [ ] tsc --noEmit clean
- [ ] biome check: 0 errors AND 0 warnings
- [ ] next build succeeds
- [ ] CI green (Build & Deploy (Nix))
- [ ] Live screenshot: monochrome, GSAP motion visible, nav/theme/command
palette intact, not a stock template
- [ ] Reduced-motion: new animate-* classes added to the kill-list
- [ ] Mobile: WebGL/heavy work gated per existing pattern
## Rollback plan
Every route ships as its own atomic commit. If user rejects a specific route's
direction, `git revert <that commit>` — do not touch unrelated routes.
+12 -14
View File
@@ -12,27 +12,25 @@
"dependencies": {
"@orpc/client": "1.15.0",
"clsx": "^2.1.1",
"lucide-react": "^1.27.0",
"motion": "^12.0.0",
"next": "16.2.12",
"lucide-react": "^1.33.0",
"next": "16.3.2",
"next-themes": "^0.4.6",
"partysocket": "1.3.0",
"react": "19.2.4",
"react-dom": "19.2.4",
"swr": "^2.4.2",
"tailwind-merge": "^3.6.0",
"three": "^0.180.0"
"react": "19.2.8",
"react-dom": "19.2.8",
"swr": "^2.5.1",
"tailwind-merge": "^3.6.0"
},
"devDependencies": {
"@biomejs/biome": "2.2.0",
"@biomejs/biome": "2.5.10",
"@tailwindcss/postcss": "^4",
"@types/node": "^20",
"@types/node": "^26.2.0",
"@types/react": "^19",
"@types/react-dom": "^19",
"@types/three": "^0.180.0",
"@types/three": "^0.185.4",
"babel-plugin-react-compiler": "1.0.0",
"puppeteer-core": "^25.7.0",
"puppeteer-core": "^25.8.0",
"tailwindcss": "^4",
"typescript": "^5"
"typescript": "^7"
}
}
}
+537 -376
View File
File diff suppressed because it is too large Load Diff
@@ -1,12 +1,7 @@
import { PageTransition } from "@/components/shared";
import { AnalysisView } from "./view";
export const dynamic = "force-dynamic";
export default function AnalysisPage() {
return (
<PageTransition>
<AnalysisView />
</PageTransition>
);
return <AnalysisView />;
}
@@ -1,11 +1,12 @@
"use client";
import { Hash, Search, Sparkles, TrendingUp } from "lucide-react";
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Avatar, Badge, GlassPanel, Input } from "@/components/primitives";
import { EmptyState, SectionHeader, SkeletonRows } from "@/components/shared";
import { useChannels, useMessageSearch, useTopReactors } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { aiTone } from "@/lib/ai-status";
import {
formatDuration,
@@ -20,6 +21,15 @@ export function AnalysisView() {
const { data: channels } = useChannels();
const ambient = useAmbient();
const reactorsRef = useRef<HTMLDivElement>(null);
const channelsRef = useRef<HTMLDivElement>(null);
const resultsRef = useStaggerReveal<HTMLDivElement>(".search-result-card", {
stagger: 0.03,
y: 8,
dependencies: [search.data?.length, query],
});
useEffect(() => {
ambient.set(
query ? "amber" : "signal",
@@ -28,74 +38,184 @@ export function AnalysisView() {
);
}, [query, ambient]);
// Bar-fill reveal for reactor leaderboard — CSS animation
useEffect(() => {
const container = reactorsRef.current;
if (!container) return;
const bars = container.querySelectorAll<HTMLElement>(".reactor-bar-fill");
const counters = container.querySelectorAll<HTMLElement>(".reactor-count");
if (bars.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) {
bars.forEach((el) => (el.style.transform = "scaleX(1)"));
counters.forEach((el) => {
el.textContent = `+${el.getAttribute("data-target") ?? "0"}`;
});
return;
}
bars.forEach((el, i) => {
el.style.transformOrigin = "left center";
el.style.animationFillMode = "forwards";
el.style.animationTimingFunction = "ease-out";
el.style.animationName = "bar-fill-in";
el.style.animationDuration = "0.7s";
el.style.animationDelay = `${i * 0.06}s`;
});
// Simple counter animation using rAF
counters.forEach((el) => {
const target = Number(el.getAttribute("data-target") ?? "0");
const start = performance.now();
const duration = 800;
const step = (now: number) => {
const progress = Math.min((now - start) / duration, 1);
const eased = 1 - (1 - progress) ** 3;
el.textContent = `+${Math.round(target * eased)}`;
if (progress < 1) requestAnimationFrame(step);
};
requestAnimationFrame(step);
});
return () => {
bars.forEach((el) => {
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-delay");
el.style.removeProperty("animation-fill-mode");
el.style.removeProperty("animation-timing-function");
el.style.removeProperty("transform-origin");
});
};
}, []);
// Channel rows stagger slide-in — CSS animation
useEffect(() => {
const container = channelsRef.current;
if (!container) return;
const rows = container.querySelectorAll<HTMLElement>(".channel-row");
if (rows.length === 0) return;
if (window.matchMedia("(prefers-reduced-motion: reduce)").matches) return;
rows.forEach((el, i) => {
el.style.opacity = "0";
el.style.animationFillMode = "forwards";
el.style.animationTimingFunction = "ease-out";
el.style.animationName = "stagger-slide-in";
el.style.animationDuration = "0.35s";
el.style.animationDelay = `${i * 0.04}s`;
});
return () => {
rows.forEach((el) => {
el.style.removeProperty("opacity");
el.style.removeProperty("animation-name");
el.style.removeProperty("animation-duration");
el.style.removeProperty("animation-delay");
el.style.removeProperty("animation-fill-mode");
el.style.removeProperty("animation-timing-function");
});
};
}, []);
return (
<div className="space-y-5">
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Deep Scan · Semantic Search & Telemetry Analysis
</h1>
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>ENGINE:</span>
<span
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
data-text={query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
>
{query.trim().length >= 2 ? "SCANNING" : "STANDBY"}
</span>
</div>
</div>
{/* Hero Query Stage */}
<GlassPanel className="p-5">
<div className="flex items-center gap-3">
<Sparkles className="size-5 text-signal" />
<span className="flex size-8 items-center justify-center rounded-[6px] border border-signal/30 bg-signal/10 text-signal">
<Sparkles className="size-4" />
</span>
<div>
<div className="eyebrow">Semantic search</div>
<h2 className="display text-balance text-2xl text-ink">
Search the archive
<h2 className="text-base font-semibold text-ink">
Cross-Guild Archive Intelligence
</h2>
<p className="font-mono text-[11px] text-ink-muted">
Query vectorized messages, heuristic flags, and user behavior
</p>
</div>
</div>
<div className="relative mt-4">
<Search className="absolute left-4 top-1/2 size-5 -translate-y-1/2 text-ink-faint" />
<Search className="absolute left-3.5 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input
className="h-12 pl-12 text-base"
placeholder="Find messages, patterns, flags…"
className="h-10 pl-10 text-xs text-ink"
placeholder="Search keywords, behavioral patterns, infraction terms..."
value={query}
onChange={(e) => setQuery(e.target.value)}
autoFocus
/>
</div>
{query.trim().length > 0 && query.trim().length < 2 && (
<div className="mono mt-2 text-xs text-ink-faint">
Type at least 2 characters
</div>
)}
</GlassPanel>
<div className="grid gap-5 lg:grid-cols-5">
<div className="grid gap-3 lg:grid-cols-5">
{/* Search Results Column */}
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow="results"
title="Matches"
eyebrow="search output"
title="Matched Log Packets"
action={
<span className="mono text-xs text-ink-faint">
{(search.data ?? []).length}
<span className="mono text-xs text-[#8a8f98]">
{(search.data ?? []).length} results
</span>
}
/>
{query.trim().length >= 2 && search.isLoading && (
<SkeletonRows rows={6} />
)}
{(search.data ?? []).length === 0 ? (
{(search.data ?? []).length === 0 && !search.isLoading ? (
<EmptyState
icon={<Search className="size-7" />}
title="No matches yet"
description="Run a search to surface messages across the guild."
title="No packets matched"
description="Enter a query string above to scan across all captured guild communication."
/>
) : (
<div className="space-y-1.5">
<div ref={resultsRef} className="space-y-1.5 mt-3">
{(search.data ?? []).map((m) => (
<div
key={m.id}
className="flex items-start gap-3 rounded-[12px] border border-hairline bg-white/[0.03] p-3"
className="search-result-card hud-card flex items-start gap-3 p-3 transition-all"
>
<Avatar src={m.avatar_url} name={m.username} size={32} />
<Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={32}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="text-sm font-semibold text-ink">
{m.username}
<span className="text-xs font-semibold text-ink">
{m.server_nick ?? m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{m.server_nick && m.server_nick !== m.username && (
<span className="font-mono text-[10px] text-ink-muted">
@{m.username}
</span>
)}
<span className="font-mono text-[10px] text-ink-faint">
{getMessageChannelLabel(m)}
</span>
{m.ai_status && (
<Badge tone={aiTone(m.ai_status)} className="ml-auto">
<Badge
tone={aiTone(m.ai_status)}
className="ml-auto font-mono text-[9px]"
>
{m.ai_analysis_duration_ms &&
m.ai_analysis_duration_ms > 0
? `${m.ai_status} · ${formatDuration(m.ai_analysis_duration_ms)}`
@@ -103,7 +223,7 @@ export function AnalysisView() {
</Badge>
)}
</div>
<div className="mt-0.5 text-sm text-ink-soft">
<div className="mt-1 text-xs text-ink-soft leading-relaxed">
{renderMessageContent(m.content, m.metadata) || "(embed)"}
</div>
</div>
@@ -113,17 +233,18 @@ export function AnalysisView() {
)}
</GlassPanel>
<div className="space-y-5 lg:col-span-2">
{/* Culture & Leaderboard Side Column */}
<div className="space-y-3 lg:col-span-2">
<GlassPanel>
<SectionHeader
eyebrow="culture"
eyebrow="engagement"
title={
<span className="flex items-center gap-2">
<TrendingUp className="size-4 text-signal" /> Top reactors
<span className="flex items-center gap-1.5">
<TrendingUp className="size-3.5 text-signal" /> Top Reactors
</span>
}
/>
<div className="space-y-2">
<div ref={reactorsRef} className="space-y-2 mt-3">
{(reactors ?? []).slice(0, 6).map((r, i) => {
const maxNet = reactors?.[0]?.net_count || 1;
const pct = Math.max(
@@ -133,27 +254,32 @@ export function AnalysisView() {
return (
<div
key={r.user_id}
className="flex items-center gap-3 text-sm"
className="flex items-center gap-3 text-xs"
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
<span className="w-28 shrink-0 truncate text-ink-soft sm:w-40">
<span className="font-mono w-4 text-[10px] text-ink-faint">
0{i + 1}
</span>
<span className="w-24 shrink-0 truncate font-mono text-xs text-ink sm:w-32">
{r.username}
</span>
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-white/8">
<div className="h-1.5 flex-1 overflow-hidden rounded-full bg-surface-2">
<div
className="h-full rounded-full bg-signal/70"
className="reactor-bar-fill h-full rounded-full bg-signal"
style={{ width: `${pct}%` }}
/>
</div>
<span className="mono w-12 text-right text-xs text-signal">
+{r.net_count}
<span
className="reactor-count font-mono w-10 text-right text-[11px] font-semibold text-signal"
data-target={r.net_count}
>
+0
</span>
</div>
);
})}
{(reactors ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint">
No data
<div className="py-4 text-center font-mono text-xs text-ink-faint">
NO REACTOR DATA
</div>
)}
</div>
@@ -161,30 +287,31 @@ export function AnalysisView() {
<GlassPanel>
<SectionHeader
eyebrow="channels"
eyebrow="volume"
title={
<span className="flex items-center gap-2">
<Hash className="size-4 text-signal" /> Top channels
<span className="flex items-center gap-1.5">
<Hash className="size-3.5 text-signal" /> Top Active Channels
</span>
}
/>
<div className="space-y-2">
<div ref={channelsRef} className="space-y-2 mt-3">
{(channels ?? []).slice(0, 6).map((c) => (
<div
key={c.channel_id}
className="flex items-center gap-3 text-sm"
className="channel-row hud-card flex items-center justify-between p-2 text-xs"
>
<span className="flex-1 truncate text-ink-soft">
{c.channel_name ?? c.channel_id.slice(0, 8)}
<span className="flex items-center gap-1.5 truncate font-mono text-xs text-ink">
<Hash className="size-3 text-signal" />
{c.channel_name ?? c.channel_id.slice(0, 10)}
</span>
<span className="mono text-xs text-ink-faint">
{c.total_messages}
<span className="font-mono text-[10px] text-ink-muted">
{c.total_messages.toLocaleString()} msgs
</span>
</div>
))}
{(channels ?? []).length === 0 && (
<div className="py-4 text-center text-xs text-ink-faint">
No data
<div className="py-4 text-center font-mono text-xs text-ink-faint">
NO CHANNEL DATA
</div>
)}
</div>
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getChannelCultures } from "@/lib/api/server";
import type { ChannelCultureRow } from "@/lib/types";
import { ChannelsView } from "./view";
@@ -12,9 +11,5 @@ export default async function ChannelsPage() {
} catch {
cultures = undefined;
}
return (
<PageTransition>
<ChannelsView initialCultures={cultures} />
</PageTransition>
);
return <ChannelsView initialCultures={cultures} />;
}
@@ -12,7 +12,23 @@ export function ChannelsView({
}) {
const { data: cultures } = useChannelCultures(100, initialCultures);
return (
<div className="space-y-5">
<div className="space-y-4">
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-3">
<div className="relative flex size-3 items-center justify-center">
<span className="absolute inline-flex size-full animate-ping rounded-full bg-signal opacity-75" />
<span className="relative inline-flex size-2 rounded-full bg-signal" />
</div>
<h1 className="font-mono text-xs font-semibold tracking-widest text-ink uppercase">
CHANNEL ROSTER · SIGNAL_MAP
</h1>
</div>
<div className="inline-flex items-center gap-1.5 rounded-md bg-surface px-2 py-0.5 font-mono text-[11px] text-ink-soft">
<span className="text-ink-faint">NODES:</span>
<span className="font-bold text-signal">{cultures?.length ?? 0}</span>
</div>
</div>
{cultures ? (
<ChannelCultureGlossary cultures={cultures} />
) : (
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getActivity, getDashboardStats } from "@/lib/api/server";
import { DashboardView } from "./view";
@@ -15,9 +14,5 @@ export default async function DashboardPage() {
} catch {
// Backend unavailable — client hooks will surface the error state.
}
return (
<PageTransition>
<DashboardView initialStats={stats} initialActivity={activity} />
</PageTransition>
);
return <DashboardView initialStats={stats} initialActivity={activity} />;
}
@@ -1,46 +1,48 @@
"use client";
import {
Activity,
Flag,
AudioWaveform,
ChevronRight,
MessageSquare,
Mic,
Radio,
ShieldAlert,
Shield,
Users,
} from "lucide-react";
import Link from "next/link";
import { useEffect } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { AreaActivity, RadialGauge } from "@/components/charts";
import { GlassPanel } from "@/components/primitives";
import {
ErrorState,
LoadingState,
MetricTile,
PageTransition,
SectionHeader,
SkeletonHero,
SkeletonMetricRow,
SkeletonPanel,
} from "@/components/shared";
import { MetricTile, SectionHeader } from "@/components/shared/section";
import {
useActivity,
useStats,
useTopReactions,
useTopReactors,
} from "@/hooks";
import { useActivity, useStats, useTopReactions } from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatNumber } from "@/lib/format";
import type { DashboardStats } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
function deriveSignal(stats?: DashboardStats) {
if (!stats) return { tone: "signal" as const, label: "nominal" };
if (!stats)
return { tone: "signal" as const, label: "NOMINAL", state: "STABLE" };
const total = stats.total_flagged + stats.total_clean || 1;
const ratio = stats.total_flagged / total;
if (stats.moderation_overview.error > 0)
return { tone: "vermilion" as const, label: "moderation fault" };
return { tone: "vermilion" as const, label: "FAULT", state: "MOD_ERR" };
if (ratio > 0.25)
return { tone: "vermilion" as const, label: "elevated flags" };
if (ratio > 0.1) return { tone: "amber" as const, label: "watch" };
return { tone: "signal" as const, label: "nominal" };
return {
tone: "vermilion" as const,
label: "ELEVATED",
state: "HIGH_RISK",
};
if (ratio > 0.1)
return { tone: "amber" as const, label: "WATCH", state: "ADVISORY" };
return { tone: "signal" as const, label: "NOMINAL", state: "OPTIMAL" };
}
export function DashboardView({
@@ -56,11 +58,16 @@ export function DashboardView({
error,
mutate: mutateStats,
} = useStats(initialStats);
const { data: activity } = useActivity(14, initialActivity as never);
const { data: reactors } = useTopReactors();
const { data: activity } = useActivity(14, initialActivity);
const { data: reactions } = useTopReactions();
const ambient = useAmbient();
const hudRef = useStaggerReveal<HTMLDivElement>(".linear-tile", {
stagger: 0.035,
y: 8,
dependencies: [stats],
});
useEffect(() => {
const s = deriveSignal(stats);
ambient.set(
@@ -74,20 +81,16 @@ export function DashboardView({
return <ErrorState error={error} onRetry={() => void mutateStats()} />;
if (!stats && isLoading)
return (
<div className="space-y-5">
<div className="space-y-4">
<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>
<SkeletonPanel rows={4} />
</div>
);
if (!stats)
return (
<ErrorState
error={error ?? new Error("No data")}
error={error ?? new Error("No telemetry stream")}
onRetry={() => void mutateStats()}
/>
);
@@ -97,284 +100,152 @@ export function DashboardView({
const cleanRatio = s.total_clean / total;
return (
<div className="space-y-5">
{/* Hero */}
<GlassPanel glow className="game-frame relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-wrap items-end justify-between gap-4">
<div>
<div className="eyebrow mb-2">GMW · Operations Grid</div>
<h2 className="display hero-clamp leading-none text-ink glow-signal">
Ambient Field
</h2>
<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>
<PageTransition>
<div ref={hudRef} className="space-y-4">
{/* Tactical HUD Header Bar */}
<div className="linear-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Telemetry Overview · Node 01
</h1>
</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}
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>SIGNAL:</span>
<span className="rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30">
STABLE_STREAM
</span>
</div>
</div>
<div className="mt-5 grid grid-cols-2 gap-3 md:grid-cols-4">
{/* Primary Metric Deck */}
<div className="linear-tile grid gap-3 sm:grid-cols-2 lg:grid-cols-4">
<MetricTile
label="Messages"
label="Total Messages"
value={formatNumber(s.total_messages)}
hint={`${formatNumber(s.today_messages)} today`}
icon={<MessageSquare className="size-4" />}
/>
<MetricTile
label="Clean Filter Ratio"
value={`${Math.round(cleanRatio * 100)}%`}
hint={`${formatNumber(s.total_clean)} verified clean`}
tone="signal"
icon={<MessageSquare className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(0)}
icon={<Shield className="size-4" />}
/>
<MetricTile
label="Flagged"
value={formatNumber(s.total_flagged)}
tone={s.total_flagged > 0 ? "vermilion" : "neutral"}
hint={`${s.today_flagged} today`}
className="animate-stagger"
style={staggerDelay(1)}
/>
<MetricTile
label="Active 24h"
value={formatNumber(s.active_users_24h)}
tone="signal"
icon={<Users className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(2)}
/>
<MetricTile
label="Voice clips"
label="Voice Recordings"
value={formatNumber(s.total_voice_recordings)}
icon={<Mic className="size-3.5" />}
className="animate-stagger"
style={staggerDelay(3)}
hint="Archived audio sessions"
icon={<Mic className="size-4" />}
/>
<MetricTile
label="Total Profiles"
value={formatNumber(s.total_profiles)}
hint={`${formatNumber(s.active_users_24h)} active 24h`}
icon={<Users className="size-4" />}
/>
</div>
</GlassPanel>
{/* Activity */}
<GlassPanel>
<SectionHeader
eyebrow="14-day signal"
title={
<span className="flex items-center gap-2">
<Activity className="size-4 text-signal" /> Activity & moderation
</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>
{/* Dynamic Activity Area & Telemetry Gauges */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="linear-tile lg:col-span-2">
<SectionHeader
eyebrow="Activity Stream"
title="Message Volume & Signal Density"
/>
<div className="mt-4">
<AreaActivity daily={activity?.daily ?? []} />
</div>
}
/>
{activity ? (
<AreaActivity daily={activity.daily} />
) : (
<LoadingState label="streaming" />
)}
</GlassPanel>
</GlassPanel>
{/* Two-column: channels + moderation */}
<div className="grid gap-5 lg:grid-cols-5">
<GlassPanel className="lg:col-span-3">
<SectionHeader eyebrow="throughput" title="Top channels" />
<div className="space-y-2.5">
{s.top_channels.slice(0, 7).map((c) => {
const pct =
(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
value={cleanRatio}
tone={
cleanRatio > 0.8
? "signal"
: cleanRatio > 0.6
? "amber"
: "vermilion"
}
label={`${Math.round(cleanRatio * 100)}%`}
sublabel="clean"
/>
<div className="flex-1 space-y-2 text-sm">
<Row
icon={<ShieldAlert className="size-4 text-signal" />}
label="Clean"
value={formatNumber(s.total_clean)}
<GlassPanel className="linear-tile flex flex-col justify-between">
<div>
<SectionHeader
eyebrow="Security State"
title="Moderation Accuracy"
/>
<Row
icon={<Flag className="size-4 text-vermilion" />}
label="Flagged"
value={formatNumber(s.total_flagged)}
/>
<Row
icon={<Activity className="size-4 text-amber" />}
label="Warned"
value={formatNumber(s.total_warned)}
/>
</div>
</div>
<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 className="my-6 flex items-center justify-center">
<RadialGauge
value={Math.round(cleanRatio * 100)}
label="Clean Ratio"
tone={cleanRatio > 0.85 ? "signal" : "amber"}
size={140}
/>
</div>
))}
{(reactions ?? []).length === 0 && <EmptyHint />}
</div>
</GlassPanel>
</div>
<div className="grid grid-cols-2 gap-2 border-t border-hairline pt-3 text-center">
<div>
<div className="eyebrow">CLEAN</div>
<div className="font-sans text-sm font-semibold text-success">
{formatNumber(s.total_clean)}
</div>
</div>
<div>
<div className="eyebrow">FLAGGED</div>
<div className="font-sans text-sm font-semibold text-vermilion">
{formatNumber(s.total_flagged)}
</div>
</div>
</div>
</GlassPanel>
</div>
{/* Secondary Insights Deck */}
<div className="grid gap-3 lg:grid-cols-2">
{reactions && reactions.length > 0 && (
<GlassPanel className="linear-tile">
<SectionHeader
eyebrow="Engagement"
title="Top Reacted Messages"
/>
<div className="mt-3 space-y-2">
{reactions.slice(0, 4).map((r) => (
<div
key={r.message_id}
className="hud-card flex items-center justify-between p-2.5"
>
<span className="truncate pr-2 text-xs text-ink-soft">
{r.content || "[Media/Attachment]"}
</span>
<span className="font-mono text-xs font-semibold text-signal shrink-0">
{formatNumber(r.reaction_count)} reactions
</span>
</div>
))}
</div>
</GlassPanel>
)}
<GlassPanel className="linear-tile flex flex-col justify-between">
<div>
<SectionHeader eyebrow="Navigation" title="Quick Diagnostics" />
<div className="mt-2 space-y-1.5">
<Link
href="/moderation"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<Shield className="size-3.5 text-signal" />
Review Live Moderation Queue
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
<Link
href="/voice"
className="hud-card flex items-center justify-between p-2.5 text-xs text-ink-soft transition-colors hover:text-ink"
>
<span className="flex items-center gap-2">
<AudioWaveform className="size-3.5 text-success" />
Inspect Active Voice Stages
</span>
<ChevronRight className="size-3.5 text-ink-muted" />
</Link>
</div>
</div>
</GlassPanel>
</div>
</div>
</div>
);
}
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,
value,
tone,
}: {
label: string;
value: number;
tone: "signal" | "amber" | "vermilion";
}) {
const color =
tone === "vermilion"
? "text-vermilion"
: tone === "amber"
? "text-amber"
: "text-signal";
return (
<div>
<div className={`display text-xl ${color}`}>{value}</div>
<div className="eyebrow mt-0.5">{label}</div>
</div>
);
}
function EmptyHint() {
return (
<div className="py-6 text-center text-xs text-ink-faint">
Awaiting data
</div>
</PageTransition>
);
}
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getGlossary } from "@/lib/api/server";
import type { GlossaryRow } from "@/lib/types";
import { GlossaryView } from "./view";
@@ -12,9 +11,5 @@ export default async function GlossaryPage() {
} catch {
terms = undefined;
}
return (
<PageTransition>
<GlossaryView initialTerms={terms} />
</PageTransition>
);
return <GlossaryView initialTerms={terms} />;
}
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getMediaStatus } from "@/lib/api/server";
import { MediaView } from "./view";
@@ -11,9 +10,5 @@ export default async function MediaPage() {
} catch {
/* client hooks surface errors */
}
return (
<PageTransition>
<MediaView initialStatus={status} />
</PageTransition>
);
return <MediaView initialStatus={status} />;
}
@@ -1,19 +1,13 @@
"use client";
import {
ListMusic,
Play,
Radio,
Repeat,
SkipForward,
Square,
Volume2,
} from "lucide-react";
import { Disc, Play, Repeat, SkipForward, Square } from "lucide-react";
import { useEffect, useState } from "react";
import { useAmbient } from "@/components/ambient/ambient-context";
import { Equalizer } from "@/components/charts";
import { Button, GlassPanel, Input, toast } from "@/components/primitives";
import {
ErrorState,
PageTransition,
SectionHeader,
SkeletonHero,
SkeletonPanel,
@@ -26,9 +20,9 @@ import {
useMediaStop,
useMediaWsSync,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { formatDuration } from "@/lib/format";
import type { MediaState } from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
@@ -51,30 +45,32 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
const playing = media?.playing ?? false;
const current = media?.current ?? null;
const queueList = media?.queue ?? [];
const queueTotal = queueList.reduce(
(acc, it) => acc + (it.durationMs ?? 0),
0,
);
const mediaRef = useStaggerReveal<HTMLDivElement>(".media-tile", {
stagger: 0.04,
y: 8,
dependencies: [playing, queueList.length],
});
const tone = playing ? "signal" : queueList.length ? "amber" : "signal";
useEffect(() => {
ambient.set(
tone,
playing ? 0.5 : 0.25,
playing ? "now playing" : "media idle",
playing ? 0.45 : 0.2,
playing ? "playing stream" : "media ready",
);
}, [tone, playing, ambient]);
const onPlay = async () => {
const u = url.trim();
if (!u) {
toast({ title: "Enter a media URL", tone: "vermilion" });
toast({ title: "Enter media URL", tone: "vermilion" });
return;
}
try {
await queue.mutateAsync({ url: u, mode: "music" });
setUrl("");
toast({ title: "Queued", tone: "signal" });
toast({ title: "Track queued", tone: "signal" });
} catch (e) {
toast({
title: "Queue failed",
@@ -88,212 +84,153 @@ export function MediaView({ initialStatus }: { initialStatus?: MediaState }) {
return <ErrorState error={error} onRetry={() => void mutate()} />;
if (!media && isLoading)
return (
<div className="space-y-5">
<div className="space-y-4">
<SkeletonHero />
<SkeletonPanel rows={4} />
<SkeletonPanel rows={3} />
</div>
);
const mockLevels = playing
? [0.4, 0.7, 0.9, 0.6, 0.8, 0.5, 0.9, 0.7, 0.4, 0.8, 0.6, 0.9]
: [0.08, 0.08, 0.08, 0.08, 0.08, 0.08];
return (
<div className="space-y-5">
<GlassPanel glow className="relative overflow-hidden">
<div className="scan-line absolute inset-x-0 top-0" />
<div className="flex flex-col gap-5 sm:flex-row sm:items-center">
<div
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 ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={current.thumbnailUrl}
alt=""
className="size-full object-cover"
loading="lazy"
/>
) : (
<ListMusic className="size-10 text-signal" />
)}
</div>
</div>
<div className="min-w-0 flex-1">
<div className="eyebrow mb-1 flex items-center gap-2">
{playing ? (
<>
<span aria-hidden className="flex h-3 items-end gap-[2px]">
{[0, 1, 2].map((i) => (
<span
key={`eq-${i}`}
className="w-[3px] animate-eq rounded-full bg-signal"
style={{
animationDelay: `${i * 160}ms`,
height: "100%",
}}
/>
))}
</span>
<span className="text-signal">Now playing</span>
</>
) : current ? (
"Paused"
) : (
"Nothing queued"
)}
</div>
<h2 className="display text-balance text-2xl text-ink">
{current?.title ?? "Nothing queued"}
</h2>
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-ink-faint">
{current?.source && (
<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 className="mt-4 flex flex-wrap items-center gap-2">
<Button
variant="primary"
size="sm"
onClick={onPlay}
disabled={queue.isPending}
>
<Play className="size-4" /> Queue & play
</Button>
<Button
variant="outline"
size="sm"
onClick={() => skip.mutate()}
disabled={skip.isPending}
>
<SkipForward className="size-4" /> Skip
</Button>
<Button
variant="outline"
size="sm"
onClick={() => stop.mutate()}
disabled={stop.isPending}
>
<Square className="size-4" /> Stop
</Button>
<Button
variant={media?.loop ? "primary" : "outline"}
size="sm"
onClick={() => loop.mutate(!media?.loop)}
aria-pressed={!!media?.loop}
>
<Repeat className="size-4" /> Loop
</Button>
</div>
</div>
</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()}
<PageTransition>
<div ref={mediaRef} className="space-y-4">
{/* Precision Sub-Header Bar */}
<div className="media-tile flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span
className={`h-2 w-2 rounded-full ${
playing
? "bg-signal shadow-[0_0_8px_var(--color-signal-glow)]"
: "bg-ink-muted"
}`}
/>
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Audio Engine · Media Gateway
</h1>
</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
className="h-full rounded-full bg-signal/70"
style={{
width: `${Math.round((media?.musicVolume ?? 0) * 100)}%`,
}}
/>
</div>
<span className="mono w-9 text-right text-[0.65rem] text-ink-faint">
{Math.round((media?.musicVolume ?? 0) * 100)}%
<div className="flex items-center gap-2 font-mono text-[11px]">
<span className="text-ink-muted">ENGINE:</span>
<span
className={`rounded px-1.5 py-0.5 font-medium border ${
playing
? "border-signal/30 bg-signal/10 text-signal"
: "border-hairline bg-surface-2 text-ink-muted"
}`}
>
{playing ? "STREAMING" : "IDLE"}
</span>
</div>
</div>
</GlassPanel>
<GlassPanel>
<SectionHeader
eyebrow="up next"
title="Queue"
action={
<span className="mono text-xs text-ink-faint">
{queueList.length} track{queueList.length === 1 ? "" : "s"}
{queueTotal && ` · ${formatDuration(queueTotal)}`}
</span>
}
/>
{queueList.length === 0 ? (
<div className="flex flex-col items-center gap-2 py-10 text-center">
<Radio className="size-6 text-ink-faint" />
<div className="text-sm text-ink-soft">Queue is empty</div>
<div className="text-xs text-ink-faint">
Paste a URL above to start playback.
</div>
</div>
) : (
<div className="space-y-2">
{queueList.map((item, i) => {
const isNext = i === 0 && playing;
return (
<div
key={`${item.source}-${i}`}
className={`animate-stagger flex items-center gap-3 rounded-[10px] border px-3 py-2.5 ${
isNext
? "border-signal/40 bg-signal/[0.07]"
: "border-hairline bg-white/5"
}`}
style={staggerDelay(i)}
>
<span className="mono w-5 text-ink-faint">{i + 1}</span>
{item.thumbnailUrl ? (
// biome-ignore lint/performance/noImgElement: external CDN thumbnails, next/image needs remote allowlist
<img
src={item.thumbnailUrl}
alt=""
className="size-9 shrink-0 rounded-md object-cover"
loading="lazy"
/>
) : (
<span className="flex size-9 shrink-0 items-center justify-center rounded-md border border-hairline bg-white/5">
<ListMusic className="size-4 text-ink-faint" />
</span>
)}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-ink">
{item.title}
</div>
<div className="mono truncate text-[0.65rem] text-ink-faint">
{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
</span>
)}
<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)}
</span>
)}
{/* Media Playback Deck */}
<div className="media-tile">
<GlassPanel className="p-5">
<div className="flex flex-col gap-4 md:flex-row md:items-center md:justify-between">
<div className="flex items-center gap-3.5">
<div className="flex size-11 items-center justify-center rounded-[8px] border border-hairline bg-surface-2 text-signal">
<Disc
className={`size-6 ${playing ? "animate-spin-disc text-signal" : "text-ink-muted"}`}
/>
</div>
);
})}
</div>
)}
</GlassPanel>
</div>
<div>
<div className="text-sm font-semibold tracking-tight text-ink">
{current?.title ?? "No Active Media Stream"}
</div>
<div className="font-mono text-[11px] text-ink-muted">
{current
? `${formatDuration(current.durationMs ?? 0)} · High Fidelity Stream`
: "Queue a track via URL below"}
</div>
</div>
</div>
{/* Controls */}
<div className="flex items-center gap-2">
<Button
variant="ghost"
size="sm"
onClick={() => loop.mutateAsync()}
className={media?.loop ? "border-signal text-signal" : ""}
>
<Repeat className="size-3.5" />
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => skip.mutateAsync()}
>
<SkipForward className="size-3.5" />
</Button>
<Button
variant="danger"
size="sm"
onClick={() => stop.mutateAsync()}
>
<Square className="size-3.5" />
</Button>
</div>
</div>
{/* Quick URL Input */}
<div className="mt-4 flex gap-2 border-t border-hairline pt-4">
<Input
placeholder="Paste YouTube, SoundCloud, or Direct Stream URL..."
value={url}
onChange={(e) => setUrl(e.target.value)}
className="flex-1 text-xs"
/>
<Button variant="primary" size="md" onClick={onPlay}>
<Play className="size-3.5" />
Queue Track
</Button>
</div>
</GlassPanel>
</div>
{/* Spectrum & Queue Grid */}
<div className="grid gap-3 lg:grid-cols-3">
<GlassPanel className="media-tile lg:col-span-2">
<SectionHeader
eyebrow="Visualizer"
title="Realtime Frequency Array"
/>
<div className="my-6 flex items-center justify-center">
<Equalizer bars={mockLevels} />
</div>
</GlassPanel>
<GlassPanel className="media-tile">
<SectionHeader
eyebrow="Playlist"
title={`Queue (${queueList.length})`}
/>
<div className="mt-3 space-y-2">
{queueList.length === 0 ? (
<div className="py-8 text-center font-mono text-xs text-ink-muted">
QUEUE EMPTY
</div>
) : (
queueList.map((item, idx) => (
<div
key={item.id ?? idx}
className="flex items-center justify-between rounded-[6px] border border-hairline bg-surface-2 p-2.5 text-xs text-ink-soft"
>
<span className="truncate pr-2 font-medium">
{item.title}
</span>
<span className="font-mono text-[10px] text-ink-muted shrink-0">
{formatDuration(item.durationMs ?? 0)}
</span>
</div>
))
)}
</div>
</GlassPanel>
</div>
</div>
</PageTransition>
);
}
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import {
getConfig,
getGuilds,
@@ -28,13 +27,11 @@ export default async function MessagesPage() {
/* client hooks surface errors */
}
return (
<PageTransition>
<MessagesView
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
initialMessages={initialMessages}
initialEdits={initialEdits}
/>
</PageTransition>
<MessagesView
initialGuilds={guilds}
initialGuildId={config?.monitorGuildId ?? null}
initialMessages={initialMessages}
initialEdits={initialEdits}
/>
);
}
@@ -4,12 +4,14 @@ import {
AlertTriangle,
Calendar,
CheckCircle2,
History,
Image as ImageIcon,
Loader2,
MessageSquare,
Paperclip,
Search,
ShieldAlert,
Sparkles,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { ActivityHeatmap } from "@/components/ActivityHeatmap";
@@ -42,6 +44,7 @@ import {
useReviewWsSync,
useSemanticSearch,
} from "@/hooks";
import { useStaggerReveal } from "@/hooks/use-gsap-animation";
import { aiTone } from "@/lib/ai-status";
import {
formatBytes,
@@ -55,9 +58,9 @@ import type {
AiStatus,
EditHistoryRow,
Guild,
MessageMetadata,
MessageRecord,
} from "@/lib/types";
import { staggerDelay } from "@/lib/utils";
import { useWebSocket } from "@/lib/ws/context";
export function MessagesView({
@@ -101,10 +104,14 @@ export function MessagesView({
channelId ?? 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
// Stream history over WS.
// Drives snapshots into the SWR list above buffered by rAF; falls back to the
// SSR `initialMessages` seed if WS is unavailable.
useMessagesStream(ws, guildId ?? "", channelId ?? undefined);
const { streaming } = useMessagesStream(
ws,
guildId ?? "",
channelId ?? undefined,
);
// Cursor to the next (older) page + whether more history exists.
const { data: pageInfo } = useMessagesHasMore(
guildId ?? "",
@@ -217,9 +224,42 @@ export function MessagesView({
prevLen.current = list.length;
}, [list.length, searching]);
// Depend on viewMode and initial load flag, NOT display.length, so streaming 200 items doesn't thrash animation
const streamRef = useStaggerReveal<HTMLDivElement>(".msg-feed-card", {
stagger: 0.02,
y: 6,
dependencies: [viewMode],
});
return (
<div className="space-y-4">
<GlassPanel className="flex flex-wrap items-center gap-3">
{/* Tactical HUD Header Bar */}
<div className="flex flex-wrap items-center justify-between gap-3 border-b border-hairline pb-3">
<div className="flex items-center gap-2.5">
<span className="h-2 w-2 rounded-full bg-signal glow-pulse" />
<h1 className="font-mono text-xs font-semibold tracking-wide text-ink uppercase">
Chat Log Stream · Ingestion Stream
</h1>
{streaming && (
<span className="flex items-center gap-1 font-mono text-[10px] text-signal animate-pulse">
<Loader2 className="size-3 animate-spin" />
STREAMING
</span>
)}
</div>
<div className="flex items-center gap-2 font-mono text-[11px] text-ink-muted">
<span>MODE:</span>
<span
className="glitch-text rounded bg-signal/15 px-2 py-0.5 font-medium text-signal border border-signal/30"
data-text={searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
>
{searching ? "SEARCH_ACTIVE" : viewMode.toUpperCase()}
</span>
</div>
</div>
{/* Filter and Mode Bar */}
<GlassPanel className="flex flex-wrap items-center gap-3 p-3">
<GuildChannelPicker
mode="text"
guildsInitial={initialGuilds}
@@ -236,48 +276,72 @@ export function MessagesView({
<div className="relative ml-auto w-full sm:w-64">
<Search className="absolute left-3 top-1/2 size-4 -translate-y-1/2 text-ink-faint" />
<Input
className="pl-9"
placeholder="Search messages…"
className="pl-9 text-xs"
placeholder="Search captured logs..."
value={query}
onChange={(e) => setQuery(e.target.value)}
/>
</div>
<button
type="button"
onClick={() => setSemanticMode((v) => !v)}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
semanticMode
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
}`}
title="Toggle semantic (vector) search over the message archive"
>
{semanticMode ? "Semantic" : "Exact"}
</button>
<button
type="button"
onClick={() =>
setViewMode((v) => (v === "feed" ? "timeline" : "feed"))
}
className={`rounded-full border px-3 py-1.5 text-xs transition-colors ${
viewMode === "timeline"
? "border-signal/40 bg-signal/10 text-signal"
: "border-hairline bg-white/[0.03] text-ink-soft hover:bg-white/[0.06]"
}`}
title="Toggle timeline (date-grouped) view"
>
{viewMode === "timeline" ? "Timeline" : "Feed"}
</button>
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
<button
type="button"
onClick={() => setSemanticMode(false)}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
!semanticMode
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
EXACT
</button>
<button
type="button"
onClick={() => setSemanticMode(true)}
className={`flex items-center gap-1 rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
semanticMode
? "bg-signal/20 text-signal border border-signal/40 shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
<Sparkles className="size-3" />
SEMANTIC
</button>
</div>
<div className="flex items-center gap-1.5 rounded-[6px] border border-hairline bg-surface-2 p-0.5">
<button
type="button"
onClick={() => setViewMode("feed")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "feed"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
FEED
</button>
<button
type="button"
onClick={() => setViewMode("timeline")}
className={`rounded-[4px] px-2.5 py-1 font-mono text-[10px] font-medium transition-all ${
viewMode === "timeline"
? "bg-surface text-ink border border-hairline-focus shadow-xs"
: "text-ink-muted hover:text-ink"
}`}
>
TIMELINE
</button>
</div>
</GlassPanel>
<div className="grid gap-4 lg:grid-cols-5">
<div className="grid gap-3 lg:grid-cols-5">
{semanticSearching && (
<GlassPanel className="lg:col-span-5">
<SectionHeader
eyebrow="semantic"
title={`${query}`}
eyebrow="semantic archive"
title={`Vector Matches for ${query}`}
action={
<span className="mono text-xs text-ink-faint">
<span className="mono text-xs text-[#8a8f98]">
{semantic.data?.length ?? 0} matches
</span>
}
@@ -289,19 +353,18 @@ export function MessagesView({
{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)}
className="hud-card flex items-start gap-3 p-3"
>
<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 className="font-mono text-[10px] font-semibold text-signal">
{(r.score * 100).toFixed(0)}% RELEVANCE
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
<span className="ml-auto font-mono text-[10px] text-ink-muted">
{formatRelativeTime(r.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-3 text-sm text-ink-soft">
<div className="mt-1 text-xs text-ink-soft leading-relaxed">
{r.content}
</div>
</div>
@@ -312,19 +375,20 @@ export function MessagesView({
<EmptyState
icon={<Search className="size-7" />}
title="No semantic matches"
description="Try different wording — semantic search finds meaning, not exact text."
description="Try different phrasing — vector search inspects contextual semantics."
/>
)}
</GlassPanel>
)}
{/* Message Stream Deck */}
<GlassPanel className="lg:col-span-3">
<SectionHeader
eyebrow={searching ? "results" : "live feed"}
title={searching ? `${query}` : "Messages"}
eyebrow={searching ? "query results" : "realtime log"}
title={searching ? `Matches for ${query}` : "Message Stream"}
action={
<span className="mono text-xs text-ink-faint">
{list.length} shown
<span className="mono text-xs text-[#8a8f98]">
{list.length} messages
</span>
}
/>
@@ -335,38 +399,37 @@ export function MessagesView({
) : list.length === 0 ? (
<EmptyState
icon={<MessageSquare className="size-7" />}
title="No messages"
description="Pick a guild to begin, or run a search."
title="No messages captured"
description="Select a channel or verify discord bridge is active."
/>
) : (
<div>
{!searching && (
<div className="mb-2 flex items-center justify-center gap-2">
{loadMore.isPending ? (
<span className="flex items-center gap-1.5 text-xs text-ink-soft">
<Loader2 className="size-3.5 animate-spin" />
Loading older
<span className="flex items-center gap-1.5 font-mono text-xs text-ink-muted">
<Loader2 className="size-3.5 animate-spin text-signal" />
FETCHING EARLIER PACKETS...
<span className="typing-dots">
<span />
<span />
<span />
</span>
</span>
) : hasMore && loadedPages < MAX_OLDER_PAGES ? (
<button
type="button"
onClick={loadOlder}
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]"
className="rounded-md border border-hairline bg-surface-2 px-3 py-1 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface hover:text-ink"
>
Load older messages
LOAD PREVIOUS BATCH
</button>
) : loadedPages >= MAX_OLDER_PAGES ? (
<span className="mono text-[0.65rem] text-ink-faint">
capped at {MAX_OLDER_PAGES} older pages · use search for
more
</span>
) : (
messages &&
messages.length > 0 && (
<span className="mono text-[0.65rem] text-ink-faint">
beginning of history
</span>
)
<span className="font-mono text-[10px] text-ink-faint">
{loadedPages >= MAX_OLDER_PAGES
? `CAPPED AT ${MAX_OLDER_PAGES} PAGES`
: "STREAM ROOT REACHED"}
</span>
)}
</div>
)}
@@ -375,8 +438,6 @@ export function MessagesView({
className="max-h-[60vh] space-y-1.5 overflow-y-auto pr-1"
onScroll={(e) => {
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 =
el.scrollHeight - el.scrollTop - el.clientHeight < 120;
if (searching || !hasMore || loadMore.isPending) return;
@@ -386,44 +447,47 @@ export function MessagesView({
}
}}
>
{viewMode === "timeline" && timelineNodes
? timelineNodes.map((node, _i) =>
node.type === "date" ? (
<div
key={`date-${node.iso}`}
className="flex items-center gap-2 px-1 text-[0.65rem] text-ink-faint"
>
<Calendar className="size-3" />
{node.label}
</div>
) : (
<div ref={streamRef} className="space-y-1.5">
{viewMode === "timeline" && timelineNodes
? timelineNodes.map((node) =>
node.type === "date" ? (
<div
key={`date-${node.iso}`}
className="flex items-center gap-2 py-1 font-mono text-[10px] text-ink-muted"
>
<Calendar className="size-3 text-signal" />
{node.label}
</div>
) : (
<MessageRow
key={node.m.id}
m={node.m}
selected={selected}
onSelect={setSelected}
/>
),
)
: display.map((m) => (
<MessageRow
key={node.m.id}
m={node.m}
key={m.id}
m={m}
selected={selected}
onSelect={setSelected}
/>
),
)
: display.map((m, _i) => (
<MessageRow
key={m.id}
m={m}
selected={selected}
onSelect={setSelected}
/>
))}
))}
</div>
</div>
</div>
)}
</GlassPanel>
{/* Message Inspector Detail Panel */}
<GlassPanel className="lg:col-span-2">
<SectionHeader eyebrow="inspect" title="Detail" />
<SectionHeader eyebrow="telemetry analysis" title="Inspector Deck" />
{!selected ? (
<EmptyState
title="Select a message"
description="Click any message to inspect AI analysis, attachments and edit history."
description="Click any packet in the stream to inspect AI flags, moderation heuristics, and attachments."
/>
) : detail.loading ? (
<div className="space-y-2">
@@ -436,7 +500,7 @@ export function MessagesView({
attachments={detail.attachments}
/>
) : (
<EmptyState title="Not found" />
<EmptyState title="Packet not found" />
)}
</GlassPanel>
</div>
@@ -493,12 +557,25 @@ function MessageDetail({
}) {
const flags = safeParseJsonArray(m.ai_moderation_flags);
const cats = safeParseJsonArray(m.ai_categories);
const editHistory =
(m as { edit_history?: Array<{ old_content: string; edited_at: number }> })
.edit_history ?? [];
const editCount = (m as { edit_count?: number }).edit_count ?? 0;
return (
<div className="space-y-3 text-sm">
<div className="flex items-center gap-3">
<Avatar src={m.avatar_url} name={m.username} size={40} />
<Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={40}
/>
<div>
<div className="font-semibold text-ink">{m.username}</div>
<div className="font-semibold text-ink">
{m.server_nick ?? m.username}
</div>
{m.server_nick && m.server_nick !== m.username && (
<div className="text-[11px] text-ink-muted">@{m.username}</div>
)}
<div className="mono text-[0.65rem] text-ink-faint">
{getMessageChannelLabel(m)} · {formatRelativeTime(m.created_at)}
</div>
@@ -511,15 +588,15 @@ function MessageDetail({
</div>
</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
<div className="hud-card p-3 text-xs text-ink-soft leading-relaxed">
{renderMessageContent(m.edited_content ?? m.content, m.metadata) ||
"(no text)"}
"(no text content)"}
</div>
{m.ai_analysis && (
<div>
<div className="eyebrow mb-1">AI analysis</div>
<div className="rounded-[10px] border border-hairline bg-white/[0.03] p-3 text-ink-soft">
<div className="eyebrow mb-1">AI heuristic reasoning</div>
<div className="hud-card p-3 text-xs text-ink-muted leading-relaxed">
{m.ai_analysis}
</div>
</div>
@@ -540,6 +617,44 @@ function MessageDetail({
</div>
)}
{/* Edit history — before / after diff */}
{editCount > 0 && editHistory.length > 0 && (
<div>
<div className="eyebrow mb-1.5 flex items-center gap-1.5">
<History className="size-3" /> Edit history ({editCount})
</div>
<div className="space-y-2">
{editHistory.map((e, i) => (
<div
key={`${m.id}-edit-${i}`}
className="grid grid-cols-2 gap-1.5 rounded-[6px] border border-hairline bg-surface-2/50 text-[10px] leading-relaxed"
>
<div className="overflow-hidden rounded-l-[5px] border-r border-hairline">
<div className="border-b border-hairline bg-vermilion/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-vermilion">
Before
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-faint/80">
{e.old_content || <em>(empty)</em>}
</pre>
</div>
</div>
<div className="overflow-hidden rounded-r-[5px]">
<div className="border-b border-hairline bg-success/5 px-2 py-0.5 font-mono text-[8px] font-semibold uppercase tracking-wider text-success">
After
</div>
<div className="max-h-20 overflow-y-auto p-1.5">
<pre className="whitespace-pre-wrap break-words text-ink-soft">
{m.content || <em>(empty)</em>}
</pre>
</div>
</div>
</div>
))}
</div>
</div>
)}
{attachments.length > 0 && (
<div>
<div className="eyebrow mb-1 flex items-center gap-1.5">
@@ -552,11 +667,11 @@ function MessageDetail({
href={a.discord_url ?? a.uploaded_url ?? "#"}
target="_blank"
rel="noreferrer"
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"
className="hud-card flex items-center gap-2 px-3 py-2 text-xs text-ink-soft hover:text-ink"
>
<ImageIcon className="size-3.5 text-signal" />
<span className="flex-1 truncate">{a.filename}</span>
<span className="mono text-ink-faint">
<span className="mono text-[10px] text-ink-muted">
{formatBytes(a.size)}
</span>
</a>
@@ -568,6 +683,22 @@ function MessageDetail({
);
}
/** Parse metadata JSON safely — returns null on malformed / missing data. */
function parseMeta(raw: string | null | undefined): MessageMetadata | null {
if (!raw) return null;
try {
return JSON.parse(raw) as MessageMetadata;
} catch {
return null;
}
}
/** True when an attachment content-type looks like an image we can inline. */
function isImageType(ct?: string | null): boolean {
if (!ct) return false;
return ct.startsWith("image/");
}
/** Single message card used by both the live feed and the date-grouped timeline. */
function MessageRow({
m,
@@ -578,35 +709,158 @@ function MessageRow({
selected: string | null;
onSelect: (id: string) => void;
}) {
const meta = parseMeta(m.metadata);
const attachments = meta?.attachments ?? [];
const embeds = meta?.embeds ?? [];
const stickers = meta?.stickers ?? [];
const imageAttachments = attachments.filter((a) =>
isImageType(a.contentType),
);
const fileAttachments = attachments.filter(
(a) => !isImageType(a.contentType),
);
// First embed image or sticker url for visual preview
const embedImage =
embeds.find((e) => e.image?.url)?.image?.url ??
embeds.find((e) => e.thumbnail?.url)?.thumbnail?.url ??
null;
const stickerUrl = stickers.find((s) => s.url)?.url ?? null;
return (
<button
key={m.id}
type="button"
onClick={() => onSelect(m.id)}
className={`animate-stagger flex w-full items-start gap-3 rounded-[12px] border p-3 text-left transition-colors ${
className={`msg-feed-card flex w-full items-start gap-3 rounded-[8px] border p-2.5 text-left transition-all ${
selected === m.id
? "border-signal/40 bg-signal/8"
: "border-hairline bg-white/[0.03] hover:bg-white/[0.06]"
? "border-signal/50 bg-signal/10 shadow-xs"
: "border-hairline bg-surface-2 hover:border-hairline-focus hover:bg-surface"
}`}
>
<Avatar src={m.avatar_url} name={m.username} size={34} />
<Avatar
src={m.avatar_url}
name={m.server_nick ?? m.username}
size={32}
/>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2">
<span className="truncate text-sm font-semibold text-ink">
{m.username}
<span className="truncate text-xs font-semibold text-ink">
{m.server_nick ?? m.username}
</span>
<span className="mono text-[0.65rem] text-ink-faint">
{m.server_nick && m.server_nick !== m.username && (
<span className="truncate font-mono text-[10px] text-ink-muted">
@{m.username}
</span>
)}
<span className="font-mono text-[10px] text-ink-muted">
{getMessageChannelLabel(m)}
</span>
<span className="mono ml-auto text-[0.6rem] text-ink-faint">
<span className="ml-auto font-mono text-[10px] text-ink-muted">
{formatRelativeTime(m.created_at)}
</span>
</div>
<div className="mt-0.5 line-clamp-2 text-sm text-ink-soft">
{renderMessageContent(m.content, m.metadata) || (
<span className="italic text-ink-faint">(empty / embed)</span>
)}
</div>
{/* Text content */}
{m.content && (
<div className="mt-0.5 line-clamp-2 text-xs text-ink-soft">
{renderMessageContent(m.content, m.metadata)}
</div>
)}
{/* Inline image attachments */}
{imageAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1.5">
{imageAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={a.url}
alt={a.name}
loading="lazy"
className="max-h-32 w-auto max-w-[200px] object-cover"
/>
</a>
))}
</div>
)}
{/* Sticker image */}
{stickerUrl && imageAttachments.length === 0 && (
<div className="mt-1.5">
<a
href={stickerUrl}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={stickerUrl}
alt={stickers[0]?.name ?? "sticker"}
loading="lazy"
className="max-h-28 w-auto max-w-[160px] object-contain"
/>
</a>
</div>
)}
{/* Embed image / thumbnail */}
{embedImage && imageAttachments.length === 0 && !stickerUrl && (
<div className="mt-1.5">
<a
href={embedImage}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="block overflow-hidden rounded-[6px] border border-hairline"
>
<img
src={embedImage}
alt="embed"
loading="lazy"
className="max-h-32 w-auto max-w-[240px] object-cover"
/>
</a>
{embeds[0]?.title && (
<div className="mt-0.5 truncate text-[10px] font-medium text-ink-muted">
{embeds[0].title}
</div>
)}
</div>
)}
{/* Non-image file attachments */}
{fileAttachments.length > 0 && (
<div className="mt-1.5 flex flex-wrap gap-1">
{fileAttachments.map((a) => (
<a
key={a.url}
href={a.url}
target="_blank"
rel="noreferrer"
onClick={(e) => e.stopPropagation()}
className="inline-flex items-center gap-1 rounded-[4px] border border-hairline bg-surface px-2 py-0.5 font-mono text-[10px] text-ink-muted transition-colors hover:bg-surface-2 hover:text-ink"
>
<Paperclip className="size-2.5" />
{a.name}
</a>
))}
</div>
)}
{/* No content at all */}
{!m.content && attachments.length === 0 && embeds.length === 0 && (
<div className="mt-0.5 text-xs italic text-ink-muted">
(empty / embed)
</div>
)}
</div>
<AiBadge status={m.ai_status} durationMs={m.ai_analysis_duration_ms} />
</button>
@@ -1,4 +1,3 @@
import { PageTransition } from "@/components/shared";
import { getModerationActions, getModerationStats } from "@/lib/api/server";
import { ModerationView } from "./view";
@@ -15,9 +14,5 @@ export default async function ModerationPage() {
} catch {
/* client hooks surface errors */
}
return (
<PageTransition>
<ModerationView initialStats={stats} initialActions={actions} />
</PageTransition>
);
return <ModerationView initialStats={stats} initialActions={actions} />;
}

Some files were not shown because too many files have changed in this diff Show More