refactor: rename mascot to chatbot across entire codebase
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 30s
Build & Deploy / build-and-push (backend) (push) Failing after 2m28s
Build & Deploy / build-and-push (proxy) (push) Successful in 2m28s
Build & Deploy / build-and-push (discord-gateway) (push) Failing after 30s
Build & Deploy / build-and-push (backend) (push) Failing after 2m28s
Build & Deploy / build-and-push (proxy) (push) Successful in 2m28s
- Backend: mascot-chat module → chatbot, routes /mascot/chat → /chat - Shared schema: pgMascotChatMessagesTable → pgChatbotMessagesTable - Frontend: MascotProvider/useMascot → ChatbotProvider/useChatbot - Gateway schema: update exports to match shared schema - Docs: update all .md references (CLAUDE.md, README, specs, plans) - All API routes, controller names, service classes, types renamed Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bd9e7d8151
commit
977a6f9653
@@ -80,7 +80,7 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
|
|||||||
- `POST /api/messages/:id/moderate` — Dispatch moderation action
|
- `POST /api/messages/:id/moderate` — Dispatch moderation action
|
||||||
- `GET /api/review` — Flagged/warned messages
|
- `GET /api/review` — Flagged/warned messages
|
||||||
- `GET /api/analysis/search` — Full-text search with `?q=`
|
- `GET /api/analysis/search` — Full-text search with `?q=`
|
||||||
- `POST /api/chat` — Mascot AI chat
|
- `POST /api/chat` — AI chatbot chat
|
||||||
- `GET /api/chat/history` — Chat history
|
- `GET /api/chat/history` — Chat history
|
||||||
- `POST /api/chat/clear` — Clear chat history
|
- `POST /api/chat/clear` — Clear chat history
|
||||||
- `POST /api/voice/command` — Send voice transmit commands
|
- `POST /api/voice/command` — Send voice transmit commands
|
||||||
@@ -105,7 +105,7 @@ Express 5 + Helmet HTTP server with WebSocket (ws) on port 3001 (default).
|
|||||||
- `voice/` — Voice connection, guilds, channels
|
- `voice/` — Voice connection, guilds, channels
|
||||||
- `media/` — Music/screenshare player control
|
- `media/` — Music/screenshare player control
|
||||||
- `analysis/` — Full-text search across analyzed messages
|
- `analysis/` — Full-text search across analyzed messages
|
||||||
- `mascot-chat/` — AI chatbot with server context
|
- `chatbot/` — AI chatbot with server context
|
||||||
- `recordings/` — Voice recording listing
|
- `recordings/` — Voice recording listing
|
||||||
- `ui-state/` — Persistent UI state for dashboard
|
- `ui-state/` — Persistent UI state for dashboard
|
||||||
- `config/` — App config endpoint
|
- `config/` — App config endpoint
|
||||||
@@ -205,7 +205,7 @@ Next.js 16 (React 19) static export dashboard, built with TypeScript + Tailwind
|
|||||||
|
|
||||||
**Feature structure:**
|
**Feature structure:**
|
||||||
- `src/app/` — App Router pages (login, dashboard with tabs)
|
- `src/app/` — App Router pages (login, dashboard with tabs)
|
||||||
- `src/features/` — Feature components (dashboard, messages, live, mascot)
|
- `src/features/` — Feature components (dashboard, messages, live, chatbot)
|
||||||
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
|
- `src/lib/` — Shared utilities (types, API client, WebSocket, hooks)
|
||||||
- `src/components/` — Shared UI components (layout, ui)
|
- `src/components/` — Shared UI components (layout, ui)
|
||||||
|
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
**Goal:** Full frontend redesign with glassmorphic dark theme, floating top nav, Live2D mascot, and rich analytics dashboard.
|
**Goal:** Full frontend redesign with glassmorphic dark theme, floating top nav, Live2D chatbot, and rich analytics dashboard.
|
||||||
|
|
||||||
**Architecture:** No routing or state management changes — same Next.js App Router, TanStack Query, WebSocket context. Only visual layer and component structure rewritten. New glass component system wraps existing logic.
|
**Architecture:** No routing or state management changes — same Next.js App Router, TanStack Query, WebSocket context. Only visual layer and component structure rewritten. New glass component system wraps existing logic.
|
||||||
|
|
||||||
@@ -33,7 +33,7 @@
|
|||||||
| `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations |
|
| `src/app/globals.css` | Complete rewrite — new tokens, glass system, animations |
|
||||||
| `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata |
|
| `src/app/layout.tsx` | Fonts (Inter + JetBrains Mono), metadata |
|
||||||
| `src/app/page.tsx` | Redirect `/dashboard` not `/messages` |
|
| `src/app/page.tsx` | Redirect `/dashboard` not `/messages` |
|
||||||
| `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, mascot context, media context, WS provider |
|
| `src/app/(dashboard)/layout.tsx` | Top nav, no sidebar, chatbot context, media context, WS provider |
|
||||||
| `src/lib/navigation.ts` | New nav items (no Search link), mobile items updated |
|
| `src/lib/navigation.ts` | New nav items (no Search link), mobile items updated |
|
||||||
| `src/app/(dashboard)/dashboard/page.tsx` | Full rewrite — Ops Center |
|
| `src/app/(dashboard)/dashboard/page.tsx` | Full rewrite — Ops Center |
|
||||||
| `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane |
|
| `src/app/(dashboard)/messages/page.tsx` | Full rewrite — Split pane |
|
||||||
@@ -69,23 +69,23 @@
|
|||||||
| `src/components/voice/activity-timeline.tsx` | Voice activity chart |
|
| `src/components/voice/activity-timeline.tsx` | Voice activity chart |
|
||||||
| `src/components/recordings/recording-card.tsx` | Glass card + waveform preview |
|
| `src/components/recordings/recording-card.tsx` | Glass card + waveform preview |
|
||||||
| `src/components/recordings/recording-player.tsx` | Inline audio player |
|
| `src/components/recordings/recording-player.tsx` | Inline audio player |
|
||||||
| `src/components/mascot/mascot-container.tsx` | Floating L2D container |
|
| `src/components/chatbot/chatbot-container.tsx` | Floating L2D container |
|
||||||
| `src/components/mascot/mascot-canvas.tsx` | WebGL Live2D renderer |
|
| `src/components/chatbot/chatbot-canvas.tsx` | WebGL Live2D renderer |
|
||||||
| `src/components/mascot/chat-panel.tsx` | Chat input + history |
|
| `src/components/chatbot/chat-panel.tsx` | Chat input + history |
|
||||||
| `src/components/mascot/mascot-context.tsx` | Context provider |
|
| `src/components/chatbot/chatbot-context.tsx` | Context provider |
|
||||||
| `src/components/media/mini-player.tsx` | Floating media player |
|
| `src/components/media/mini-player.tsx` | Floating media player |
|
||||||
| `src/components/shared/error-boundary.tsx` | Per-page error boundary |
|
| `src/components/shared/error-boundary.tsx` | Per-page error boundary |
|
||||||
| `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton |
|
| `src/components/shared/loading-skeleton.tsx` | Glass shimmer skeleton |
|
||||||
| `src/components/shared/empty-state.tsx` | Empty state |
|
| `src/components/shared/empty-state.tsx` | Empty state |
|
||||||
| `src/lib/hooks/use-media-player.ts` | Global media player context |
|
| `src/lib/hooks/use-media-player.ts` | Global media player context |
|
||||||
| `src/lib/hooks/use-mascot.ts` | Mascot context hook |
|
| `src/lib/hooks/use-chatbot.ts` | Chatbot context hook |
|
||||||
|
|
||||||
### Deleted files (replaced by new components):
|
### Deleted files (replaced by new components):
|
||||||
| File | Replaced by |
|
| File | Replaced by |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `src/components/layout/app-sidebar.tsx` | `top-nav.tsx` + `hidden-sidebar.tsx` |
|
| `src/components/layout/app-sidebar.tsx` | `top-nav.tsx` + `hidden-sidebar.tsx` |
|
||||||
| `src/components/layout/app-header.tsx` | `top-nav.tsx` + `sub-nav.tsx` |
|
| `src/components/layout/app-header.tsx` | `top-nav.tsx` + `sub-nav.tsx` |
|
||||||
| `src/components/chatbot/chatbot.tsx` | `mascot/` components |
|
| `src/components/chatbot/chatbot.tsx` | `chatbot/` components |
|
||||||
| `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` |
|
| `src/components/shared/stat-card.tsx` | `dashboard/stat-card.tsx` |
|
||||||
| `src/components/shared/detail-stat.tsx` | inline in detail views |
|
| `src/components/shared/detail-stat.tsx` | inline in detail views |
|
||||||
| `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` |
|
| `src/components/messages/images-grid.tsx` | `attachments-grid.tsx` |
|
||||||
@@ -749,8 +749,8 @@ import { Suspense } from "react";
|
|||||||
import { TopNav } from "@/components/layout/top-nav";
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { WsProvider } from "@/lib/ws/context";
|
import { WsProvider } from "@/lib/ws/context";
|
||||||
import { MascotProvider } from "@/components/mascot/mascot-context";
|
import { ChatbotProvider } from "@/components/chatbot/chatbot-context";
|
||||||
import { MascotContainer } from "@/components/mascot/mascot-container";
|
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||||
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
||||||
@@ -777,7 +777,7 @@ export default function DashboardLayout({
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<MediaPlayerProvider>
|
<MediaPlayerProvider>
|
||||||
<MascotProvider>
|
<ChatbotProvider>
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-screen bg-canvas">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||||
@@ -799,9 +799,9 @@ export default function DashboardLayout({
|
|||||||
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
<MascotContainer />
|
<ChatbotContainer />
|
||||||
</div>
|
</div>
|
||||||
</MascotProvider>
|
</ChatbotProvider>
|
||||||
</MediaPlayerProvider>
|
</MediaPlayerProvider>
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
@@ -822,7 +822,7 @@ rm src/components/chatbot/chatbot.tsx
|
|||||||
```bash
|
```bash
|
||||||
git add src/app/\(dashboard\)/layout.tsx
|
git add src/app/\(dashboard\)/layout.tsx
|
||||||
git rm src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/chatbot/chatbot.tsx
|
git rm src/components/layout/app-sidebar.tsx src/components/layout/app-header.tsx src/components/chatbot/chatbot.tsx
|
||||||
git commit -m "feat: rewrite dashboard layout with top nav, hidden sidebar, mascot, mini-player"
|
git commit -m "feat: rewrite dashboard layout with top nav, hidden sidebar, chatbot, mini-player"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -3117,38 +3117,38 @@ git commit -m "feat: add media player context and floating mini player"
|
|||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
### Task 21: Mascot — Context, Container & Canvas
|
### Task 21: Chatbot — Context, Container & Canvas
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Create: `src/components/mascot/mascot-context.tsx`
|
- Create: `src/components/chatbot/chatbot-context.tsx`
|
||||||
- Create: `src/components/mascot/mascot-container.tsx`
|
- Create: `src/components/chatbot/chatbot-container.tsx`
|
||||||
- Create: `src/components/mascot/mascot-canvas.tsx`
|
- Create: `src/components/chatbot/chatbot-canvas.tsx`
|
||||||
- Create: `src/components/mascot/chat-panel.tsx`
|
- Create: `src/components/chatbot/chat-panel.tsx`
|
||||||
|
|
||||||
- [ ] **Step 1: Create MascotContext**
|
- [ ] **Step 1: Create ChatbotContext**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { createContext, useContext, useState, type ReactNode } from "react";
|
import { createContext, useContext, useState, type ReactNode } from "react";
|
||||||
|
|
||||||
type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
||||||
|
|
||||||
interface MascotContextType {
|
interface ChatbotContextType {
|
||||||
expression: MascotExpression;
|
expression: ChatbotExpression;
|
||||||
minimized: boolean;
|
minimized: boolean;
|
||||||
chatOpen: boolean;
|
chatOpen: boolean;
|
||||||
chatHistory: { role: "user" | "assistant"; text: string }[];
|
chatHistory: { role: "user" | "assistant"; text: string }[];
|
||||||
setExpression: (expr: MascotExpression) => void;
|
setExpression: (expr: ChatbotExpression) => void;
|
||||||
setMinimized: (v: boolean) => void;
|
setMinimized: (v: boolean) => void;
|
||||||
setChatOpen: (v: boolean) => void;
|
setChatOpen: (v: boolean) => void;
|
||||||
addChat: (role: "user" | "assistant", text: string) => void;
|
addChat: (role: "user" | "assistant", text: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MascotContext = createContext<MascotContextType | null>(null);
|
const ChatbotContext = createContext<ChatbotContextType | null>(null);
|
||||||
|
|
||||||
export function MascotProvider({ children }: { children: ReactNode }) {
|
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||||
const [expression, setExpression] = useState<MascotExpression>("idle");
|
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||||
const [minimized, setMinimized] = useState(true);
|
const [minimized, setMinimized] = useState(true);
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
|
const [chatHistory, setChatHistory] = useState<{ role: "user" | "assistant"; text: string }[]>([]);
|
||||||
@@ -3158,28 +3158,28 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MascotContext.Provider
|
<ChatbotContext.Provider
|
||||||
value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }}
|
value={{ expression, minimized, chatOpen, chatHistory, setExpression, setMinimized, setChatOpen, addChat }}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MascotContext.Provider>
|
</ChatbotContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMascot() {
|
export function useChatbot() {
|
||||||
const ctx = useContext(MascotContext);
|
const ctx = useContext(ChatbotContext);
|
||||||
if (!ctx) throw new Error("useMascot must be used within MascotProvider");
|
if (!ctx) throw new Error("useChatbot must be used within ChatbotProvider");
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 2: Create MascotCanvas (Live2D placeholder)**
|
- [ ] **Step 2: Create ChatbotCanvas (Live2D placeholder)**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live2D Cubism WebGL canvas.
|
* Live2D Cubism WebGL canvas.
|
||||||
@@ -3188,15 +3188,15 @@ import { useMascot } from "./mascot-context";
|
|||||||
* Integration requires:
|
* Integration requires:
|
||||||
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||||
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||||
* 3. Place model files in public/mascot/
|
* 3. Place model files in public/chatbot/
|
||||||
*
|
*
|
||||||
* The current implementation shows a placeholder character.
|
* The current implementation shows a placeholder character.
|
||||||
* Replace with actual Cubism SDK integration when model files are available.
|
* Replace with actual Cubism SDK integration when model files are available.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function MascotCanvas() {
|
export function ChatbotCanvas() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const { expression } = useMascot();
|
const { expression } = useChatbot();
|
||||||
|
|
||||||
// Placeholder: draw a simple avatar face that responds to expression
|
// Placeholder: draw a simple avatar face that responds to expression
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -3308,19 +3308,19 @@ export function MascotCanvas() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 3: Create MascotContainer**
|
- [ ] **Step 3: Create ChatbotContainer**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react";
|
import { MessageCircle, X, Minimize2, Maximize2 } from "lucide-react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
import { MascotCanvas } from "./mascot-canvas";
|
import { ChatbotCanvas } from "./chatbot-canvas";
|
||||||
import { ChatPanel } from "./chat-panel";
|
import { ChatPanel } from "./chat-panel";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
|
|
||||||
export function MascotContainer() {
|
export function ChatbotContainer() {
|
||||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
@@ -3345,7 +3345,7 @@ export function MascotContainer() {
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
>
|
>
|
||||||
{/* Main mascot bubble */}
|
{/* Main chatbot bubble */}
|
||||||
<div
|
<div
|
||||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||||
minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]"
|
minimized ? "w-16 h-16 cursor-pointer" : "w-[200px]"
|
||||||
@@ -3368,7 +3368,7 @@ export function MascotContainer() {
|
|||||||
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Mascot</span>
|
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">Chatbot</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button type="button" onClick={() => setChatOpen(!chatOpen)}>
|
<button type="button" onClick={() => setChatOpen(!chatOpen)}>
|
||||||
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
@@ -3381,7 +3381,7 @@ export function MascotContainer() {
|
|||||||
|
|
||||||
{/* Canvas area */}
|
{/* Canvas area */}
|
||||||
<div className="h-[140px] flex items-center justify-center">
|
<div className="h-[140px] flex items-center justify-center">
|
||||||
<MascotCanvas />
|
<ChatbotCanvas />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat panel (expandable) */}
|
{/* Chat panel (expandable) */}
|
||||||
@@ -3403,10 +3403,10 @@ export function MascotContainer() {
|
|||||||
|
|
||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
|
|
||||||
export function ChatPanel() {
|
export function ChatPanel() {
|
||||||
const { chatHistory, addChat, setExpression } = useMascot();
|
const { chatHistory, addChat, setExpression } = useChatbot();
|
||||||
const [input, setInput] = useState("");
|
const [input, setInput] = useState("");
|
||||||
|
|
||||||
const handleSend = () => {
|
const handleSend = () => {
|
||||||
@@ -3414,7 +3414,7 @@ export function ChatPanel() {
|
|||||||
addChat("user", input);
|
addChat("user", input);
|
||||||
setExpression("listening");
|
setExpression("listening");
|
||||||
|
|
||||||
// Simulated bot response — replace with actual mascot-chat API call
|
// Simulated bot response — replace with actual chatbot-chat API call
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
addChat("assistant", `I'm monitoring this server for you!`);
|
addChat("assistant", `I'm monitoring this server for you!`);
|
||||||
setExpression("happy");
|
setExpression("happy");
|
||||||
@@ -3444,7 +3444,7 @@ export function ChatPanel() {
|
|||||||
value={input}
|
value={input}
|
||||||
onChange={(e) => setInput(e.target.value)}
|
onChange={(e) => setInput(e.target.value)}
|
||||||
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
onKeyDown={(e) => e.key === "Enter" && handleSend()}
|
||||||
placeholder="Ask mascot..."
|
placeholder="Ask chatbot..."
|
||||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||||
/>
|
/>
|
||||||
<button type="button" onClick={handleSend} className="size-5 flex items-center justify-center">
|
<button type="button" onClick={handleSend} className="size-5 flex items-center justify-center">
|
||||||
@@ -3459,17 +3459,17 @@ export function ChatPanel() {
|
|||||||
- [ ] **Step 5: Create barrel export**
|
- [ ] **Step 5: Create barrel export**
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// src/components/mascot/index.ts
|
// src/components/chatbot/index.ts
|
||||||
export { MascotProvider } from "./mascot-context";
|
export { ChatbotProvider } from "./chatbot-context";
|
||||||
export { MascotContainer } from "./mascot-container";
|
export { ChatbotContainer } from "./chatbot-container";
|
||||||
export { useMascot } from "./mascot-context";
|
export { useChatbot } from "./chatbot-context";
|
||||||
```
|
```
|
||||||
|
|
||||||
- [ ] **Step 6: Commit**
|
- [ ] **Step 6: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/components/mascot/
|
git add src/components/chatbot/
|
||||||
git commit -m "feat: add Live2D mascot container with canvas, chat panel, and context"
|
git commit -m "feat: add Live2D chatbot container with canvas, chat panel, and context"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -3477,21 +3477,21 @@ git commit -m "feat: add Live2D mascot container with canvas, chat panel, and co
|
|||||||
### Task 22: WS Expression Triggers
|
### Task 22: WS Expression Triggers
|
||||||
|
|
||||||
**Files:**
|
**Files:**
|
||||||
- Modify: `src/app/(dashboard)/layout.tsx` — add WS → mascot expression bindings
|
- Modify: `src/app/(dashboard)/layout.tsx` — add WS → chatbot expression bindings
|
||||||
|
|
||||||
- [ ] **Step 1: Add WebSocket expression triggers**
|
- [ ] **Step 1: Add WebSocket expression triggers**
|
||||||
|
|
||||||
In the dashboard layout, add a side-effect that connects WebSocket events to mascot expressions:
|
In the dashboard layout, add a side-effect that connects WebSocket events to chatbot expressions:
|
||||||
|
|
||||||
```tsx
|
```tsx
|
||||||
// Add to dashboard layout before the return:
|
// Add to dashboard layout before the return:
|
||||||
import { useEffect } from "react";
|
import { useEffect } from "react";
|
||||||
import { useMascot } from "@/components/mascot/mascot-context";
|
import { useChatbot } from "@/components/chatbot/chatbot-context";
|
||||||
import { useWebSocket } from "@/lib/ws/context";
|
import { useWebSocket } from "@/lib/ws/context";
|
||||||
|
|
||||||
function MascotExpressionSync() {
|
function ChatbotExpressionSync() {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { setExpression } = useMascot();
|
const { setExpression } = useChatbot();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub1 = ws.on("message_created", (data: any) => {
|
const unsub1 = ws.on("message_created", (data: any) => {
|
||||||
@@ -3512,13 +3512,13 @@ function MascotExpressionSync() {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
Then render `<MascotExpressionSync />` inside the layout tree.
|
Then render `<ChatbotExpressionSync />` inside the layout tree.
|
||||||
|
|
||||||
- [ ] **Step 2: Commit**
|
- [ ] **Step 2: Commit**
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
git add src/app/\(dashboard\)/layout.tsx
|
git add src/app/\(dashboard\)/layout.tsx
|
||||||
git commit -m "feat: connect WS events to mascot expression triggers"
|
git commit -m "feat: connect WS events to chatbot expression triggers"
|
||||||
```
|
```
|
||||||
|
|
||||||
---
|
---
|
||||||
@@ -3570,12 +3570,12 @@ git commit -m "chore: remove old components replaced by redesign"
|
|||||||
- Section 8 (Tech Stack) → Task 2 (fonts)
|
- Section 8 (Tech Stack) → Task 2 (fonts)
|
||||||
- Section 9 (File Structure) → All tasks
|
- Section 9 (File Structure) → All tasks
|
||||||
- Section 10 (Implementation Order) → Followed as-is
|
- Section 10 (Implementation Order) → Followed as-is
|
||||||
- Mascot → Tasks 21, 22
|
- Chatbot → Tasks 21, 22
|
||||||
- Media Player → Task 20
|
- Media Player → Task 20
|
||||||
- No gaps found.
|
- No gaps found.
|
||||||
|
|
||||||
- [ ] **Placeholder check:** No TBD, TODO, or "implement later" found. Every task has specific code. The only note is the Live2D canvas is a placeholder with Canvas2D drawing — this is intentional since the actual Live2D model file isn't available yet.
|
- [ ] **Placeholder check:** No TBD, TODO, or "implement later" found. Every task has specific code. The only note is the Live2D canvas is a placeholder with Canvas2D drawing — this is intentional since the actual Live2D model file isn't available yet.
|
||||||
|
|
||||||
- [ ] **Type consistency:** All component props match what consuming pages expect. Hook interfaces consistent (useMascot, useMediaPlayer). No type drift between tasks.
|
- [ ] **Type consistency:** All component props match what consuming pages expect. Hook interfaces consistent (useChatbot, useMediaPlayer). No type drift between tasks.
|
||||||
|
|
||||||
- [ ] **No contradictions:** nav items match top nav links. Page layouts match sub-nav tabs. No file referenced before being created.
|
- [ ] **No contradictions:** nav items match top nav links. Page layouts match sub-nav tabs. No file referenced before being created.
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
# Frontend Refactor: Cleanup, API Alignment & Rebrand
|
# Frontend Refactor: Cleanup, API Alignment & Rebrand
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "mascot" to "chatbot".
|
Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, properly aligned with backend API, and rebranded from "bete/GMW" to "Discord Automod" and from "chatbot" to "chatbot".
|
||||||
|
|
||||||
## Scope
|
## Scope
|
||||||
|
|
||||||
@@ -23,7 +23,7 @@ Refactor the frontend (`services/frontend/`) to be cleaner, more maintainable, p
|
|||||||
|
|
||||||
### D. Rebrand
|
### D. Rebrand
|
||||||
- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments
|
- **bete/GMW → Discord Automod**: page title, sidebar, settings, comments
|
||||||
- **mascot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/mascot/chat`) stay unchanged on frontend since they reference the actual backend path
|
- **chatbot → chatbot**: the frontend already uses "chatbot" naming for the component and API module; backend paths (`/api/chatbot/chat`) stay unchanged on frontend since they reference the actual backend path
|
||||||
|
|
||||||
### E. Dead Code Removal
|
### E. Dead Code Removal
|
||||||
- Remove `components/landing/` (including `live-stats.tsx`)
|
- Remove `components/landing/` (including `live-stats.tsx`)
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ Comprehensive refactoring of `services/backend` (4.2k lines) and `services/disco
|
|||||||
|
|
||||||
**Problem:** Two competing controller patterns.
|
**Problem:** Two competing controller patterns.
|
||||||
|
|
||||||
- `messages.controller.ts`, `mascot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
|
- `messages.controller.ts`, `chatbot-chat.controller.ts` use convoluted `asyncHandler` inside function body (Gaya A)
|
||||||
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
|
- `voice.controller.ts`, `health.controller.ts` use clean `asyncHandler` decorator (Gaya B)
|
||||||
|
|
||||||
**Fix:** Convert all controllers to **Gaya B** (decorator pattern).
|
**Fix:** Convert all controllers to **Gaya B** (decorator pattern).
|
||||||
@@ -36,7 +36,7 @@ export const handleListMessages = asyncHandler(async (req, res) => {
|
|||||||
|
|
||||||
**Files affected:**
|
**Files affected:**
|
||||||
- `modules/messages/messages.controller.ts`
|
- `modules/messages/messages.controller.ts`
|
||||||
- `modules/mascot-chat/mascot-chat.controller.ts`
|
- `modules/chatbot-chat/chatbot-chat.controller.ts`
|
||||||
|
|
||||||
### Phase 2 — Backend `response.ts` Cleanup
|
### Phase 2 — Backend `response.ts` Cleanup
|
||||||
|
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ Full frontend redesign for Discord Automod, a Discord moderation watcher dashboa
|
|||||||
Key pillars:
|
Key pillars:
|
||||||
- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content
|
- **Immersion** — Full-viewport canvas with ambient motion, glass panels float over content
|
||||||
- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts
|
- **Awareness** — Live data streams, real-time voice waveforms, animated moderation alerts
|
||||||
- **Presence** — Live2D vtuber mascot character as chatbot interface, reacts to server events
|
- **Presence** — Live2D vtuber chatbot character as chatbot interface, reacts to server events
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -69,7 +69,7 @@ Key pillars:
|
|||||||
│ └─────────┘ └──────────────┘ │
|
│ └─────────┘ └──────────────┘ │
|
||||||
│ │
|
│ │
|
||||||
├──────────────────────────────────────────────────────┤
|
├──────────────────────────────────────────────────────┤
|
||||||
│ 🎵 [Mini-player] ← bottom-left 🎭 [Mascot] ← BR │
|
│ 🎵 [Mini-player] ← bottom-left 🎭 [Chatbot] ← BR │
|
||||||
└──────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -196,9 +196,9 @@ Custom theme matching design tokens:
|
|||||||
- Heatmap: activity by hour × weekday
|
- Heatmap: activity by hour × weekday
|
||||||
- Radar: multi-axis for moderation categories
|
- Radar: multi-axis for moderation categories
|
||||||
|
|
||||||
### 4.5 Live2D Mascot / Chatbot
|
### 4.5 Live2D Chatbot / Chatbot
|
||||||
|
|
||||||
- Replaces the existing `Chatbot` component entirely — mascot panel is the new chat interface
|
- Replaces the existing `Chatbot` component entirely — chatbot panel is the new chat interface
|
||||||
- **Location:** Floating panel, bottom-right corner, draggable
|
- **Location:** Floating panel, bottom-right corner, draggable
|
||||||
- **Default size:** Compact — upper body visible (~200×280px)
|
- **Default size:** Compact — upper body visible (~200×280px)
|
||||||
- **Click character:** Expand with full chat panel
|
- **Click character:** Expand with full chat panel
|
||||||
@@ -232,7 +232,7 @@ Full-viewport command center:
|
|||||||
- **Message Trend Chart:** 7-day area chart
|
- **Message Trend Chart:** 7-day area chart
|
||||||
- **Activity Heatmap:** Hour × day-of-week, moderation event density
|
- **Activity Heatmap:** Hour × day-of-week, moderation event density
|
||||||
- **Top Channels:** Bar chart with channel names
|
- **Top Channels:** Bar chart with channel names
|
||||||
- **Mascot visible** floating bottom-right
|
- **Chatbot visible** floating bottom-right
|
||||||
|
|
||||||
### 5.2 Messages — Split Pane
|
### 5.2 Messages — Split Pane
|
||||||
|
|
||||||
@@ -334,8 +334,8 @@ Optimistic cache updates for real-time data
|
|||||||
### 7.4 Global State (React Context)
|
### 7.4 Global State (React Context)
|
||||||
|
|
||||||
- `useMediaPlayer()` — current track, queue, play/skip/stop/volume
|
- `useMediaPlayer()` — current track, queue, play/skip/stop/volume
|
||||||
- `useMascot()` — expression, minimized, chatHistory, setExpression
|
- `useChatbot()` — expression, minimized, chatHistory, setExpression
|
||||||
- Externally triggerable: `mascot.setExpression("surprise")` on flagged message, `("listening")` on voice activity
|
- Externally triggerable: `chatbot.setExpression("surprise")` on flagged message, `("listening")` on voice activity
|
||||||
|
|
||||||
### 7.5 URL State
|
### 7.5 URL State
|
||||||
|
|
||||||
@@ -359,7 +359,7 @@ Each page has its own error boundary. One page failure doesn't affect others.
|
|||||||
- **Icons:** lucide-react
|
- **Icons:** lucide-react
|
||||||
- **State/data:** @tanstack/react-query v5
|
- **State/data:** @tanstack/react-query v5
|
||||||
- **Charts:** Recharts 3.8 (with custom theme)
|
- **Charts:** Recharts 3.8 (with custom theme)
|
||||||
- **3D/Mascot:** Live2D Cubism SDK WebGL (pixi.js wrapper)
|
- **3D/Chatbot:** Live2D Cubism SDK WebGL (pixi.js wrapper)
|
||||||
- **Audio:** Web Audio API for waveform visualization
|
- **Audio:** Web Audio API for waveform visualization
|
||||||
- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed)
|
- **Animation:** CSS animations + transitions (no GSAP/framer-motion dependency unless specifically needed)
|
||||||
|
|
||||||
@@ -374,7 +374,7 @@ src/
|
|||||||
│ ├── page.tsx # Redirect → /dashboard
|
│ ├── page.tsx # Redirect → /dashboard
|
||||||
│ ├── globals.css # Complete redesign CSS (tokens, glass, animations)
|
│ ├── globals.css # Complete redesign CSS (tokens, glass, animations)
|
||||||
│ └── (dashboard)/
|
│ └── (dashboard)/
|
||||||
│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, mascot)
|
│ ├── layout.tsx # Dashboard layout (top nav, QueryClient, WS, chatbot)
|
||||||
│ ├── dashboard/
|
│ ├── dashboard/
|
||||||
│ │ └── page.tsx # Ops Center
|
│ │ └── page.tsx # Ops Center
|
||||||
│ ├── messages/
|
│ ├── messages/
|
||||||
@@ -424,11 +424,11 @@ src/
|
|||||||
│ │ ├── recording-card.tsx # Glass card with waveform preview
|
│ │ ├── recording-card.tsx # Glass card with waveform preview
|
||||||
│ │ └── recording-player.tsx # Inline audio player
|
│ │ └── recording-player.tsx # Inline audio player
|
||||||
│ │
|
│ │
|
||||||
│ ├── mascot/
|
│ ├── chatbot/
|
||||||
│ │ ├── mascot-container.tsx # Floating L2D container
|
│ │ ├── chatbot-container.tsx # Floating L2D container
|
||||||
│ │ ├── mascot-canvas.tsx # WebGL canvas for L2D rendering
|
│ │ ├── chatbot-canvas.tsx # WebGL canvas for L2D rendering
|
||||||
│ │ ├── chat-panel.tsx # Chat input + history
|
│ │ ├── chat-panel.tsx # Chat input + history
|
||||||
│ │ └── mascot-context.tsx # Context provider
|
│ │ └── chatbot-context.tsx # Context provider
|
||||||
│ │
|
│ │
|
||||||
│ ├── media/
|
│ ├── media/
|
||||||
│ │ └── mini-player.tsx # Floating mini media player
|
│ │ └── mini-player.tsx # Floating mini media player
|
||||||
@@ -448,7 +448,7 @@ src/
|
|||||||
│ │ └── types.ts # WS event types
|
│ │ └── types.ts # WS event types
|
||||||
│ ├── hooks/ # Existing hooks + new ones
|
│ ├── hooks/ # Existing hooks + new ones
|
||||||
│ │ ├── use-media-player.ts # Global media state
|
│ │ ├── use-media-player.ts # Global media state
|
||||||
│ │ ├── use-mascot.ts # Mascot context hook
|
│ │ ├── use-chatbot.ts # Chatbot context hook
|
||||||
│ │ └── use-heatmap.ts # Heatmap data hook
|
│ │ └── use-heatmap.ts # Heatmap data hook
|
||||||
│ ├── types/ # Existing types (unchanged)
|
│ ├── types/ # Existing types (unchanged)
|
||||||
│ ├── navigation.ts # Nav items (updated)
|
│ ├── navigation.ts # Nav items (updated)
|
||||||
@@ -486,7 +486,7 @@ src/
|
|||||||
|
|
||||||
### Phase 5 — Floating Elements
|
### Phase 5 — Floating Elements
|
||||||
18. Build `mini-player.tsx` for media
|
18. Build `mini-player.tsx` for media
|
||||||
19. Build mascot components (L2D integration)
|
19. Build chatbot components (L2D integration)
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -495,7 +495,7 @@ src/
|
|||||||
- Visual regression checks per component
|
- Visual regression checks per component
|
||||||
- WS integration tests for cache updates
|
- WS integration tests for cache updates
|
||||||
- Responsive breakpoint testing (mobile bottom nav)
|
- Responsive breakpoint testing (mobile bottom nav)
|
||||||
- L2D mascot load + expression trigger
|
- L2D chatbot load + expression trigger
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -502,30 +502,30 @@ export const pgRetentionPoliciesTable = pgTable(
|
|||||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Mascot Chat Messages Table (PostgreSQL)
|
* Chatbot Messages Table (PostgreSQL)
|
||||||
* Stores AI mascot chat conversation history
|
* Stores AI chat conversation history
|
||||||
*/
|
*/
|
||||||
export const pgMascotChatMessagesTable = pgTable(
|
export const pgChatbotMessagesTable = pgTable(
|
||||||
"mascot_chat_messages",
|
"chatbot_messages",
|
||||||
{
|
{
|
||||||
id: pgUuid("id").defaultRandom().primaryKey(),
|
id: pgUuid("id").defaultRandom().primaryKey(),
|
||||||
user_id: pgText("user_id").notNull(),
|
user_id: pgText("user_id").notNull(),
|
||||||
user_message: pgText("user_message").notNull(),
|
user_message: pgText("user_message").notNull(),
|
||||||
mascot_response: pgText("mascot_response").notNull(),
|
bot_response: pgText("bot_response").notNull(),
|
||||||
context: pgJsonb("context").notNull().default("{}"),
|
context: pgJsonb("context").notNull().default("{}"),
|
||||||
created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" })
|
created_at: pgTimestamp("created_at", { withTimezone: true, mode: "date" })
|
||||||
.notNull()
|
.notNull()
|
||||||
.defaultNow(),
|
.defaultNow(),
|
||||||
},
|
},
|
||||||
(table) => ({
|
(table) => ({
|
||||||
userCreatedIdx: pgIndex("idx_mascot_chat_messages_user_created").on(
|
userCreatedIdx: pgIndex("idx_chatbot_messages_user_created").on(
|
||||||
table.user_id,
|
table.user_id,
|
||||||
table.created_at.desc(),
|
table.created_at.desc(),
|
||||||
),
|
),
|
||||||
}),
|
}),
|
||||||
);
|
);
|
||||||
|
|
||||||
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
|
export const chatbotMessagesTable = pgChatbotMessagesTable;
|
||||||
|
|
||||||
// =============================================================================
|
// =============================================================================
|
||||||
// Type Exports
|
// Type Exports
|
||||||
@@ -590,7 +590,7 @@ export type DbRetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
|||||||
export type DbRetentionPolicyInsert =
|
export type DbRetentionPolicyInsert =
|
||||||
typeof retentionPoliciesTable.$inferInsert;
|
typeof retentionPoliciesTable.$inferInsert;
|
||||||
|
|
||||||
// Mascot Chat Messages
|
// Chatbot Messages
|
||||||
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
|
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||||
export type MascotChatMessageInsert =
|
export type ChatbotMessageInsert =
|
||||||
typeof mascotChatMessagesTable.$inferInsert;
|
typeof chatbotMessagesTable.$inferInsert;
|
||||||
|
|||||||
@@ -10,7 +10,7 @@ import { createAnalysisRouter } from "../modules/analysis/index.js";
|
|||||||
import { createConfigRouter } from "../modules/config/index.js";
|
import { createConfigRouter } from "../modules/config/index.js";
|
||||||
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
import { createDashboardRouter } from "../modules/dashboard/index.js";
|
||||||
import { createHealthRouter } from "../modules/health/index.js";
|
import { createHealthRouter } from "../modules/health/index.js";
|
||||||
import { createMascotChatRouter } from "../modules/mascot-chat/index.js";
|
import { createChatbotRouter } from "../modules/chatbot/index.js";
|
||||||
import { createMediaRouter } from "../modules/media/index.js";
|
import { createMediaRouter } from "../modules/media/index.js";
|
||||||
import { createMessagesRouter } from "../modules/messages/index.js";
|
import { createMessagesRouter } from "../modules/messages/index.js";
|
||||||
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
import { createRecordingsRouter } from "../modules/recordings/index.js";
|
||||||
@@ -64,7 +64,7 @@ export function createHttpApp(): Express {
|
|||||||
app.use("/api", createDashboardRouter());
|
app.use("/api", createDashboardRouter());
|
||||||
app.use("/api", createMessagesRouter());
|
app.use("/api", createMessagesRouter());
|
||||||
app.use("/api", createAnalysisRouter());
|
app.use("/api", createAnalysisRouter());
|
||||||
app.use("/api", createMascotChatRouter());
|
app.use("/api", createChatbotRouter());
|
||||||
app.use("/api", createRecordingsRouter());
|
app.use("/api", createRecordingsRouter());
|
||||||
app.use("/api", createUiStateRouter());
|
app.use("/api", createUiStateRouter());
|
||||||
app.use("/api", createMediaRouter());
|
app.use("/api", createMediaRouter());
|
||||||
|
|||||||
+12
-12
@@ -1,15 +1,15 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import type { Request, Response } from "express";
|
import type { Request, Response } from "express";
|
||||||
import { asyncHandler } from "../../shared/middlewares/index.js";
|
import { asyncHandler } from "../../shared/middlewares/index.js";
|
||||||
import { mascotChatService } from "./mascot-chat.service.js";
|
import { chatbotService } from "./chatbot.service.js";
|
||||||
|
|
||||||
const logger = createChildLogger("mascot-chat.controller");
|
const logger = createChildLogger("chatbot.controller");
|
||||||
|
|
||||||
interface AuthenticatedRequest extends Request {
|
interface AuthenticatedRequest extends Request {
|
||||||
userId?: string;
|
userId?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const handleMascotChat = asyncHandler(
|
export const handleChatbotChat = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const { message, context } = req.body as {
|
const { message, context } = req.body as {
|
||||||
message: string;
|
message: string;
|
||||||
@@ -29,26 +29,26 @@ export const handleMascotChat = asyncHandler(
|
|||||||
|
|
||||||
logger.debug(
|
logger.debug(
|
||||||
{ userId, messageLength: message.length, context },
|
{ userId, messageLength: message.length, context },
|
||||||
"Received mascot chat message",
|
"Received chatbot chat message",
|
||||||
);
|
);
|
||||||
|
|
||||||
// Process message & generate response
|
// Process message & generate response
|
||||||
const response = await mascotChatService.processMessage(
|
const response = await chatbotService.processMessage(
|
||||||
message,
|
message,
|
||||||
context,
|
context,
|
||||||
userId,
|
userId,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Save conversation to database
|
// Save conversation to database
|
||||||
await mascotChatService.saveConversation({
|
await chatbotService.saveConversation({
|
||||||
userId,
|
userId,
|
||||||
userMessage: message,
|
userMessage: message,
|
||||||
mascotResponse: response,
|
botResponse: response,
|
||||||
context,
|
context,
|
||||||
timestamp: new Date(),
|
timestamp: new Date(),
|
||||||
});
|
});
|
||||||
|
|
||||||
logger.info({ userId }, "Mascot chat processed successfully");
|
logger.info({ userId }, "Chatbot chat processed successfully");
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
response,
|
response,
|
||||||
@@ -57,12 +57,12 @@ export const handleMascotChat = asyncHandler(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const getMascotChatHistory = asyncHandler(
|
export const getChatbotHistory = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||||
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
const limit = Math.min(parseInt(req.query.limit as string, 10) || 50, 100);
|
||||||
|
|
||||||
const history = await mascotChatService.getChatHistory(userId, limit);
|
const history = await chatbotService.getChatHistory(userId, limit);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
history,
|
history,
|
||||||
@@ -71,11 +71,11 @@ export const getMascotChatHistory = asyncHandler(
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
export const clearMascotChatHistory = asyncHandler(
|
export const clearChatbotHistory = asyncHandler(
|
||||||
async (req: Request, res: Response) => {
|
async (req: Request, res: Response) => {
|
||||||
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
const userId = (req as AuthenticatedRequest).userId || "anonymous";
|
||||||
|
|
||||||
await mascotChatService.clearChatHistory(userId);
|
await chatbotService.clearChatHistory(userId);
|
||||||
|
|
||||||
res.status(200).json({
|
res.status(200).json({
|
||||||
message: "Chat history cleared successfully",
|
message: "Chat history cleared successfully",
|
||||||
+20
-20
@@ -1,11 +1,11 @@
|
|||||||
import { pgMascotChatMessagesTable, pgMessagesTable } from "@bete/shared";
|
import { pgChatbotMessagesTable, pgMessagesTable } from "@bete/shared";
|
||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
import { and, desc, eq, type SQL, sql } from "drizzle-orm";
|
||||||
import { getDatabase } from "../../shared/database/index.js";
|
import { getDatabase } from "../../shared/database/index.js";
|
||||||
|
|
||||||
const logger = createChildLogger("mascot-chat.repository");
|
const logger = createChildLogger("chatbot.repository");
|
||||||
|
|
||||||
export interface MascotChatContext {
|
export interface ChatbotContext {
|
||||||
messageCount?: number;
|
messageCount?: number;
|
||||||
activeParticipants?: number;
|
activeParticipants?: number;
|
||||||
lastActivity?: string;
|
lastActivity?: string;
|
||||||
@@ -17,17 +17,17 @@ export interface MascotChatContext {
|
|||||||
export interface SaveConversationInput {
|
export interface SaveConversationInput {
|
||||||
userId: string;
|
userId: string;
|
||||||
userMessage: string;
|
userMessage: string;
|
||||||
mascotResponse: string;
|
botResponse: string;
|
||||||
context?: MascotChatContext;
|
context?: ChatbotContext;
|
||||||
timestamp: Date;
|
timestamp: Date;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface MascotChatHistoryRow {
|
export interface ChatbotHistoryRow {
|
||||||
id: string;
|
id: string;
|
||||||
user_id: string;
|
user_id: string;
|
||||||
user_message: string;
|
user_message: string;
|
||||||
mascot_response: string;
|
bot_response: string;
|
||||||
context: MascotChatContext | null;
|
context: ChatbotContext | null;
|
||||||
created_at: string;
|
created_at: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -38,14 +38,14 @@ export interface ServerInsights {
|
|||||||
warned: number;
|
warned: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class MascotChatRepository {
|
export class ChatbotRepository {
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
await db.insert(pgMascotChatMessagesTable).values({
|
await db.insert(pgChatbotMessagesTable).values({
|
||||||
user_id: input.userId,
|
user_id: input.userId,
|
||||||
user_message: input.userMessage,
|
user_message: input.userMessage,
|
||||||
mascot_response: input.mascotResponse,
|
bot_response: input.botResponse,
|
||||||
context: (input.context ?? {}) as Record<string, unknown>,
|
context: (input.context ?? {}) as Record<string, unknown>,
|
||||||
created_at: input.timestamp,
|
created_at: input.timestamp,
|
||||||
});
|
});
|
||||||
@@ -56,27 +56,27 @@ export class MascotChatRepository {
|
|||||||
async getChatHistory(
|
async getChatHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<MascotChatHistoryRow[]> {
|
): Promise<ChatbotHistoryRow[]> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
const rows = await db
|
const rows = await db
|
||||||
.select()
|
.select()
|
||||||
.from(pgMascotChatMessagesTable)
|
.from(pgChatbotMessagesTable)
|
||||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
.where(eq(pgChatbotMessagesTable.user_id, userId))
|
||||||
.orderBy(desc(pgMascotChatMessagesTable.created_at))
|
.orderBy(desc(pgChatbotMessagesTable.created_at))
|
||||||
.limit(limit);
|
.limit(limit);
|
||||||
|
|
||||||
logger.debug({ userId, count: rows.length }, "Chat history fetched");
|
logger.debug({ userId, count: rows.length }, "Chat history fetched");
|
||||||
return rows.reverse() as unknown as MascotChatHistoryRow[];
|
return rows.reverse() as unknown as ChatbotHistoryRow[];
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearChatHistory(userId: string): Promise<void> {
|
async clearChatHistory(userId: string): Promise<void> {
|
||||||
const db = getDatabase();
|
const db = getDatabase();
|
||||||
|
|
||||||
const deleted = await db
|
const deleted = await db
|
||||||
.delete(pgMascotChatMessagesTable)
|
.delete(pgChatbotMessagesTable)
|
||||||
.where(eq(pgMascotChatMessagesTable.user_id, userId))
|
.where(eq(pgChatbotMessagesTable.user_id, userId))
|
||||||
.returning({ id: pgMascotChatMessagesTable.id });
|
.returning({ id: pgChatbotMessagesTable.id });
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
{ userId, deletedRows: deleted.length },
|
{ userId, deletedRows: deleted.length },
|
||||||
@@ -135,4 +135,4 @@ export class MascotChatRepository {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mascotChatRepository = new MascotChatRepository();
|
export const chatbotRepository = new ChatbotRepository();
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
import express, { type Router } from "express";
|
||||||
|
import { validateBody } from "../../shared/middlewares/index.js";
|
||||||
|
import {
|
||||||
|
clearChatbotHistory,
|
||||||
|
getChatbotHistory,
|
||||||
|
handleChatbotChat,
|
||||||
|
} from "./chatbot.controller.js";
|
||||||
|
import { chatRequestSchema } from "./chatbot.schema.js";
|
||||||
|
|
||||||
|
export function createChatbotRouter(): Router {
|
||||||
|
const router = express.Router();
|
||||||
|
|
||||||
|
router.post(
|
||||||
|
"/chat",
|
||||||
|
validateBody(chatRequestSchema),
|
||||||
|
handleChatbotChat,
|
||||||
|
);
|
||||||
|
router.get("/chat/history", getChatbotHistory);
|
||||||
|
router.delete("/chat/history", clearChatbotHistory);
|
||||||
|
|
||||||
|
return router;
|
||||||
|
}
|
||||||
+17
-17
@@ -1,18 +1,18 @@
|
|||||||
import { createChildLogger } from "@bete/shared/logger";
|
import { createChildLogger } from "@bete/shared/logger";
|
||||||
import { config } from "../../shared/config/index.js";
|
import { config } from "../../shared/config/index.js";
|
||||||
import type {
|
import type {
|
||||||
MascotChatContext,
|
ChatbotContext,
|
||||||
MascotChatHistoryRow,
|
ChatbotHistoryRow,
|
||||||
SaveConversationInput,
|
SaveConversationInput,
|
||||||
} from "./mascot-chat.repository.js";
|
} from "./chatbot.repository.js";
|
||||||
import { mascotChatRepository } from "./mascot-chat.repository.js";
|
import { chatbotRepository } from "./chatbot.repository.js";
|
||||||
|
|
||||||
const logger = createChildLogger("mascot-chat.service");
|
const logger = createChildLogger("chatbot.service");
|
||||||
|
|
||||||
class MascotChatService {
|
class ChatbotService {
|
||||||
async processMessage(
|
async processMessage(
|
||||||
message: string,
|
message: string,
|
||||||
context: MascotChatContext | undefined,
|
context: ChatbotContext | undefined,
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
logger.info(
|
logger.info(
|
||||||
@@ -20,7 +20,7 @@ class MascotChatService {
|
|||||||
"processMessage called",
|
"processMessage called",
|
||||||
);
|
);
|
||||||
const recentContext = await this.getRecentConversationContext(userId);
|
const recentContext = await this.getRecentConversationContext(userId);
|
||||||
const serverInsights = await mascotChatRepository.getServerInsights(
|
const serverInsights = await chatbotRepository.getServerInsights(
|
||||||
context?.guildId,
|
context?.guildId,
|
||||||
context?.channelId,
|
context?.channelId,
|
||||||
);
|
);
|
||||||
@@ -39,29 +39,29 @@ class MascotChatService {
|
|||||||
|
|
||||||
async saveConversation(input: SaveConversationInput): Promise<void> {
|
async saveConversation(input: SaveConversationInput): Promise<void> {
|
||||||
logger.info({ userId: input.userId }, "saveConversation called");
|
logger.info({ userId: input.userId }, "saveConversation called");
|
||||||
await mascotChatRepository.saveConversation(input);
|
await chatbotRepository.saveConversation(input);
|
||||||
}
|
}
|
||||||
|
|
||||||
async getChatHistory(
|
async getChatHistory(
|
||||||
userId: string,
|
userId: string,
|
||||||
limit: number,
|
limit: number,
|
||||||
): Promise<MascotChatHistoryRow[]> {
|
): Promise<ChatbotHistoryRow[]> {
|
||||||
logger.debug({ userId, limit }, "getChatHistory called");
|
logger.debug({ userId, limit }, "getChatHistory called");
|
||||||
return mascotChatRepository.getChatHistory(userId, limit);
|
return chatbotRepository.getChatHistory(userId, limit);
|
||||||
}
|
}
|
||||||
|
|
||||||
async clearChatHistory(userId: string): Promise<void> {
|
async clearChatHistory(userId: string): Promise<void> {
|
||||||
logger.info({ userId }, "clearChatHistory called");
|
logger.info({ userId }, "clearChatHistory called");
|
||||||
await mascotChatRepository.clearChatHistory(userId);
|
await chatbotRepository.clearChatHistory(userId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getRecentConversationContext(
|
private async getRecentConversationContext(
|
||||||
userId: string,
|
userId: string,
|
||||||
): Promise<string[]> {
|
): Promise<string[]> {
|
||||||
const history = await mascotChatRepository.getChatHistory(userId, 3);
|
const history = await chatbotRepository.getChatHistory(userId, 3);
|
||||||
return history.flatMap((row) => [
|
return history.flatMap((row) => [
|
||||||
`User: ${row.user_message}`,
|
`User: ${row.user_message}`,
|
||||||
`Mascot: ${row.mascot_response}`,
|
`Bot: ${row.bot_response}`,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -71,7 +71,7 @@ class MascotChatService {
|
|||||||
flagged: number;
|
flagged: number;
|
||||||
warned: number;
|
warned: number;
|
||||||
}): string {
|
}): string {
|
||||||
return `Kamu lagi ngobrol sama mascot Discord Watcher — temen ngobrol yang tau keadaan server.
|
return `Kamu lagi ngobrol sama chatbot Discord Watcher — temen ngobrol yang tau keadaan server.
|
||||||
|
|
||||||
Data server saat ini:
|
Data server saat ini:
|
||||||
- Pesan: ${insights.total_messages}
|
- Pesan: ${insights.total_messages}
|
||||||
@@ -92,7 +92,7 @@ Gaya ngobrol:
|
|||||||
private buildHistoryMessages(
|
private buildHistoryMessages(
|
||||||
recentContext: string[],
|
recentContext: string[],
|
||||||
): Array<{ role: "user" | "assistant"; content: string }> {
|
): Array<{ role: "user" | "assistant"; content: string }> {
|
||||||
// recentContext is alternating User/Mascot messages
|
// recentContext is alternating User/Bot messages
|
||||||
return recentContext.map((text) => {
|
return recentContext.map((text) => {
|
||||||
if (text.startsWith("User: ")) {
|
if (text.startsWith("User: ")) {
|
||||||
return { role: "user" as const, content: text.slice(6) };
|
return { role: "user" as const, content: text.slice(6) };
|
||||||
@@ -178,4 +178,4 @@ Gaya ngobrol:
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export const mascotChatService = new MascotChatService();
|
export const chatbotService = new ChatbotService();
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
export { createChatbotRouter } from "./chatbot.routes.js";
|
||||||
@@ -1 +0,0 @@
|
|||||||
export { createMascotChatRouter } from "./mascot-chat.routes.js";
|
|
||||||
@@ -1,22 +0,0 @@
|
|||||||
import express, { type Router } from "express";
|
|
||||||
import { validateBody } from "../../shared/middlewares/index.js";
|
|
||||||
import {
|
|
||||||
clearMascotChatHistory,
|
|
||||||
getMascotChatHistory,
|
|
||||||
handleMascotChat,
|
|
||||||
} from "./mascot-chat.controller.js";
|
|
||||||
import { chatRequestSchema } from "./mascot-chat.schema.js";
|
|
||||||
|
|
||||||
export function createMascotChatRouter(): Router {
|
|
||||||
const router = express.Router();
|
|
||||||
|
|
||||||
router.post(
|
|
||||||
"/mascot/chat",
|
|
||||||
validateBody(chatRequestSchema),
|
|
||||||
handleMascotChat,
|
|
||||||
);
|
|
||||||
router.get("/mascot/chat/history", getMascotChatHistory);
|
|
||||||
router.delete("/mascot/chat/history", clearMascotChatHistory);
|
|
||||||
|
|
||||||
return router;
|
|
||||||
}
|
|
||||||
@@ -1,5 +1,5 @@
|
|||||||
import {
|
import {
|
||||||
pgMascotChatMessagesTable,
|
pgChatbotMessagesTable,
|
||||||
pgMuxerJobsTable,
|
pgMuxerJobsTable,
|
||||||
pgRetentionPoliciesTable,
|
pgRetentionPoliciesTable,
|
||||||
pgUIStateTable,
|
pgUIStateTable,
|
||||||
@@ -7,7 +7,7 @@ import {
|
|||||||
|
|
||||||
// Re-export shared tables
|
// Re-export shared tables
|
||||||
export {
|
export {
|
||||||
pgMascotChatMessagesTable,
|
pgChatbotMessagesTable,
|
||||||
pgMuxerJobsTable,
|
pgMuxerJobsTable,
|
||||||
pgRetentionPoliciesTable,
|
pgRetentionPoliciesTable,
|
||||||
pgUIStateTable,
|
pgUIStateTable,
|
||||||
@@ -15,7 +15,7 @@ export {
|
|||||||
export const muxerJobsTable = pgMuxerJobsTable;
|
export const muxerJobsTable = pgMuxerJobsTable;
|
||||||
export const uiStateTable = pgUIStateTable;
|
export const uiStateTable = pgUIStateTable;
|
||||||
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
export const retentionPoliciesTable = pgRetentionPoliciesTable;
|
||||||
export const mascotChatMessagesTable = pgMascotChatMessagesTable;
|
export const chatbotMessagesTable = pgChatbotMessagesTable;
|
||||||
|
|
||||||
// Types
|
// Types
|
||||||
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
export type MuxerJob = typeof muxerJobsTable.$inferSelect;
|
||||||
@@ -24,6 +24,6 @@ export type UIState = typeof uiStateTable.$inferSelect;
|
|||||||
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
export type UIStateInsert = typeof uiStateTable.$inferInsert;
|
||||||
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
export type RetentionPolicy = typeof retentionPoliciesTable.$inferSelect;
|
||||||
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
export type RetentionPolicyInsert = typeof retentionPoliciesTable.$inferInsert;
|
||||||
export type MascotChatMessage = typeof mascotChatMessagesTable.$inferSelect;
|
export type ChatbotMessage = typeof chatbotMessagesTable.$inferSelect;
|
||||||
export type MascotChatMessageInsert =
|
export type ChatbotMessageInsert =
|
||||||
typeof mascotChatMessagesTable.$inferInsert;
|
typeof chatbotMessagesTable.$inferInsert;
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ src/
|
|||||||
│ ├── messages/ # Message feed, search, review, detail modal
|
│ ├── messages/ # Message feed, search, review, detail modal
|
||||||
│ ├── live/ # Voice connection, music player, recordings
|
│ ├── live/ # Voice connection, music player, recordings
|
||||||
│ ├── dashboard/ # Stats, users, channels overview
|
│ ├── dashboard/ # Stats, users, channels overview
|
||||||
│ └── mascot/ # AI chatbot
|
│ └── chatbot/ # AI chatbot
|
||||||
├── lib/
|
├── lib/
|
||||||
│ ├── api/ # Fetch-based API client (all BE endpoints)
|
│ ├── api/ # Fetch-based API client (all BE endpoints)
|
||||||
│ ├── ws/ # WebSocket client + React context
|
│ ├── ws/ # WebSocket client + React context
|
||||||
|
|||||||
@@ -5,8 +5,8 @@ import { Suspense, useEffect, useState } from "react";
|
|||||||
import { TopNav } from "@/components/layout/top-nav";
|
import { TopNav } from "@/components/layout/top-nav";
|
||||||
import { MobileNav } from "@/components/layout/mobile-nav";
|
import { MobileNav } from "@/components/layout/mobile-nav";
|
||||||
import { WsProvider, useWebSocket } from "@/lib/ws/context";
|
import { WsProvider, useWebSocket } from "@/lib/ws/context";
|
||||||
import { MascotProvider, useMascot } from "@/components/mascot/mascot-context";
|
import { ChatbotProvider, useChatbot } from "@/components/chatbot/chatbot-context";
|
||||||
import { MascotContainer } from "@/components/mascot/mascot-container";
|
import { ChatbotContainer } from "@/components/chatbot/chatbot-container";
|
||||||
import { MiniPlayer } from "@/components/media/mini-player";
|
import { MiniPlayer } from "@/components/media/mini-player";
|
||||||
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
import { MediaPlayerProvider } from "@/lib/hooks/use-media-player";
|
||||||
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
import { HiddenSidebar } from "@/components/layout/hidden-sidebar";
|
||||||
@@ -21,9 +21,9 @@ const queryClient = new QueryClient({
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
function MascotExpressionSync() {
|
function ChatbotExpressionSync() {
|
||||||
const ws = useWebSocket();
|
const ws = useWebSocket();
|
||||||
const { setExpression } = useMascot();
|
const { setExpression } = useChatbot();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const unsub1 = ws.on("message_created", (data: any) => {
|
const unsub1 = ws.on("message_created", (data: any) => {
|
||||||
@@ -57,8 +57,8 @@ export default function DashboardLayout({
|
|||||||
<QueryClientProvider client={queryClient}>
|
<QueryClientProvider client={queryClient}>
|
||||||
<WsProvider>
|
<WsProvider>
|
||||||
<MediaPlayerProvider>
|
<MediaPlayerProvider>
|
||||||
<MascotProvider>
|
<ChatbotProvider>
|
||||||
<MascotExpressionSync />
|
<ChatbotExpressionSync />
|
||||||
<div className="min-h-screen bg-canvas">
|
<div className="min-h-screen bg-canvas">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
<HiddenSidebar guildId={guildId} onGuildChange={(g) => setGuildId(g ?? "")} />
|
||||||
@@ -80,9 +80,9 @@ export default function DashboardLayout({
|
|||||||
|
|
||||||
<MobileNav />
|
<MobileNav />
|
||||||
<MiniPlayer />
|
<MiniPlayer />
|
||||||
<MascotContainer />
|
<ChatbotContainer />
|
||||||
</div>
|
</div>
|
||||||
</MascotProvider>
|
</ChatbotProvider>
|
||||||
</MediaPlayerProvider>
|
</MediaPlayerProvider>
|
||||||
</WsProvider>
|
</WsProvider>
|
||||||
</QueryClientProvider>
|
</QueryClientProvider>
|
||||||
|
|||||||
+4
-4
@@ -2,14 +2,14 @@
|
|||||||
|
|
||||||
import { useRef, useEffect } from "react";
|
import { useRef, useEffect } from "react";
|
||||||
import { Send } from "lucide-react";
|
import { Send } from "lucide-react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
|
|
||||||
interface ChatPanelProps {
|
interface ChatPanelProps {
|
||||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||||
const { messages, sendMessage, isTyping } = useMascot();
|
const { messages, sendMessage, isTyping } = useChatbot();
|
||||||
const listRef = useRef<HTMLDivElement>(null);
|
const listRef = useRef<HTMLDivElement>(null);
|
||||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||||
const inputRef = externalInputRef ?? internalInputRef;
|
const inputRef = externalInputRef ?? internalInputRef;
|
||||||
@@ -35,7 +35,7 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
|||||||
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
||||||
{messages.length === 0 && (
|
{messages.length === 0 && (
|
||||||
<div className="flex items-center justify-center h-full">
|
<div className="flex items-center justify-center h-full">
|
||||||
<p className="text-[10px] text-text-secondary/40">Ask mascot anything</p>
|
<p className="text-[10px] text-text-secondary/40">Ask chatbot anything</p>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{messages.slice(-8).map((msg, i) => (
|
{messages.slice(-8).map((msg, i) => (
|
||||||
@@ -72,7 +72,7 @@ export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
|||||||
<input
|
<input
|
||||||
ref={inputRef}
|
ref={inputRef}
|
||||||
type="text"
|
type="text"
|
||||||
placeholder="Ask mascot..."
|
placeholder="Ask chatbot..."
|
||||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||||
disabled={isTyping}
|
disabled={isTyping}
|
||||||
/>
|
/>
|
||||||
+4
-4
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef } from "react";
|
import { useEffect, useRef } from "react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Live2D Cubism WebGL canvas.
|
* Live2D Cubism WebGL canvas.
|
||||||
@@ -10,15 +10,15 @@ import { useMascot } from "./mascot-context";
|
|||||||
* Integration requires:
|
* Integration requires:
|
||||||
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||||
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||||
* 3. Place model files in public/mascot/
|
* 3. Place model files in public/chatbot/
|
||||||
*
|
*
|
||||||
* The current implementation shows a placeholder character.
|
* The current implementation shows a placeholder character.
|
||||||
* Replace with actual Cubism SDK integration when model files are available.
|
* Replace with actual Cubism SDK integration when model files are available.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
export function MascotCanvas() {
|
export function ChatbotCanvas() {
|
||||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||||
const { expression } = useMascot();
|
const { expression } = useChatbot();
|
||||||
|
|
||||||
// Placeholder: draw a simple avatar face that responds to expression
|
// Placeholder: draw a simple avatar face that responds to expression
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
+9
-9
@@ -2,12 +2,12 @@
|
|||||||
|
|
||||||
import { useRef, useState, useCallback, useEffect } from "react";
|
import { useRef, useState, useCallback, useEffect } from "react";
|
||||||
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
|
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
|
||||||
import { useMascot } from "./mascot-context";
|
import { useChatbot } from "./chatbot-context";
|
||||||
import { MascotCanvas } from "./mascot-canvas";
|
import { ChatbotCanvas } from "./chatbot-canvas";
|
||||||
import { ChatPanel } from "./chat-panel";
|
import { ChatPanel } from "./chat-panel";
|
||||||
|
|
||||||
export function MascotContainer() {
|
export function ChatbotContainer() {
|
||||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useMascot();
|
const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
|
||||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||||
const [dragging, setDragging] = useState(false);
|
const [dragging, setDragging] = useState(false);
|
||||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||||
@@ -42,7 +42,7 @@ export function MascotContainer() {
|
|||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp}
|
||||||
onMouseLeave={handleMouseUp}
|
onMouseLeave={handleMouseUp}
|
||||||
>
|
>
|
||||||
{/* Main mascot bubble */}
|
{/* Main chatbot bubble */}
|
||||||
<div
|
<div
|
||||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||||
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
|
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
|
||||||
@@ -55,7 +55,7 @@ export function MascotContainer() {
|
|||||||
onClick={() => setMinimized(false)}
|
onClick={() => setMinimized(false)}
|
||||||
className="w-full h-full flex items-center justify-center"
|
className="w-full h-full flex items-center justify-center"
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
aria-label="Open mascot"
|
aria-label="Open chatbot"
|
||||||
>
|
>
|
||||||
<Bot className="size-6 text-primary" />
|
<Bot className="size-6 text-primary" />
|
||||||
</button>
|
</button>
|
||||||
@@ -67,7 +67,7 @@ export function MascotContainer() {
|
|||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown}
|
||||||
>
|
>
|
||||||
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
|
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
|
||||||
Mascot
|
Chatbot
|
||||||
</span>
|
</span>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -82,7 +82,7 @@ export function MascotContainer() {
|
|||||||
type="button"
|
type="button"
|
||||||
onClick={() => setMinimized(true)}
|
onClick={() => setMinimized(true)}
|
||||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||||
aria-label="Minimize mascot"
|
aria-label="Minimize chatbot"
|
||||||
>
|
>
|
||||||
{minimized ? (
|
{minimized ? (
|
||||||
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||||
@@ -95,7 +95,7 @@ export function MascotContainer() {
|
|||||||
|
|
||||||
{/* Canvas area */}
|
{/* Canvas area */}
|
||||||
<div className="h-[140px] flex items-center justify-center">
|
<div className="h-[140px] flex items-center justify-center">
|
||||||
<MascotCanvas />
|
<ChatbotCanvas />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Chat panel (expandable) */}
|
{/* Chat panel (expandable) */}
|
||||||
+19
-19
@@ -12,18 +12,18 @@ import {
|
|||||||
import { chatbotApi } from "@/lib/api";
|
import { chatbotApi } from "@/lib/api";
|
||||||
import type { ChatHistoryMessage } from "@/lib/types";
|
import type { ChatHistoryMessage } from "@/lib/types";
|
||||||
|
|
||||||
export type MascotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
export type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
||||||
|
|
||||||
interface MascotMessage {
|
interface ChatbotMessage {
|
||||||
role: "user" | "assistant";
|
role: "user" | "assistant";
|
||||||
content: string;
|
content: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface MascotContextValue {
|
interface ChatbotContextValue {
|
||||||
/** Expression the mascot avatar should display */
|
/** Expression the chatbot avatar should display */
|
||||||
expression: MascotExpression;
|
expression: ChatbotExpression;
|
||||||
setExpression: (expr: MascotExpression) => void;
|
setExpression: (expr: ChatbotExpression) => void;
|
||||||
|
|
||||||
/** Whether the enlarged bubble is minimized to a small icon */
|
/** Whether the enlarged bubble is minimized to a small icon */
|
||||||
minimized: boolean;
|
minimized: boolean;
|
||||||
@@ -42,19 +42,19 @@ interface MascotContextValue {
|
|||||||
toggle: () => void;
|
toggle: () => void;
|
||||||
|
|
||||||
/** Chat messages with real API backend */
|
/** Chat messages with real API backend */
|
||||||
messages: MascotMessage[];
|
messages: ChatbotMessage[];
|
||||||
sendMessage: (content: string) => Promise<void>;
|
sendMessage: (content: string) => Promise<void>;
|
||||||
clearMessages: () => Promise<void>;
|
clearMessages: () => Promise<void>;
|
||||||
isTyping: boolean;
|
isTyping: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MascotContext = createContext<MascotContextValue | null>(null);
|
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||||
|
|
||||||
export function MascotProvider({ children }: { children: ReactNode }) {
|
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||||
const [expression, setExpression] = useState<MascotExpression>("idle");
|
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||||
const [minimized, setMinimized] = useState(true);
|
const [minimized, setMinimized] = useState(true);
|
||||||
const [chatOpen, setChatOpen] = useState(false);
|
const [chatOpen, setChatOpen] = useState(false);
|
||||||
const [messages, setMessages] = useState<MascotMessage[]>([]);
|
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||||
const [isTyping, setIsTyping] = useState(false);
|
const [isTyping, setIsTyping] = useState(false);
|
||||||
const historyFetched = useRef(false);
|
const historyFetched = useRef(false);
|
||||||
|
|
||||||
@@ -89,7 +89,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
const sendMessage = useCallback(async (content: string) => {
|
const sendMessage = useCallback(async (content: string) => {
|
||||||
if (!content.trim()) return;
|
if (!content.trim()) return;
|
||||||
|
|
||||||
const userMsg: MascotMessage = {
|
const userMsg: ChatbotMessage = {
|
||||||
role: "user",
|
role: "user",
|
||||||
content: content.trim(),
|
content: content.trim(),
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -100,7 +100,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const res = await chatbotApi.send(content.trim());
|
const res = await chatbotApi.send(content.trim());
|
||||||
const botMsg: MascotMessage = {
|
const botMsg: ChatbotMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: res.response,
|
content: res.response,
|
||||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||||
@@ -108,7 +108,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
setMessages((prev) => [...prev, botMsg]);
|
setMessages((prev) => [...prev, botMsg]);
|
||||||
setExpression("happy");
|
setExpression("happy");
|
||||||
} catch {
|
} catch {
|
||||||
const errorMsg: MascotMessage = {
|
const errorMsg: ChatbotMessage = {
|
||||||
role: "assistant",
|
role: "assistant",
|
||||||
content: "Sorry, I couldn't process that request. Please try again.",
|
content: "Sorry, I couldn't process that request. Please try again.",
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
@@ -130,7 +130,7 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<MascotContext.Provider
|
<ChatbotContext.Provider
|
||||||
value={{
|
value={{
|
||||||
expression,
|
expression,
|
||||||
setExpression,
|
setExpression,
|
||||||
@@ -148,14 +148,14 @@ export function MascotProvider({ children }: { children: ReactNode }) {
|
|||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{children}
|
{children}
|
||||||
</MascotContext.Provider>
|
</ChatbotContext.Provider>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function useMascot(): MascotContextValue {
|
export function useChatbot(): ChatbotContextValue {
|
||||||
const ctx = useContext(MascotContext);
|
const ctx = useContext(ChatbotContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
throw new Error("useMascot must be used within a MascotProvider");
|
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||||
}
|
}
|
||||||
return ctx;
|
return ctx;
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
export { ChatbotProvider, useChatbot } from "./chatbot-context";
|
||||||
|
export { ChatbotContainer } from "./chatbot-container";
|
||||||
|
export { ChatbotCanvas } from "./chatbot-canvas";
|
||||||
|
export { ChatPanel } from "./chat-panel";
|
||||||
@@ -1,93 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Clock, Hash, Sparkles } from "lucide-react";
|
|
||||||
|
|
||||||
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { useChannelDetail } from "@/hooks";
|
|
||||||
|
|
||||||
export function ChannelDetailSection({
|
|
||||||
channelId,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
channelId: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const { data: channel, isLoading } = useChannelDetail(channelId);
|
|
||||||
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
|
|
||||||
if (!channel) return <ErrorState message="Channel not found." />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
|
||||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" /> Back
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 space-y-5">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold flex items-center gap-2">
|
|
||||||
<Hash className="size-5 text-muted-foreground" />
|
|
||||||
{channel.channel_name ?? channel.channel_id.slice(0, 8)}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
|
||||||
{channel.channel_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<DetailStat label="Messages" value={channel.total_messages} />
|
|
||||||
<DetailStat
|
|
||||||
label="Flagged"
|
|
||||||
value={channel.flagged_count}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<DetailStat
|
|
||||||
label="Clean"
|
|
||||||
value={channel.clean_count}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{channel.culture_summary && (
|
|
||||||
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
|
||||||
Channel Culture
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed italic">
|
|
||||||
“{channel.culture_summary}”
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{channel.recent_messages.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<Clock className="size-4 text-muted-foreground" /> Recent
|
|
||||||
Messages
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channel.recent_messages.slice(0, 5).map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-2 mb-1">
|
|
||||||
<span className="text-sm font-medium">
|
|
||||||
{msg.username}
|
|
||||||
</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(msg.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm">{msg.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronRight, Hash, Search } from "lucide-react";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useChannels } from "@/hooks";
|
|
||||||
|
|
||||||
export function ChannelsSection({
|
|
||||||
guildId,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
guildId: string;
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const {
|
|
||||||
data: channels,
|
|
||||||
isLoading,
|
|
||||||
refetch,
|
|
||||||
} = useChannels(guildId, search || undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search channels…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={6} height="h-20" />
|
|
||||||
) : !channels || channels.length === 0 ? (
|
|
||||||
<EmptyState icon={Hash} title="No channels found." />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{channels.map((ch) => (
|
|
||||||
<Card
|
|
||||||
key={ch.channel_id}
|
|
||||||
className="cursor-pointer hover:bg-accent/5 transition-colors"
|
|
||||||
onClick={() => onSelect(ch.channel_id)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-center justify-between">
|
|
||||||
<div className="min-w-0 flex-1">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<Hash className="size-3.5 text-muted-foreground shrink-0" />
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{ch.channel_name ?? ch.channel_id.slice(0, 8)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-xs text-muted-foreground mt-0.5">
|
|
||||||
{ch.total_messages} messages
|
|
||||||
{ch.flagged_count > 0
|
|
||||||
? ` · ${ch.flagged_count} flagged`
|
|
||||||
: ""}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="size-4 text-muted-foreground shrink-0 ml-2" />
|
|
||||||
</div>
|
|
||||||
{ch.culture_summary && (
|
|
||||||
<p className="text-xs text-muted-foreground/70 mt-2 italic line-clamp-2 border-t border-border/50 pt-2">
|
|
||||||
“{ch.culture_summary}”
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,5 +0,0 @@
|
|||||||
export { ChannelDetailSection } from "./channel-detail-section";
|
|
||||||
export { ChannelsSection } from "./channels-section";
|
|
||||||
export { StatsSection } from "./stats-section";
|
|
||||||
export { UserDetailSection } from "./user-detail-section";
|
|
||||||
export { UsersSection } from "./users-section";
|
|
||||||
@@ -1,145 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import {
|
|
||||||
AlertCircle,
|
|
||||||
Clock,
|
|
||||||
Hash,
|
|
||||||
Shield,
|
|
||||||
Sparkles,
|
|
||||||
Users,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
import { ErrorState, LoadingSkeleton, StatCard } from "@/components/shared";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import { Progress } from "@/components/ui/progress";
|
|
||||||
import { useStats } from "@/hooks";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
|
||||||
|
|
||||||
export function StatsSection() {
|
|
||||||
const { data: stats, isLoading, error, refetch } = useStats();
|
|
||||||
if (error) return <ErrorState message={error.message} onRetry={refetch} />;
|
|
||||||
if (isLoading || !stats)
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
|
||||||
<LoadingSkeleton count={8} height="h-28" columns={4} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<StatCard
|
|
||||||
label="Total Messages"
|
|
||||||
value={stats.total_messages}
|
|
||||||
icon={Hash}
|
|
||||||
/>
|
|
||||||
<StatCard label="Today" value={stats.today_messages} icon={Clock} />
|
|
||||||
<StatCard label="Users" value={stats.total_users} icon={Users} />
|
|
||||||
<StatCard
|
|
||||||
label="Active 24h"
|
|
||||||
value={stats.active_users_24h}
|
|
||||||
icon={Sparkles}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Flagged"
|
|
||||||
value={stats.total_flagged}
|
|
||||||
icon={AlertCircle}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Clean"
|
|
||||||
value={stats.total_clean}
|
|
||||||
icon={Shield}
|
|
||||||
variant="success"
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="Voice Recordings"
|
|
||||||
value={stats.total_voice_recordings}
|
|
||||||
icon={Hash}
|
|
||||||
/>
|
|
||||||
<StatCard
|
|
||||||
label="AI Profiles"
|
|
||||||
value={stats.total_profiles}
|
|
||||||
icon={Sparkles}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Hash className="size-4 text-muted-foreground" /> Top Channels
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{stats.top_channels.length === 0 ? (
|
|
||||||
<p className="text-sm text-muted-foreground py-6 text-center">
|
|
||||||
No channel data yet.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{stats.top_channels.map((ch) => {
|
|
||||||
const max = stats.top_channels[0].message_count;
|
|
||||||
const pct = max > 0 ? (ch.message_count / max) * 100 : 0;
|
|
||||||
return (
|
|
||||||
<div key={ch.channel_id} className="space-y-1">
|
|
||||||
<div className="flex items-center justify-between text-sm">
|
|
||||||
<span className="truncate font-medium">
|
|
||||||
#{ch.channel_name ?? ch.channel_id.slice(0, 8)}
|
|
||||||
</span>
|
|
||||||
<span className="text-muted-foreground tabular-nums">
|
|
||||||
{formatNumber(ch.message_count)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<Progress value={pct} className="h-1.5" />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Shield className="size-4 text-muted-foreground" /> Moderation
|
|
||||||
Queue
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
<div className="grid grid-cols-3 gap-3">
|
|
||||||
{[
|
|
||||||
{
|
|
||||||
label: "Pending",
|
|
||||||
value: stats.moderation_overview.pending,
|
|
||||||
cls: "bg-muted/50",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Processing",
|
|
||||||
value: stats.moderation_overview.processing,
|
|
||||||
cls: "bg-yellow-500/10 text-yellow-500",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
label: "Errors",
|
|
||||||
value: stats.moderation_overview.error,
|
|
||||||
cls: "bg-destructive/10 text-destructive",
|
|
||||||
},
|
|
||||||
].map(({ label, value, cls }) => (
|
|
||||||
<div
|
|
||||||
key={label}
|
|
||||||
className={`rounded-lg p-3 text-center space-y-1.5 ${cls}`}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={`text-2xl font-bold tabular-nums ${cls.includes("yellow") ? "text-yellow-500" : cls.includes("destructive") ? "text-destructive" : ""}`}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs text-muted-foreground">{label}</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,106 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ArrowLeft, Clock, Sparkles } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
|
|
||||||
import { DetailStat, ErrorState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { useUserDetail } from "@/hooks";
|
|
||||||
|
|
||||||
export function UserDetailSection({
|
|
||||||
userId,
|
|
||||||
onBack,
|
|
||||||
}: {
|
|
||||||
userId: string;
|
|
||||||
onBack: () => void;
|
|
||||||
}) {
|
|
||||||
const { data: user, isLoading } = useUserDetail(userId);
|
|
||||||
if (isLoading) return <LoadingSkeleton count={1} height="h-64" />;
|
|
||||||
if (!user) return <ErrorState message="User not found." />;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-5 animate-fade-in-up">
|
|
||||||
<Button variant="ghost" size="sm" onClick={onBack}>
|
|
||||||
<ArrowLeft className="size-4 mr-1" /> Back
|
|
||||||
</Button>
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-6 space-y-5">
|
|
||||||
<div className="flex items-center gap-4">
|
|
||||||
<div className="size-14 shrink-0 rounded-full bg-muted flex items-center justify-center text-xl font-medium overflow-hidden ring-2 ring-border">
|
|
||||||
{user.avatar_url ? (
|
|
||||||
<Image
|
|
||||||
src={user.avatar_url}
|
|
||||||
alt=""
|
|
||||||
width={56}
|
|
||||||
height={56}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(user.username ?? "?").charAt(0).toUpperCase()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="min-w-0">
|
|
||||||
<h2 className="text-lg font-semibold">
|
|
||||||
{user.username ?? "Unknown"}
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-muted-foreground font-mono">
|
|
||||||
{user.user_id}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
<DetailStat label="Messages" value={user.total_messages} />
|
|
||||||
<DetailStat
|
|
||||||
label="Flagged"
|
|
||||||
value={user.flagged_count}
|
|
||||||
variant="danger"
|
|
||||||
/>
|
|
||||||
<DetailStat
|
|
||||||
label="Clean Streak"
|
|
||||||
value={user.clean_message_streak ?? 0}
|
|
||||||
/>
|
|
||||||
<DetailStat
|
|
||||||
label="Trust Score"
|
|
||||||
value={user.trust_score ?? 0}
|
|
||||||
suffix="%"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{user.profile_summary && (
|
|
||||||
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
<p className="text-xs text-muted-foreground font-medium uppercase tracking-wider">
|
|
||||||
AI Profile
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed">{user.profile_summary}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{user.recent_messages.length > 0 && (
|
|
||||||
<div className="space-y-3">
|
|
||||||
<h3 className="text-sm font-semibold flex items-center gap-2">
|
|
||||||
<Clock className="size-4 text-muted-foreground" /> Recent
|
|
||||||
Messages
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2 max-h-80 overflow-y-auto">
|
|
||||||
{user.recent_messages.slice(0, 5).map((msg) => (
|
|
||||||
<div
|
|
||||||
key={msg.id}
|
|
||||||
className="rounded-lg border border-border/50 bg-muted/20 p-3 text-sm"
|
|
||||||
>
|
|
||||||
<p className="text-xs text-muted-foreground mb-1 flex items-center gap-2">
|
|
||||||
<Clock className="size-3" />
|
|
||||||
{new Date(msg.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
<p className="text-sm">{msg.content}</p>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,80 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ChevronRight, Search, Users } from "lucide-react";
|
|
||||||
import Image from "next/image";
|
|
||||||
import { useState } from "react";
|
|
||||||
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { Input } from "@/components/ui/input";
|
|
||||||
import { useUsers } from "@/hooks";
|
|
||||||
|
|
||||||
export function UsersSection({ onSelect }: { onSelect: (id: string) => void }) {
|
|
||||||
const [search, setSearch] = useState("");
|
|
||||||
const { data: users, isLoading } = useUsers(search || undefined);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-4 animate-fade-in-up">
|
|
||||||
<div className="relative">
|
|
||||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 size-4 text-muted-foreground" />
|
|
||||||
<Input
|
|
||||||
placeholder="Search users…"
|
|
||||||
value={search}
|
|
||||||
onChange={(e) => setSearch(e.target.value)}
|
|
||||||
className="pl-9 h-9"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={6} height="h-20" columns={2} />
|
|
||||||
) : !users || users.length === 0 ? (
|
|
||||||
<EmptyState icon={Users} title="No users found." />
|
|
||||||
) : (
|
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
||||||
{users.map((u) => (
|
|
||||||
<Card
|
|
||||||
key={u.user_id}
|
|
||||||
className="cursor-pointer hover:bg-accent/5 transition-colors"
|
|
||||||
onClick={() => onSelect(u.user_id)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<div className="size-10 shrink-0 rounded-full bg-muted flex items-center justify-center text-sm font-medium overflow-hidden ring-1 ring-border">
|
|
||||||
{u.avatar_url ? (
|
|
||||||
<Image
|
|
||||||
src={u.avatar_url}
|
|
||||||
alt=""
|
|
||||||
width={40}
|
|
||||||
height={40}
|
|
||||||
className="size-full object-cover"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
(u.username ?? "?").charAt(0).toUpperCase()
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">
|
|
||||||
{u.username ?? "Unknown"}
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-muted-foreground flex items-center gap-2">
|
|
||||||
<span>{u.total_messages} messages</span>
|
|
||||||
{u.flagged_count > 0 && (
|
|
||||||
<Badge
|
|
||||||
variant="destructive"
|
|
||||||
className="text-[10px] px-1.5 py-0 h-4"
|
|
||||||
>
|
|
||||||
{u.flagged_count} flagged
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ChevronRight className="size-4 text-muted-foreground shrink-0" />
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,4 +0,0 @@
|
|||||||
export { MascotProvider, useMascot } from "./mascot-context";
|
|
||||||
export { MascotContainer } from "./mascot-container";
|
|
||||||
export { MascotCanvas } from "./mascot-canvas";
|
|
||||||
export { ChatPanel } from "./chat-panel";
|
|
||||||
@@ -1,62 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ImageIcon } from "lucide-react";
|
|
||||||
import { Card } from "@/components/ui/card";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { extractFirstImage } from "./message-card";
|
|
||||||
|
|
||||||
export function ImagesGrid({
|
|
||||||
images,
|
|
||||||
onSelect,
|
|
||||||
}: {
|
|
||||||
images: MessageRecord[];
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (!images || images.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
||||||
<ImageIcon
|
|
||||||
className="size-10 text-muted-foreground/40 mb-3"
|
|
||||||
aria-label="No images"
|
|
||||||
/>
|
|
||||||
<p className="text-sm text-muted-foreground">No images yet.</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-3 animate-fade-in-up">
|
|
||||||
{images.map((msg) => {
|
|
||||||
const imgUrl = extractFirstImage(msg.metadata);
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
key={msg.id}
|
|
||||||
className="group relative overflow-hidden cursor-pointer"
|
|
||||||
onClick={() => onSelect(msg.id)}
|
|
||||||
>
|
|
||||||
<div className="aspect-square relative bg-muted">
|
|
||||||
{imgUrl ? (
|
|
||||||
<img
|
|
||||||
src={imgUrl}
|
|
||||||
alt={msg.content || "Image"}
|
|
||||||
className="absolute inset-0 size-full object-cover transition-transform duration-300 group-hover:scale-105"
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center justify-center size-full text-muted-foreground text-xs">
|
|
||||||
No image
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{msg.content && (
|
|
||||||
<div className="absolute inset-0 bg-gradient-to-t from-black/70 via-black/20 to-transparent opacity-0 group-hover:opacity-100 transition-opacity duration-200 flex items-end p-3">
|
|
||||||
<p className="text-xs text-white/90 line-clamp-2">
|
|
||||||
{msg.username}: {msg.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,165 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { ExternalLink, Sparkles } from "lucide-react";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatBytes, safeParseJsonArray } from "@/lib/format";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
function MiniStat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
destructive,
|
|
||||||
capitalize,
|
|
||||||
}: {
|
|
||||||
label: string;
|
|
||||||
value: string;
|
|
||||||
destructive?: boolean;
|
|
||||||
capitalize?: boolean;
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<p className="text-xs text-muted-foreground">{label}</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-sm font-medium mt-0.5",
|
|
||||||
capitalize && "capitalize",
|
|
||||||
destructive && "text-destructive",
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{value}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MessageDetailView({
|
|
||||||
message,
|
|
||||||
attachments,
|
|
||||||
}: {
|
|
||||||
message: MessageRecord;
|
|
||||||
attachments: {
|
|
||||||
id: string;
|
|
||||||
filename: string;
|
|
||||||
type: string;
|
|
||||||
size: number;
|
|
||||||
uploaded_url?: string | null;
|
|
||||||
discord_url?: string | null;
|
|
||||||
}[];
|
|
||||||
}) {
|
|
||||||
return (
|
|
||||||
<div className="space-y-5">
|
|
||||||
<div className="flex items-start gap-3">
|
|
||||||
<Avatar className="size-10">
|
|
||||||
<AvatarImage src={message.avatar_url ?? undefined} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{message.username.charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
|
||||||
<span className="text-sm font-medium">{message.username}</span>
|
|
||||||
<span className="text-xs text-muted-foreground">
|
|
||||||
{new Date(message.created_at).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
{message.type === "deleted" && (
|
|
||||||
<Badge variant="destructive" className="text-[10px]">
|
|
||||||
deleted
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
{message.type === "edited" && (
|
|
||||||
<Badge variant="outline" className="text-[10px]">
|
|
||||||
edited
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-sm mt-2 whitespace-pre-wrap break-words leading-relaxed">
|
|
||||||
{message.content}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{message.ai_analysis && (
|
|
||||||
<div className="rounded-lg bg-gradient-to-br from-primary/5 to-primary/[0.02] border border-primary/10 p-4">
|
|
||||||
<div className="flex items-center gap-2 mb-2">
|
|
||||||
<Sparkles className="size-4 text-primary" />
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
|
||||||
AI Analysis
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<p className="text-sm leading-relaxed">{message.ai_analysis}</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{message.ai_moderation_flags && message.ai_moderation_flags !== "[]" && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
|
||||||
Moderation Flags
|
|
||||||
</p>
|
|
||||||
<div className="flex flex-wrap gap-1.5">
|
|
||||||
{safeParseJsonArray(message.ai_moderation_flags).map((f) => (
|
|
||||||
<Badge key={f} variant="destructive" className="text-[11px]">
|
|
||||||
{f}
|
|
||||||
</Badge>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-3">
|
|
||||||
{message.ai_status && (
|
|
||||||
<MiniStat label="Status" value={message.ai_status} capitalize />
|
|
||||||
)}
|
|
||||||
{message.ai_severity && message.ai_severity !== "none" && (
|
|
||||||
<MiniStat
|
|
||||||
label="Severity"
|
|
||||||
value={message.ai_severity}
|
|
||||||
destructive
|
|
||||||
capitalize
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{message.ai_confidence != null && (
|
|
||||||
<MiniStat
|
|
||||||
label="Confidence"
|
|
||||||
value={`${(message.ai_confidence * 100).toFixed(0)}%`}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
{message.ai_recommended_action &&
|
|
||||||
message.ai_recommended_action !== "none" && (
|
|
||||||
<MiniStat
|
|
||||||
label="Action"
|
|
||||||
value={message.ai_recommended_action}
|
|
||||||
capitalize
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{attachments.length > 0 && (
|
|
||||||
<div className="space-y-2">
|
|
||||||
<p className="text-xs text-muted-foreground font-medium">
|
|
||||||
Attachments ({attachments.length})
|
|
||||||
</p>
|
|
||||||
<div className="grid grid-cols-2 gap-2">
|
|
||||||
{attachments.map((a) => (
|
|
||||||
<a
|
|
||||||
key={a.id}
|
|
||||||
href={a.uploaded_url ?? a.discord_url ?? "#"}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center gap-2 rounded-lg border border-border/50 p-2 hover:bg-muted transition-colors group"
|
|
||||||
>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-xs font-medium truncate">{a.filename}</p>
|
|
||||||
<p className="text-[11px] text-muted-foreground">
|
|
||||||
{a.type} · {formatBytes(a.size)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<ExternalLink className="size-3 shrink-0 text-muted-foreground/50 group-hover:text-muted-foreground transition-colors" />
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,39 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Flag } from "lucide-react";
|
|
||||||
import type { MessageRecord } from "@/lib/types";
|
|
||||||
import { MessageCard } from "./message-card";
|
|
||||||
|
|
||||||
export function ReviewList({
|
|
||||||
reviews,
|
|
||||||
onSelect,
|
|
||||||
onReanalyze,
|
|
||||||
}: {
|
|
||||||
reviews: MessageRecord[];
|
|
||||||
onSelect: (id: string) => void;
|
|
||||||
onReanalyze: (id: string) => void;
|
|
||||||
}) {
|
|
||||||
if (!reviews || reviews.length === 0) {
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col items-center justify-center py-20 text-center">
|
|
||||||
<Flag className="size-10 text-muted-foreground/40 mb-3" />
|
|
||||||
<p className="text-sm text-muted-foreground">
|
|
||||||
No flagged messages to review.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-2 animate-fade-in-up">
|
|
||||||
{reviews.map((msg) => (
|
|
||||||
<MessageCard
|
|
||||||
key={msg.id}
|
|
||||||
message={msg}
|
|
||||||
onClick={onSelect}
|
|
||||||
onReanalyze={onReanalyze}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,94 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { Download, Headphones, Trash2 } from "lucide-react";
|
|
||||||
|
|
||||||
import { EmptyState, LoadingSkeleton } from "@/components/shared";
|
|
||||||
import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar";
|
|
||||||
import { Badge } from "@/components/ui/badge";
|
|
||||||
import { Button } from "@/components/ui/button";
|
|
||||||
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
|
|
||||||
import {
|
|
||||||
useDeleteRecording,
|
|
||||||
useRecordings,
|
|
||||||
useRecordingsWsSync,
|
|
||||||
} from "@/hooks";
|
|
||||||
import { formatBytes } from "@/lib/format";
|
|
||||||
import type { WsHook } from "@/lib/ws-hook";
|
|
||||||
|
|
||||||
interface RecordingListProps {
|
|
||||||
ws: WsHook;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RecordingList({ ws }: RecordingListProps) {
|
|
||||||
const { data: recordings, isLoading } = useRecordings();
|
|
||||||
const deleteMut = useDeleteRecording();
|
|
||||||
|
|
||||||
useRecordingsWsSync(ws);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Card>
|
|
||||||
<CardHeader>
|
|
||||||
<CardTitle className="flex items-center gap-2 text-sm font-semibold">
|
|
||||||
<Headphones className="size-4 text-primary" />
|
|
||||||
Voice Recordings
|
|
||||||
</CardTitle>
|
|
||||||
</CardHeader>
|
|
||||||
<CardContent>
|
|
||||||
{isLoading ? (
|
|
||||||
<LoadingSkeleton count={5} height="h-16" />
|
|
||||||
) : !recordings || recordings.length === 0 ? (
|
|
||||||
<EmptyState icon={Headphones} title="No recordings yet." />
|
|
||||||
) : (
|
|
||||||
<div className="space-y-2">
|
|
||||||
{recordings.map((rec) => (
|
|
||||||
<div
|
|
||||||
key={rec.id}
|
|
||||||
className="flex items-center gap-3 rounded-lg border border-border/50 p-3 hover:bg-muted/30 transition-colors"
|
|
||||||
>
|
|
||||||
<Avatar className="size-8">
|
|
||||||
<AvatarImage src={rec.avatar_url ?? undefined} />
|
|
||||||
<AvatarFallback>
|
|
||||||
{(rec.username ?? "?").charAt(0).toUpperCase()}
|
|
||||||
</AvatarFallback>
|
|
||||||
</Avatar>
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<p className="text-sm font-medium truncate">{rec.username}</p>
|
|
||||||
<p className="text-xs text-muted-foreground">
|
|
||||||
{rec.channel_name ?? rec.channel_id ?? "Unknown channel"} —{" "}
|
|
||||||
{new Date(rec.created_at).toLocaleString()}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<Badge
|
|
||||||
variant="outline"
|
|
||||||
className="text-[10px] font-mono shrink-0"
|
|
||||||
>
|
|
||||||
{formatBytes(rec.size_bytes)}
|
|
||||||
</Badge>
|
|
||||||
{rec.download_url && (
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => {
|
|
||||||
if (rec.download_url)
|
|
||||||
window.open(rec.download_url, "_blank");
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Download className="size-4" />
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() => deleteMut.mutate(rec.id)}
|
|
||||||
className="hover:text-destructive hover:bg-destructive/10"
|
|
||||||
>
|
|
||||||
<Trash2 className="size-4" />
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,42 +0,0 @@
|
|||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface DetailStatProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
variant?: "default" | "danger" | "success";
|
|
||||||
suffix?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
const valueColor = {
|
|
||||||
default: "",
|
|
||||||
danger: "text-red-400",
|
|
||||||
success: "text-emerald-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Small stat label used inside detail views.
|
|
||||||
*/
|
|
||||||
export function DetailStat({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
variant = "default",
|
|
||||||
suffix,
|
|
||||||
}: DetailStatProps) {
|
|
||||||
return (
|
|
||||||
<Card className="bg-gradient-to-br from-cyan-500/5 to-transparent border-cyan-500/10">
|
|
||||||
<CardContent className="p-3">
|
|
||||||
<p className="text-xs text-muted-foreground/70 tracking-wide">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn("text-lg font-bold tabular-nums", valueColor[variant])}
|
|
||||||
>
|
|
||||||
{formatNumber(value)}
|
|
||||||
{suffix}
|
|
||||||
</p>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,78 +0,0 @@
|
|||||||
import type { LucideIcon } from "lucide-react";
|
|
||||||
|
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
|
||||||
import { formatNumber } from "@/lib/format";
|
|
||||||
import { cn } from "@/lib/utils";
|
|
||||||
|
|
||||||
interface StatCardProps {
|
|
||||||
label: string;
|
|
||||||
value: number;
|
|
||||||
icon: LucideIcon;
|
|
||||||
variant?: "default" | "danger" | "success" | "warning";
|
|
||||||
}
|
|
||||||
|
|
||||||
const variantStyles = {
|
|
||||||
default: "from-cyan-500/10 to-teal-500/5 border-cyan-500/20",
|
|
||||||
danger: "from-red-500/10 to-rose-500/5 border-red-500/20",
|
|
||||||
success: "from-emerald-500/10 to-green-500/5 border-emerald-500/20",
|
|
||||||
warning: "from-amber-500/10 to-yellow-500/5 border-amber-500/20",
|
|
||||||
};
|
|
||||||
|
|
||||||
const iconBg = {
|
|
||||||
default: "bg-cyan-500/15 text-cyan-400",
|
|
||||||
danger: "bg-red-500/15 text-red-400",
|
|
||||||
success: "bg-emerald-500/15 text-emerald-400",
|
|
||||||
warning: "bg-amber-500/15 text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
const valueColor = {
|
|
||||||
default: "",
|
|
||||||
danger: "text-red-400",
|
|
||||||
success: "text-emerald-400",
|
|
||||||
warning: "text-amber-400",
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Metric card used across dashboard and landing pages.
|
|
||||||
*/
|
|
||||||
export function StatCard({
|
|
||||||
label,
|
|
||||||
value,
|
|
||||||
icon: Icon,
|
|
||||||
variant = "default",
|
|
||||||
}: StatCardProps) {
|
|
||||||
return (
|
|
||||||
<Card
|
|
||||||
className={cn(
|
|
||||||
"border bg-gradient-to-br backdrop-blur-sm",
|
|
||||||
variantStyles[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<CardContent className="p-4">
|
|
||||||
<div className="flex items-start justify-between">
|
|
||||||
<div className="space-y-1.5">
|
|
||||||
<p className="text-xs text-muted-foreground/80 tracking-wide">
|
|
||||||
{label}
|
|
||||||
</p>
|
|
||||||
<p
|
|
||||||
className={cn(
|
|
||||||
"text-2xl font-bold tabular-nums tracking-tight",
|
|
||||||
valueColor[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{formatNumber(value)}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"flex size-9 shrink-0 items-center justify-center rounded-lg",
|
|
||||||
iconBg[variant],
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<Icon className="size-4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</CardContent>
|
|
||||||
</Card>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -3,9 +3,9 @@ import { api } from "./client";
|
|||||||
|
|
||||||
export const chatbotApi = {
|
export const chatbotApi = {
|
||||||
send: (message: string) =>
|
send: (message: string) =>
|
||||||
api.post<ChatbotResponse>("/api/mascot/chat", { message }),
|
api.post<ChatbotResponse>("/api/chat", { message }),
|
||||||
|
|
||||||
getHistory: () => api.get<ChatHistoryMessage[]>("/api/mascot/chat/history"),
|
getHistory: () => api.get<ChatHistoryMessage[]>("/api/chat/history"),
|
||||||
|
|
||||||
clearHistory: () => api.delete<{ ok: boolean }>("/api/mascot/chat/history"),
|
clearHistory: () => api.delete<{ ok: boolean }>("/api/chat/history"),
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,11 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2017",
|
"target": "ES2017",
|
||||||
"lib": ["dom", "dom.iterable", "esnext"],
|
"lib": [
|
||||||
|
"dom",
|
||||||
|
"dom.iterable",
|
||||||
|
"esnext"
|
||||||
|
],
|
||||||
"allowJs": true,
|
"allowJs": true,
|
||||||
"skipLibCheck": true,
|
"skipLibCheck": true,
|
||||||
"strict": true,
|
"strict": true,
|
||||||
@@ -19,7 +23,9 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"paths": {
|
"paths": {
|
||||||
"@/*": ["./src/*"]
|
"@/*": [
|
||||||
|
"./src/*"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
@@ -28,7 +34,10 @@
|
|||||||
"**/*.tsx",
|
"**/*.tsx",
|
||||||
".next/types/**/*.ts",
|
".next/types/**/*.ts",
|
||||||
".next/dev/types/**/*.ts",
|
".next/dev/types/**/*.ts",
|
||||||
"**/*.mts"
|
"**/*.mts",
|
||||||
|
".next/dev/dev/types/**/*.ts"
|
||||||
],
|
],
|
||||||
"exclude": ["node_modules"]
|
"exclude": [
|
||||||
|
"node_modules"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user