yuuki-deidad

This commit is contained in:
aguitauwu
2026-02-09 19:36:27 -06:00
commit efdbd95700
34 changed files with 5639 additions and 0 deletions

154
components/chat-message.tsx Normal file
View File

@@ -0,0 +1,154 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { User, Bot, Globe, Youtube, Copy, Check } from "lucide-react";
export interface ChatMsg {
id: string;
role: "user" | "assistant" | "system";
content: string;
type?: "text" | "research" | "youtube";
researchData?: {
answer: string;
results: { title: string; url: string; content: string }[];
};
youtubeData?: {
videos: {
id: string;
title: string;
description: string;
channel: string;
thumbnail: string;
url: string;
}[];
};
isStreaming?: boolean;
}
interface ChatMessageProps {
message: ChatMsg;
accentColor: string;
}
export function ChatMessage({ message, accentColor }: ChatMessageProps) {
const [copied, setCopied] = React.useState(false);
const isUser = message.role === "user";
const handleCopy = () => {
navigator.clipboard.writeText(message.content);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
return (
<div className={cn("group flex gap-3 px-4 py-4", isUser ? "justify-end" : "justify-start")}>
{!isUser && (
<div className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg bg-muted">
{message.type === "research" ? (
<Globe className="h-4 w-4 text-foreground" />
) : message.type === "youtube" ? (
<Youtube className="h-4 w-4 text-foreground" />
) : (
<Bot className="h-4 w-4 text-foreground" />
)}
</div>
)}
<div className={cn("flex max-w-[75%] flex-col gap-1", isUser && "items-end")}>
<div
className={cn(
"rounded-2xl px-4 py-2.5 text-sm leading-relaxed",
isUser
? "rounded-br-md text-white"
: "rounded-bl-md bg-muted text-foreground"
)}
style={isUser ? { backgroundColor: accentColor } : undefined}
>
{/* Research results */}
{message.type === "research" && message.researchData && (
<div className="flex flex-col gap-3">
{message.researchData.answer && (
<p className="text-sm leading-relaxed">{message.researchData.answer}</p>
)}
{message.researchData.results.length > 0 && (
<div className="flex flex-col gap-2 mt-2">
<span className="text-xs font-medium text-muted-foreground uppercase tracking-wider">Sources</span>
{message.researchData.results.map((r, i) => (
<a
key={`${r.url}-${i}`}
href={r.url}
target="_blank"
rel="noopener noreferrer"
className="flex flex-col gap-0.5 rounded-lg border border-border bg-background/50 p-2.5 transition-colors hover:bg-background/80"
>
<span className="text-xs font-medium text-foreground line-clamp-1">{r.title}</span>
<span className="text-xs text-muted-foreground line-clamp-2">{r.content}</span>
</a>
))}
</div>
)}
</div>
)}
{/* YouTube results */}
{message.type === "youtube" && message.youtubeData && (
<div className="flex flex-col gap-2">
{message.youtubeData.videos.map((v) => (
<a
key={v.id}
href={v.url}
target="_blank"
rel="noopener noreferrer"
className="flex gap-3 rounded-lg border border-border bg-background/50 p-2 transition-colors hover:bg-background/80"
>
{v.thumbnail && (
<img
src={v.thumbnail || "/placeholder.svg"}
alt={v.title}
className="h-16 w-24 shrink-0 rounded-md object-cover"
crossOrigin="anonymous"
/>
)}
<div className="flex flex-col gap-0.5 overflow-hidden">
<span className="text-xs font-medium text-foreground line-clamp-2">{v.title}</span>
<span className="text-xs text-muted-foreground">{v.channel}</span>
</div>
</a>
))}
</div>
)}
{/* Regular text */}
{(!message.type || message.type === "text") && (
<span className={isUser ? "text-white" : ""}>
{message.content}
{message.isStreaming && <span className="cursor-blink ml-0.5">|</span>}
</span>
)}
</div>
{/* Copy button for assistant messages */}
{!isUser && !message.isStreaming && message.content && (
<button
type="button"
onClick={handleCopy}
className="flex items-center gap-1 rounded-md px-2 py-1 text-xs text-muted-foreground opacity-0 transition-all group-hover:opacity-100 hover:text-foreground hover:bg-muted"
>
{copied ? <Check className="h-3 w-3" /> : <Copy className="h-3 w-3" />}
{copied ? "Copied" : "Copy"}
</button>
)}
</div>
{isUser && (
<div
className="mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-lg"
style={{ backgroundColor: accentColor }}
>
<User className="h-4 w-4 text-white" />
</div>
)}
</div>
);
}

513
components/chat-window.tsx Normal file
View File

@@ -0,0 +1,513 @@
"use client";
import React, { useState, useRef, useEffect, useCallback } from "react";
import { useAuth } from "@/lib/auth-context";
import { useYuukiTheme } from "@/lib/theme-context";
import { ChatMessage, type ChatMsg } from "./chat-message";
import { ModelSelector } from "./model-selector";
import { ThemePanel } from "./theme-panel";
import {
Send,
Palette,
LogOut,
Globe,
Youtube,
Loader2,
Plus,
Trash2,
MessageSquare,
Settings,
X,
} from "lucide-react";
import { cn } from "@/lib/utils";
interface Conversation {
id: string;
title: string;
messages: ChatMsg[];
}
export function ChatWindow() {
const { token, tokenSource, logout } = useAuth();
const { accentColor } = useYuukiTheme();
const [conversations, setConversations] = useState<Conversation[]>([
{ id: "1", title: "New Chat", messages: [] },
]);
const [activeConvId, setActiveConvId] = useState("1");
const [model, setModel] = useState("yuuki-best");
const [input, setInput] = useState("");
const [loading, setLoading] = useState(false);
const [themeOpen, setThemeOpen] = useState(false);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [researchEnabled, setResearchEnabled] = useState(false);
const [youtubeEnabled, setYoutubeEnabled] = useState(false);
const messagesEndRef = useRef<HTMLDivElement>(null);
const inputRef = useRef<HTMLTextAreaElement>(null);
const activeConv = conversations.find((c) => c.id === activeConvId) || conversations[0];
const messages = activeConv.messages;
const scrollToBottom = useCallback(() => {
messagesEndRef.current?.scrollIntoView({ behavior: "smooth" });
}, []);
useEffect(() => {
scrollToBottom();
}, [messages, scrollToBottom]);
const updateMessages = (convId: string, msgs: ChatMsg[]) => {
setConversations((prev) =>
prev.map((c) => (c.id === convId ? { ...c, messages: msgs } : c))
);
};
const updateTitle = (convId: string, firstMsg: string) => {
setConversations((prev) =>
prev.map((c) =>
c.id === convId && c.title === "New Chat"
? { ...c, title: firstMsg.slice(0, 30) + (firstMsg.length > 30 ? "..." : "") }
: c
)
);
};
const createNewChat = () => {
const id = Date.now().toString();
setConversations((prev) => [...prev, { id, title: "New Chat", messages: [] }]);
setActiveConvId(id);
setSidebarOpen(false);
};
const deleteConversation = (id: string) => {
setConversations((prev) => {
const next = prev.filter((c) => c.id !== id);
if (next.length === 0) {
const newConv = { id: Date.now().toString(), title: "New Chat", messages: [] };
setActiveConvId(newConv.id);
return [newConv];
}
if (id === activeConvId) setActiveConvId(next[0].id);
return next;
});
};
const sendMessage = async () => {
if (!input.trim() || loading) return;
const userMsg: ChatMsg = {
id: Date.now().toString(),
role: "user",
content: input.trim(),
};
const newMessages = [...messages, userMsg];
updateMessages(activeConvId, newMessages);
updateTitle(activeConvId, input.trim());
const currentInput = input.trim();
setInput("");
setLoading(true);
// Resize textarea
if (inputRef.current) inputRef.current.style.height = "auto";
try {
// If research is enabled, do a parallel research call
if (researchEnabled) {
const resRes = await fetch("/api/research", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: currentInput }),
});
const resData = await resRes.json();
if (resRes.ok) {
const researchMsg: ChatMsg = {
id: `res-${Date.now()}`,
role: "assistant",
content: resData.answer || "Research completed",
type: "research",
researchData: resData,
};
const updated = [...newMessages, researchMsg];
updateMessages(activeConvId, updated);
setLoading(false);
return;
}
}
// If YouTube is enabled, do a YouTube search
if (youtubeEnabled) {
const ytRes = await fetch("/api/youtube", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ query: currentInput }),
});
const ytData = await ytRes.json();
if (ytRes.ok && ytData.videos?.length > 0) {
const ytMsg: ChatMsg = {
id: `yt-${Date.now()}`,
role: "assistant",
content: `Found ${ytData.videos.length} videos for "${currentInput}"`,
type: "youtube",
youtubeData: ytData,
};
const updated = [...newMessages, ytMsg];
updateMessages(activeConvId, updated);
setLoading(false);
return;
}
}
// Default: chat with model
const streamingMsg: ChatMsg = {
id: `ast-${Date.now()}`,
role: "assistant",
content: "",
isStreaming: true,
};
updateMessages(activeConvId, [...newMessages, streamingMsg]);
const response = await fetch("/api/chat", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
messages: newMessages.map((m) => ({ role: m.role, content: m.content })),
model,
token: tokenSource === "demo" ? null : token,
tokenSource,
}),
});
const data = await response.json();
if (!response.ok) {
const errorMsg: ChatMsg = {
id: `err-${Date.now()}`,
role: "assistant",
content: `Error: ${data.error || "Something went wrong"}`,
};
updateMessages(activeConvId, [...newMessages, errorMsg]);
} else {
const assistantMsg: ChatMsg = {
id: data.id || `ast-${Date.now()}`,
role: "assistant",
content: data.content,
};
updateMessages(activeConvId, [...newMessages, assistantMsg]);
}
} catch (err) {
const errorMsg: ChatMsg = {
id: `err-${Date.now()}`,
role: "assistant",
content: `Connection error: ${err instanceof Error ? err.message : "Please try again"}`,
};
updateMessages(activeConvId, [...newMessages, errorMsg]);
} finally {
setLoading(false);
}
};
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
};
const handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
setInput(e.target.value);
// Auto resize
const el = e.target;
el.style.height = "auto";
el.style.height = Math.min(el.scrollHeight, 150) + "px";
};
return (
<div className="flex h-screen bg-background">
{/* Sidebar overlay for mobile */}
{sidebarOpen && (
<div
className="fixed inset-0 z-30 bg-background/60 backdrop-blur-sm md:hidden"
onClick={() => setSidebarOpen(false)}
/>
)}
{/* Sidebar */}
<aside
className={cn(
"fixed inset-y-0 left-0 z-40 flex w-64 flex-col border-r border-border bg-card transition-transform duration-300 md:relative md:translate-x-0",
sidebarOpen ? "translate-x-0" : "-translate-x-full"
)}
>
{/* Sidebar header */}
<div className="flex items-center justify-between border-b border-border px-4 py-3">
<div className="flex items-center gap-2">
<div className="flex h-7 w-7 items-center justify-center rounded-md bg-foreground">
<span className="text-xs font-bold text-background font-mono">Y</span>
</div>
<span className="text-sm font-semibold text-foreground">Yuuki Chat</span>
</div>
<button
type="button"
onClick={() => setSidebarOpen(false)}
className="text-muted-foreground hover:text-foreground md:hidden"
>
<X className="h-4 w-4" />
</button>
</div>
{/* New chat button */}
<div className="p-3">
<button
type="button"
onClick={createNewChat}
className="flex w-full items-center gap-2 rounded-lg border border-border px-3 py-2 text-sm text-foreground transition-colors hover:bg-muted"
>
<Plus className="h-4 w-4" />
New Chat
</button>
</div>
{/* Conversation list */}
<div className="flex-1 overflow-y-auto custom-scrollbar px-3">
<div className="flex flex-col gap-0.5">
{conversations.map((conv) => (
<div
key={conv.id}
className={cn(
"group flex items-center gap-2 rounded-lg px-3 py-2 transition-colors cursor-pointer",
conv.id === activeConvId
? "bg-muted text-foreground"
: "text-muted-foreground hover:bg-muted/50 hover:text-foreground"
)}
onClick={() => {
setActiveConvId(conv.id);
setSidebarOpen(false);
}}
>
<MessageSquare className="h-3.5 w-3.5 shrink-0" />
<span className="flex-1 truncate text-sm">{conv.title}</span>
<button
type="button"
onClick={(e) => {
e.stopPropagation();
deleteConversation(conv.id);
}}
className="shrink-0 opacity-0 group-hover:opacity-100 text-muted-foreground hover:text-destructive"
>
<Trash2 className="h-3.5 w-3.5" />
</button>
</div>
))}
</div>
</div>
{/* Sidebar footer */}
<div className="border-t border-border p-3 flex flex-col gap-1">
<button
type="button"
onClick={() => setThemeOpen(true)}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
<Palette className="h-4 w-4" />
Theme
</button>
<button
type="button"
onClick={logout}
className="flex items-center gap-2 rounded-lg px-3 py-2 text-sm text-muted-foreground transition-colors hover:bg-muted hover:text-destructive"
>
<LogOut className="h-4 w-4" />
Sign Out
</button>
</div>
</aside>
{/* Main chat area */}
<main className="flex flex-1 flex-col overflow-hidden">
{/* Top bar */}
<header className="flex items-center justify-between border-b border-border glass px-4 py-2.5">
<div className="flex items-center gap-3">
<button
type="button"
onClick={() => setSidebarOpen(true)}
className="flex items-center justify-center rounded-lg p-1.5 text-muted-foreground hover:text-foreground hover:bg-muted md:hidden"
>
<Settings className="h-4 w-4" />
</button>
<ModelSelector value={model} onChange={setModel} />
<span className="hidden sm:inline-flex items-center gap-1 rounded-md bg-muted px-2 py-1 text-[10px] font-mono text-muted-foreground uppercase tracking-wider">
{tokenSource === "demo"
? "demo"
: tokenSource === "yuuki-api"
? "yk-api"
: "hf"}
</span>
</div>
<div className="flex items-center gap-1.5">
{/* Research toggle */}
<button
type="button"
onClick={() => { setResearchEnabled(!researchEnabled); setYoutubeEnabled(false); }}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-all",
researchEnabled
? "border-foreground/20 bg-muted text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/10"
)}
>
<Globe className="h-3.5 w-3.5" />
<span className="hidden sm:inline">Research</span>
</button>
{/* YouTube toggle */}
<button
type="button"
onClick={() => { setYoutubeEnabled(!youtubeEnabled); setResearchEnabled(false); }}
className={cn(
"flex items-center gap-1.5 rounded-lg border px-2.5 py-1.5 text-xs font-medium transition-all",
youtubeEnabled
? "border-foreground/20 bg-muted text-foreground"
: "border-border text-muted-foreground hover:text-foreground hover:border-foreground/10"
)}
>
<Youtube className="h-3.5 w-3.5" />
<span className="hidden sm:inline">YouTube</span>
</button>
{/* Theme button (desktop only) */}
<button
type="button"
onClick={() => setThemeOpen(true)}
className="hidden md:flex items-center gap-1.5 rounded-lg border border-border px-2.5 py-1.5 text-xs font-medium text-muted-foreground transition-colors hover:text-foreground hover:border-foreground/10"
>
<Palette className="h-3.5 w-3.5" />
</button>
</div>
</header>
{/* Messages */}
<div className="flex-1 overflow-y-auto custom-scrollbar">
{messages.length === 0 ? (
<div className="flex h-full flex-col items-center justify-center gap-4 px-4">
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted">
<span className="text-2xl font-bold text-foreground font-mono">Y</span>
</div>
<div className="text-center">
<h2 className="text-xl font-semibold text-foreground">Yuuki Chat</h2>
<p className="mt-1 text-sm text-muted-foreground">
{researchEnabled
? "Research mode is active. Ask anything to search the web."
: youtubeEnabled
? "YouTube mode is active. Search for videos."
: `Using ${model.replace("yuuki-", "Yuuki ")} model. Start typing to chat.`
}
</p>
</div>
<div className="flex flex-wrap justify-center gap-2 mt-2">
{["Tell me about yourself", "Write a short poem", "Explain quantum computing"].map((s) => (
<button
key={s}
type="button"
onClick={() => setInput(s)}
className="rounded-full border border-border px-3 py-1.5 text-xs text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
{s}
</button>
))}
</div>
</div>
) : (
<div className="mx-auto max-w-3xl">
{messages.map((msg) => (
<ChatMessage key={msg.id} message={msg} accentColor={accentColor} />
))}
{loading && (
<div className="flex items-center gap-3 px-4 py-4">
<div className="flex h-8 w-8 items-center justify-center rounded-lg bg-muted">
<Loader2 className="h-4 w-4 animate-spin text-foreground" />
</div>
<div className="flex items-center gap-1.5">
<span className="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/40" style={{ animationDelay: "0ms" }} />
<span className="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/40" style={{ animationDelay: "150ms" }} />
<span className="h-2 w-2 animate-bounce rounded-full bg-muted-foreground/40" style={{ animationDelay: "300ms" }} />
</div>
</div>
)}
<div ref={messagesEndRef} />
</div>
)}
</div>
{/* Input area */}
<div className="border-t border-border glass p-4">
<div className="mx-auto max-w-3xl">
{/* Active mode indicator */}
{(researchEnabled || youtubeEnabled) && (
<div className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
{researchEnabled && (
<>
<Globe className="h-3 w-3" />
<span>Research mode active - results from Tavily</span>
</>
)}
{youtubeEnabled && (
<>
<Youtube className="h-3 w-3" />
<span>YouTube mode active - searching videos</span>
</>
)}
</div>
)}
<div className="flex items-end gap-2">
<div className="flex-1 rounded-xl border border-border bg-card transition-colors focus-within:border-foreground/20">
<textarea
ref={inputRef}
value={input}
onChange={handleInputChange}
onKeyDown={handleKeyDown}
placeholder={
researchEnabled
? "Search the web..."
: youtubeEnabled
? "Search YouTube..."
: "Message Yuuki..."
}
rows={1}
className="w-full resize-none bg-transparent px-4 py-3 text-sm text-foreground placeholder:text-muted-foreground/50 focus:outline-none"
disabled={loading}
/>
</div>
<button
type="button"
onClick={sendMessage}
disabled={!input.trim() || loading}
className="flex h-11 w-11 shrink-0 items-center justify-center rounded-xl text-white transition-all hover:opacity-90 disabled:opacity-40"
style={{ backgroundColor: accentColor }}
>
{loading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Send className="h-4 w-4" />
)}
</button>
</div>
<div className="mt-2 text-center">
<span className="text-[10px] text-muted-foreground/60">
Yuuki Chat can make mistakes. Verify important information.
</span>
</div>
</div>
</div>
</main>
{/* Theme panel */}
<ThemePanel open={themeOpen} onClose={() => setThemeOpen(false)} />
</div>
);
}

