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
@@ -0,0 +1,90 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useEffect } from "react";
|
||||
import { Send } from "lucide-react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
interface ChatPanelProps {
|
||||
inputRef?: React.RefObject<HTMLInputElement | null>;
|
||||
}
|
||||
|
||||
export function ChatPanel({ inputRef: externalInputRef }: ChatPanelProps) {
|
||||
const { messages, sendMessage, isTyping } = useChatbot();
|
||||
const listRef = useRef<HTMLDivElement>(null);
|
||||
const internalInputRef = useRef<HTMLInputElement>(null);
|
||||
const inputRef = externalInputRef ?? internalInputRef;
|
||||
|
||||
// Auto-scroll to bottom on new messages
|
||||
useEffect(() => {
|
||||
if (listRef.current) {
|
||||
listRef.current.scrollTop = listRef.current.scrollHeight;
|
||||
}
|
||||
}, [messages, isTyping]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
const input = inputRef.current;
|
||||
if (!input || !input.value.trim()) return;
|
||||
sendMessage(input.value);
|
||||
input.value = "";
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Chat messages */}
|
||||
<div ref={listRef} className="flex-1 overflow-y-auto px-2 py-1 space-y-1">
|
||||
{messages.length === 0 && (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<p className="text-[10px] text-text-secondary/40">Ask chatbot anything</p>
|
||||
</div>
|
||||
)}
|
||||
{messages.slice(-8).map((msg, i) => (
|
||||
<div
|
||||
key={`${msg.timestamp}-${i}`}
|
||||
className={`flex ${msg.role === "user" ? "justify-end" : "justify-start"}`}
|
||||
>
|
||||
<span
|
||||
className={`text-[10px] px-2 py-1 rounded-lg max-w-[85%] leading-relaxed ${
|
||||
msg.role === "user"
|
||||
? "bg-primary/20 text-text-primary"
|
||||
: "glass text-text-secondary"
|
||||
}`}
|
||||
>
|
||||
{msg.content}
|
||||
</span>
|
||||
</div>
|
||||
))}
|
||||
{isTyping && (
|
||||
<div className="flex justify-start">
|
||||
<div className="glass rounded-lg px-2 py-1">
|
||||
<span className="inline-flex gap-0.5">
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "0ms" }} />
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "150ms" }} />
|
||||
<span className="size-1 rounded-full bg-text-secondary animate-bounce" style={{ animationDelay: "300ms" }} />
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Input bar */}
|
||||
<form onSubmit={handleSubmit} className="flex items-center gap-1 px-2 py-1.5 border-t border-glass-border shrink-0">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="text"
|
||||
placeholder="Ask chatbot..."
|
||||
className="flex-1 bg-transparent text-[10px] text-text-primary placeholder-text-secondary/30 outline-none"
|
||||
disabled={isTyping}
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="size-5 flex items-center justify-center disabled:opacity-40"
|
||||
disabled={isTyping}
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="size-3 text-primary" />
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,130 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef } from "react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
|
||||
/**
|
||||
* Live2D Cubism WebGL canvas.
|
||||
*
|
||||
* This component renders the Live2D model via the Cubism SDK.
|
||||
* Integration requires:
|
||||
* 1. Live2D Cubism SDK for Web (npm: @live2d/cubism)
|
||||
* 2. Model files: .model3.json, .moc3, .physics3.json, textures
|
||||
* 3. Place model files in public/chatbot/
|
||||
*
|
||||
* The current implementation shows a placeholder character.
|
||||
* Replace with actual Cubism SDK integration when model files are available.
|
||||
*/
|
||||
|
||||
export function ChatbotCanvas() {
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
const { expression } = useChatbot();
|
||||
|
||||
// Placeholder: draw a simple avatar face that responds to expression
|
||||
useEffect(() => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext("2d");
|
||||
if (!ctx) return;
|
||||
|
||||
const w = canvas.width;
|
||||
const h = canvas.height;
|
||||
|
||||
ctx.clearRect(0, 0, w, h);
|
||||
|
||||
// Background circle
|
||||
const gradient = ctx.createRadialGradient(w / 2, h / 2 - 10, 10, w / 2, h / 2, 80);
|
||||
gradient.addColorStop(0, "oklch(0.62 0.17 215 / 0.8)");
|
||||
gradient.addColorStop(0.6, "oklch(0.12 0.02 245 / 0.9)");
|
||||
gradient.addColorStop(1, "oklch(0.07 0.015 250 / 1)");
|
||||
ctx.fillStyle = gradient;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, h / 2, 75, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
|
||||
// Eyes
|
||||
const eyeOffsetX = 20;
|
||||
const eyeY = 45;
|
||||
|
||||
// Expression-driven eyes
|
||||
if (expression === "surprise") {
|
||||
// Wide eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 12, 14, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 5, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else if (expression === "happy") {
|
||||
// Happy closed crescent eyes
|
||||
ctx.strokeStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.lineWidth = 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||
ctx.stroke();
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 10, Math.PI * 0.1, Math.PI * 0.9);
|
||||
ctx.stroke();
|
||||
} else if (expression === "sad") {
|
||||
// Sad downcast eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 8, 6, 0.2, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 8, 6, -0.2, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
} else {
|
||||
// Normal eyes
|
||||
ctx.fillStyle = "oklch(0.93 0.01 245)";
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2 - eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||
ctx.ellipse(w / 2 + eyeOffsetX, eyeY, 10, 8, 0, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
ctx.fillStyle = "oklch(0.62 0.17 215)";
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2 - eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||
ctx.arc(w / 2 + eyeOffsetX, eyeY, 4, 0, Math.PI * 2);
|
||||
ctx.fill();
|
||||
}
|
||||
|
||||
// Mouth
|
||||
ctx.strokeStyle = "oklch(0.93 0.01 245 / 0.7)";
|
||||
ctx.lineWidth = 2;
|
||||
if (expression === "talking") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2, 70, 8, 6, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
} else if (expression === "happy") {
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, 70, 10, 0.1, Math.PI - 0.1);
|
||||
ctx.stroke();
|
||||
} else if (expression === "surprise") {
|
||||
ctx.beginPath();
|
||||
ctx.ellipse(w / 2, 70, 6, 8, 0, 0, Math.PI * 2);
|
||||
ctx.stroke();
|
||||
ctx.fillStyle = "oklch(0.12 0.02 245)";
|
||||
ctx.fill();
|
||||
} else {
|
||||
ctx.beginPath();
|
||||
ctx.arc(w / 2, 75, 6, 0.1, Math.PI - 0.1);
|
||||
ctx.stroke();
|
||||
}
|
||||
|
||||
// Breathing animation — subtle canvas shift
|
||||
const breath = Math.sin(Date.now() / 1000) * 1.5;
|
||||
// Applied via CSS transform on container instead
|
||||
|
||||
}, [expression]);
|
||||
|
||||
return (
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
width={160}
|
||||
height={180}
|
||||
className="w-full h-full"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client";
|
||||
|
||||
import { useRef, useState, useCallback, useEffect } from "react";
|
||||
import { Bot, MessageCircle, Minimize2 } from "lucide-react";
|
||||
import { useChatbot } from "./chatbot-context";
|
||||
import { ChatbotCanvas } from "./chatbot-canvas";
|
||||
import { ChatPanel } from "./chat-panel";
|
||||
|
||||
export function ChatbotContainer() {
|
||||
const { minimized, setMinimized, chatOpen, setChatOpen } = useChatbot();
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const [dragStart, setDragStart] = useState({ x: 0, y: 0 });
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleMouseDown = useCallback((e: React.MouseEvent) => {
|
||||
setDragging(true);
|
||||
setDragStart({ x: e.clientX - position.x, y: e.clientY - position.y });
|
||||
}, [position]);
|
||||
|
||||
const handleMouseMove = useCallback((e: React.MouseEvent) => {
|
||||
if (!dragging) return;
|
||||
setPosition({ x: e.clientX - dragStart.x, y: e.clientY - dragStart.y });
|
||||
}, [dragging, dragStart]);
|
||||
|
||||
const handleMouseUp = useCallback(() => setDragging(false), []);
|
||||
|
||||
// Focus input when chat opens
|
||||
useEffect(() => {
|
||||
if (chatOpen) {
|
||||
// Small delay for the animation
|
||||
const id = setTimeout(() => inputRef.current?.focus(), 150);
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
}, [chatOpen]);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed bottom-4 right-4 z-40 select-none"
|
||||
style={{ transform: `translate(${position.x}px, ${position.y}px)` }}
|
||||
onMouseMove={handleMouseMove}
|
||||
onMouseUp={handleMouseUp}
|
||||
onMouseLeave={handleMouseUp}
|
||||
>
|
||||
{/* Main chatbot bubble */}
|
||||
<div
|
||||
className={`glass-intense rounded-2xl overflow-hidden transition-all duration-200 ${
|
||||
minimized ? "w-14 h-14 cursor-pointer" : "w-[220px]"
|
||||
}`}
|
||||
style={{ height: minimized ? 56 : 320 }}
|
||||
>
|
||||
{minimized ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(false)}
|
||||
className="w-full h-full flex items-center justify-center"
|
||||
onMouseDown={handleMouseDown}
|
||||
aria-label="Open chatbot"
|
||||
>
|
||||
<Bot className="size-6 text-primary" />
|
||||
</button>
|
||||
) : (
|
||||
<>
|
||||
{/* Drag handle + controls */}
|
||||
<div
|
||||
className="flex items-center justify-between px-3 py-1.5 border-b border-glass-border cursor-grab active:cursor-grabbing"
|
||||
onMouseDown={handleMouseDown}
|
||||
>
|
||||
<span className="text-[10px] font-semibold text-text-secondary tracking-wide uppercase">
|
||||
Chatbot
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setChatOpen(!chatOpen)}
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
aria-label={chatOpen ? "Close chat" : "Open chat"}
|
||||
>
|
||||
<MessageCircle className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMinimized(true)}
|
||||
className="size-5 flex items-center justify-center rounded hover:bg-glass-bg transition-colors"
|
||||
aria-label="Minimize chatbot"
|
||||
>
|
||||
{minimized ? (
|
||||
<Bot className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
) : (
|
||||
<Minimize2 className="size-3 text-text-secondary/60 hover:text-text-primary" />
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Canvas area */}
|
||||
<div className="h-[140px] flex items-center justify-center">
|
||||
<ChatbotCanvas />
|
||||
</div>
|
||||
|
||||
{/* Chat panel (expandable) */}
|
||||
<div
|
||||
className={`transition-all duration-200 overflow-hidden ${
|
||||
chatOpen ? "h-[130px]" : "h-0"
|
||||
}`}
|
||||
>
|
||||
<ChatPanel inputRef={inputRef} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import {
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useContext,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { chatbotApi } from "@/lib/api";
|
||||
import type { ChatHistoryMessage } from "@/lib/types";
|
||||
|
||||
export type ChatbotExpression = "idle" | "listening" | "surprise" | "happy" | "sad" | "talking";
|
||||
|
||||
interface ChatbotMessage {
|
||||
role: "user" | "assistant";
|
||||
content: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
interface ChatbotContextValue {
|
||||
/** Expression the chatbot avatar should display */
|
||||
expression: ChatbotExpression;
|
||||
setExpression: (expr: ChatbotExpression) => void;
|
||||
|
||||
/** Whether the enlarged bubble is minimized to a small icon */
|
||||
minimized: boolean;
|
||||
setMinimized: (v: boolean) => void;
|
||||
|
||||
/** Whether the chat panel inside the bubble is open */
|
||||
chatOpen: boolean;
|
||||
setChatOpen: (v: boolean) => void;
|
||||
|
||||
/**
|
||||
* @deprecated Use `minimized` / `setMinimized` instead.
|
||||
* Legacy toggle alias kept for compatibility.
|
||||
*/
|
||||
isOpen: boolean;
|
||||
setOpen: (open: boolean) => void;
|
||||
toggle: () => void;
|
||||
|
||||
/** Chat messages with real API backend */
|
||||
messages: ChatbotMessage[];
|
||||
sendMessage: (content: string) => Promise<void>;
|
||||
clearMessages: () => Promise<void>;
|
||||
isTyping: boolean;
|
||||
}
|
||||
|
||||
const ChatbotContext = createContext<ChatbotContextValue | null>(null);
|
||||
|
||||
export function ChatbotProvider({ children }: { children: ReactNode }) {
|
||||
const [expression, setExpression] = useState<ChatbotExpression>("idle");
|
||||
const [minimized, setMinimized] = useState(true);
|
||||
const [chatOpen, setChatOpen] = useState(false);
|
||||
const [messages, setMessages] = useState<ChatbotMessage[]>([]);
|
||||
const [isTyping, setIsTyping] = useState(false);
|
||||
const historyFetched = useRef(false);
|
||||
|
||||
// Derived legacy state
|
||||
const isOpen = !minimized;
|
||||
|
||||
const setOpen = useCallback((open: boolean) => {
|
||||
setMinimized(!open);
|
||||
}, []);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
setMinimized((prev) => !prev);
|
||||
}, []);
|
||||
|
||||
// Load chat history on first mount
|
||||
useEffect(() => {
|
||||
if (historyFetched.current) return;
|
||||
historyFetched.current = true;
|
||||
|
||||
chatbotApi.getHistory().then((history) => {
|
||||
const mapped = (history ?? []).map((msg: ChatHistoryMessage) => ({
|
||||
role: msg.role as "user" | "assistant",
|
||||
content: msg.content,
|
||||
timestamp: msg.timestamp,
|
||||
}));
|
||||
setMessages(mapped);
|
||||
}).catch(() => {
|
||||
// API may not be available yet — silently ignore
|
||||
});
|
||||
}, []);
|
||||
|
||||
const sendMessage = useCallback(async (content: string) => {
|
||||
if (!content.trim()) return;
|
||||
|
||||
const userMsg: ChatbotMessage = {
|
||||
role: "user",
|
||||
content: content.trim(),
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, userMsg]);
|
||||
setExpression("listening");
|
||||
setIsTyping(true);
|
||||
|
||||
try {
|
||||
const res = await chatbotApi.send(content.trim());
|
||||
const botMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: res.response,
|
||||
timestamp: res.timestamp ?? new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, botMsg]);
|
||||
setExpression("happy");
|
||||
} catch {
|
||||
const errorMsg: ChatbotMessage = {
|
||||
role: "assistant",
|
||||
content: "Sorry, I couldn't process that request. Please try again.",
|
||||
timestamp: new Date().toISOString(),
|
||||
};
|
||||
setMessages((prev) => [...prev, errorMsg]);
|
||||
setExpression("sad");
|
||||
} finally {
|
||||
setIsTyping(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const clearMessages = useCallback(async () => {
|
||||
try {
|
||||
await chatbotApi.clearHistory();
|
||||
} catch {
|
||||
// Best-effort clear
|
||||
}
|
||||
setMessages([]);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<ChatbotContext.Provider
|
||||
value={{
|
||||
expression,
|
||||
setExpression,
|
||||
minimized,
|
||||
setMinimized,
|
||||
chatOpen,
|
||||
setChatOpen,
|
||||
isOpen,
|
||||
setOpen,
|
||||
toggle,
|
||||
messages,
|
||||
sendMessage,
|
||||
clearMessages,
|
||||
isTyping,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ChatbotContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useChatbot(): ChatbotContextValue {
|
||||
const ctx = useContext(ChatbotContext);
|
||||
if (!ctx) {
|
||||
throw new Error("useChatbot must be used within a ChatbotProvider");
|
||||
}
|
||||
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";
|
||||
Reference in New Issue
Block a user