View File

@@ -0,0 +1,67 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
interface MacOSWindowProps {
title: string;
children: React.ReactNode;
className?: string;
bodyClassName?: string;
noPadding?: boolean;
onClose?: () => void;
onMinimize?: () => void;
onMaximize?: () => void;
}
export function MacOSWindow({
title,
children,
className,
bodyClassName,
noPadding,
onClose,
onMinimize,
onMaximize,
}: MacOSWindowProps) {
return (
<div
className={cn(
"overflow-hidden rounded-xl border border-border bg-card shadow-2xl",
className
)}
>
{/* Title bar */}
<div className="flex items-center gap-2 border-b border-border bg-muted/50 px-4 py-2.5">
<div className="flex gap-1.5">
<button
type="button"
onClick={onClose}
className="h-3 w-3 rounded-full bg-[#ff5f57] transition-opacity hover:opacity-80"
aria-label="Close"
/>
<button
type="button"
onClick={onMinimize}
className="h-3 w-3 rounded-full bg-[#febc2e] transition-opacity hover:opacity-80"
aria-label="Minimize"
/>
<button
type="button"
onClick={onMaximize}
className="h-3 w-3 rounded-full bg-[#28c840] transition-opacity hover:opacity-80"
aria-label="Maximize"
/>
</div>
<span className="flex-1 text-center text-xs text-muted-foreground font-mono select-none">
{title}
</span>
<div className="w-[52px]" />
</div>
{/* Content */}
<div className={cn(noPadding ? "" : "p-6", bodyClassName)}>
{children}
</div>
</div>
);
}

View File

@@ -0,0 +1,76 @@
"use client";
import React from "react";
import { cn } from "@/lib/utils";
import { ChevronDown, Cpu } from "lucide-react";
const MODELS = [
{ id: "yuuki-best", name: "Yuuki Best", tag: "Flagship" },
{ id: "yuuki-3.7", name: "Yuuki 3.7", tag: "Balanced" },
{ id: "yuuki-v0.1", name: "Yuuki v0.1", tag: "Fast" },
];
interface ModelSelectorProps {
value: string;
onChange: (model: string) => void;
}
export function ModelSelector({ value, onChange }: ModelSelectorProps) {
const [open, setOpen] = React.useState(false);
const ref = React.useRef<HTMLDivElement>(null);
const selected = MODELS.find((m) => m.id === value) || MODELS[0];
React.useEffect(() => {
const handler = (e: MouseEvent) => {
if (ref.current && !ref.current.contains(e.target as Node)) {
setOpen(false);
}
};
document.addEventListener("mousedown", handler);
return () => document.removeEventListener("mousedown", handler);
}, []);
return (
<div ref={ref} className="relative">
<button
type="button"
onClick={() => setOpen(!open)}
className="flex items-center gap-2 rounded-lg border border-border bg-card px-3 py-1.5 text-xs font-medium text-foreground transition-colors hover:bg-muted"
>
<Cpu className="h-3.5 w-3.5 text-muted-foreground" />
{selected.name}
<ChevronDown
className={cn(
"h-3 w-3 text-muted-foreground transition-transform",
open && "rotate-180"
)}
/>
</button>
{open && (
<div className="absolute top-full left-0 z-50 mt-1.5 min-w-[200px] overflow-hidden rounded-xl border border-border bg-card shadow-xl animate-in fade-in slide-in-from-top-1 duration-150">
{MODELS.map((m) => (
<button
key={m.id}
type="button"
onClick={() => {
onChange(m.id);
setOpen(false);
}}
className={cn(
"flex w-full items-center justify-between px-3 py-2.5 text-left text-sm transition-colors hover:bg-muted",
m.id === value && "bg-muted/50"
)}
>
<div className="flex flex-col">
<span className="font-medium text-foreground">{m.name}</span>
</div>
<span className="rounded-full bg-muted px-2 py-0.5 text-[10px] font-mono text-muted-foreground uppercase tracking-wider">
{m.tag}
</span>
</button>
))}
</div>
)}
</div>
);
}

175
components/theme-panel.tsx Normal file
View File

@@ -0,0 +1,175 @@
"use client";
import React, { useState } from "react";
import { useYuukiTheme, type ThemeMode } from "@/lib/theme-context";
import { Sun, Moon, Palette, X, Check } from "lucide-react";
import { cn } from "@/lib/utils";
const PRESET_COLORS = [
"#ff6b6b",
"#ffa07a",
"#ffd93d",
"#6bcb77",
"#4d96ff",
"#9b59b6",
"#ff69b4",
"#00d2d3",
"#f8b500",
"#ffffff",
"#0a0a0a",
"#c08b6e",
];
const MODES: { id: ThemeMode; label: string; icon: React.ElementType }[] = [
{ id: "light", label: "Light", icon: Sun },
{ id: "dark", label: "Dark", icon: Moon },
{ id: "pastel", label: "Pastel", icon: Palette },
];
interface ThemePanelProps {
open: boolean;
onClose: () => void;
}
export function ThemePanel({ open, onClose }: ThemePanelProps) {
const { mode, setMode, accentColor, setAccentColor } = useYuukiTheme();
const [customHex, setCustomHex] = useState(accentColor);
const handleHexChange = (val: string) => {
setCustomHex(val);
if (/^#[0-9a-fA-F]{6}$/.test(val)) {
setAccentColor(val);
}
};
if (!open) return null;
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-background/60 backdrop-blur-sm" onClick={onClose} />
<div className="relative w-full max-w-sm overflow-hidden rounded-xl border border-border bg-card shadow-2xl animate-in fade-in zoom-in-95 duration-200">
{/* Title bar */}
<div className="flex items-center justify-between border-b border-border bg-muted/50 px-4 py-2.5">
<div className="flex gap-1.5">
<div className="h-3 w-3 rounded-full bg-[#ff5f57]" />
<div className="h-3 w-3 rounded-full bg-[#febc2e]" />
<div className="h-3 w-3 rounded-full bg-[#28c840]" />
</div>
<span className="text-xs text-muted-foreground font-mono">theme settings</span>
<button
type="button"
onClick={onClose}
className="text-muted-foreground hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</div>
<div className="flex flex-col gap-6 p-5">
{/* Mode selection */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Appearance
</label>
<div className="grid grid-cols-3 gap-2">
{MODES.map((m) => {
const Icon = m.icon;
return (
<button
key={m.id}
type="button"
onClick={() => setMode(m.id)}
className={cn(
"flex flex-col items-center gap-1.5 rounded-lg border px-3 py-3 text-xs font-medium transition-all",
mode === m.id
? "border-foreground/30 bg-muted text-foreground"
: "border-border text-muted-foreground hover:border-foreground/10 hover:text-foreground"
)}
>
<Icon className="h-4 w-4" />
{m.label}
</button>
);
})}
</div>
</div>
{/* Accent color */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Accent Color
</label>
<div className="flex flex-wrap gap-2">
{PRESET_COLORS.map((color) => (
<button
key={color}
type="button"
onClick={() => { setAccentColor(color); setCustomHex(color); }}
className={cn(
"relative h-8 w-8 rounded-full border-2 transition-transform hover:scale-110",
accentColor === color ? "border-foreground" : "border-border"
)}
style={{ backgroundColor: color }}
>
{accentColor === color && (
<Check
className="absolute inset-0 m-auto h-3.5 w-3.5"
style={{
color:
color === "#ffffff" || color === "#ffd93d" || color === "#f8b500"
? "#000"
: "#fff",
}}
/>
)}
</button>
))}
</div>
</div>
{/* Custom hex input with color picker */}
<div className="flex flex-col gap-2.5">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Custom HEX
</label>
<div className="flex items-center gap-2">
<div className="relative">
<input
type="color"
value={customHex.startsWith("#") ? customHex : "#000000"}
onChange={(e) => handleHexChange(e.target.value)}
className="h-10 w-10 cursor-pointer rounded-lg border border-border bg-transparent p-0.5"
/>
</div>
<input
type="text"
value={customHex}
onChange={(e) => handleHexChange(e.target.value)}
placeholder="#ff6b6b"
maxLength={7}
className="flex-1 rounded-lg border border-border bg-background px-3 py-2 text-sm font-mono text-foreground placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-ring/20"
/>
</div>
</div>
{/* Preview */}
<div className="flex flex-col gap-2">
<label className="text-xs font-medium text-muted-foreground uppercase tracking-wider">
Preview
</label>
<div className="flex items-center gap-3 rounded-lg border border-border bg-muted/30 p-3">
<div
className="h-10 w-10 rounded-lg"
style={{ backgroundColor: accentColor }}
/>
<div className="flex-1">
<div className="text-sm font-medium text-foreground">Yuuki Chat</div>
<div className="text-xs text-muted-foreground font-mono">{accentColor}</div>
</div>
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,11 @@
'use client'
import * as React from 'react'
import {
ThemeProvider as NextThemesProvider,
type ThemeProviderProps,
} from 'next-themes'
export function ThemeProvider({ children, ...props }: ThemeProviderProps) {
return <NextThemesProvider {...props}>{children}</NextThemesProvider>
}

178
components/token-screen.tsx Normal file
View File

@@ -0,0 +1,178 @@
"use client";
import React, { useState } from "react";
import { MacOSWindow } from "./macos-window";
import { useAuth, type TokenSource } from "@/lib/auth-context";
import { Key, ExternalLink, Sparkles, Eye, EyeOff, ArrowRight } from "lucide-react";
export function TokenScreen() {
const { setAuth } = useAuth();
const [step, setStep] = useState<"choose" | "input">("choose");
const [selectedSource, setSelectedSource] = useState<TokenSource | null>(null);
const [tokenValue, setTokenValue] = useState("");
const [showToken, setShowToken] = useState(false);
const [error, setError] = useState("");
const [loading, setLoading] = useState(false);
const handleSourceSelect = (source: TokenSource) => {
if (source === "demo") {
setLoading(true);
// Demo mode: token is managed server-side via HF_DEMO_TOKEN env var
setAuth("__demo__", "demo");
return;
}
setSelectedSource(source);
setStep("input");
};
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (!tokenValue.trim()) {
setError("Please enter your API token");
return;
}
if (!selectedSource) return;
setAuth(tokenValue.trim(), selectedSource);
};
return (
<div className="flex min-h-screen items-center justify-center p-4 bg-background">
{/* Background pattern */}
<div className="pointer-events-none fixed inset-0 overflow-hidden">
<div className="absolute -top-40 -right-40 h-80 w-80 rounded-full bg-muted/30 blur-3xl" />
<div className="absolute -bottom-40 -left-40 h-80 w-80 rounded-full bg-muted/20 blur-3xl" />
</div>
<div className="relative w-full max-w-lg">
{step === "choose" ? (
<MacOSWindow title="yuuki-chat ~ authenticate" className="animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex flex-col items-center gap-8">
{/* Logo and title */}
<div className="flex flex-col items-center gap-3 pt-2">
<div className="flex h-14 w-14 items-center justify-center rounded-2xl bg-foreground">
<span className="text-2xl font-bold text-background font-mono">Y</span>
</div>
<div className="text-center">
<h1 className="text-2xl font-bold tracking-tight text-foreground text-balance">
Welcome to Yuuki Chat
</h1>
<p className="mt-1.5 text-sm text-muted-foreground leading-relaxed">
Choose how to authenticate to start chatting
</p>
</div>
</div>
{/* Two big buttons */}
<div className="grid w-full grid-cols-2 gap-3">
<button
type="button"
onClick={() => handleSourceSelect("yuuki-api")}
className="group flex flex-col items-center gap-3 rounded-xl border border-border bg-card p-6 transition-all hover:border-foreground/20 hover:bg-muted/50 hover:shadow-lg"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-foreground text-background transition-transform group-hover:scale-105">
<Key className="h-5 w-5" />
</div>
<div className="text-center">
<div className="text-sm font-semibold text-foreground">Yuuki API</div>
<div className="mt-0.5 text-xs text-muted-foreground">yuuki-api.vercel.app</div>
</div>
</button>
<button
type="button"
onClick={() => handleSourceSelect("huggingface")}
className="group flex flex-col items-center gap-3 rounded-xl border border-border bg-card p-6 transition-all hover:border-foreground/20 hover:bg-muted/50 hover:shadow-lg"
>
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-foreground text-background transition-transform group-hover:scale-105">
<ExternalLink className="h-5 w-5" />
</div>
<div className="text-center">
<div className="text-sm font-semibold text-foreground">Hugging Face</div>
<div className="mt-0.5 text-xs text-muted-foreground">huggingface.co token</div>
</div>
</button>
</div>
{/* Small demo button */}
<button
type="button"
onClick={() => handleSourceSelect("demo")}
disabled={loading}
className="flex items-center gap-2 rounded-lg px-4 py-2 text-xs text-muted-foreground transition-colors hover:text-foreground hover:bg-muted/50 disabled:opacity-50"
>
<Sparkles className="h-3.5 w-3.5" />
{loading ? "Connecting..." : "Use demo"}
</button>
</div>
</MacOSWindow>
) : (
<MacOSWindow
title={`yuuki-chat ~ ${selectedSource === "yuuki-api" ? "yuuki-api token" : "hugging face token"}`}
className="animate-in fade-in slide-in-from-bottom-4 duration-500"
onClose={() => { setStep("choose"); setError(""); setTokenValue(""); }}
>
<form onSubmit={handleSubmit} className="flex flex-col gap-6">
<div className="flex flex-col items-center gap-3 pt-2">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-foreground text-background">
{selectedSource === "yuuki-api" ? (
<Key className="h-5 w-5" />
) : (
<ExternalLink className="h-5 w-5" />
)}
</div>
<div className="text-center">
<h2 className="text-lg font-semibold text-foreground">
{selectedSource === "yuuki-api" ? "Enter Yuuki API Token" : "Enter Hugging Face Token"}
</h2>
<p className="mt-1 text-xs text-muted-foreground leading-relaxed">
{selectedSource === "yuuki-api"
? "Get your token from yuuki-api.vercel.app"
: "Get your token from huggingface.co/settings/tokens"}
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<div className="relative">
<input
type={showToken ? "text" : "password"}
value={tokenValue}
onChange={(e) => { setTokenValue(e.target.value); setError(""); }}
placeholder={selectedSource === "yuuki-api" ? "yk-xxxxxxxxxx" : "hf_xxxxxxxxxx"}
className="w-full rounded-lg border border-border bg-background px-4 py-3 pr-10 text-sm text-foreground font-mono placeholder:text-muted-foreground/50 focus:outline-none focus:ring-2 focus:ring-ring/20 focus:border-foreground/30"
autoFocus
/>
<button
type="button"
onClick={() => setShowToken(!showToken)}
className="absolute right-3 top-1/2 -translate-y-1/2 text-muted-foreground hover:text-foreground"
>
{showToken ? <EyeOff className="h-4 w-4" /> : <Eye className="h-4 w-4" />}
</button>
</div>
{error && <p className="text-xs text-destructive">{error}</p>}
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => { setStep("choose"); setError(""); setTokenValue(""); }}
className="flex-1 rounded-lg border border-border py-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-muted hover:text-foreground"
>
Back
</button>
<button
type="submit"
className="flex flex-1 items-center justify-center gap-2 rounded-lg bg-foreground py-2.5 text-sm font-medium text-background transition-opacity hover:opacity-90"
>
Continue
<ArrowRight className="h-3.5 w-3.5" />
</button>
</div>
</form>
</MacOSWindow>
)}
</div>
</div>
);
}