mirror of
https://github.com/YuuKi-OS/Yuuki-chat.git
synced 2026-02-18 22:01:09 +00:00
yuuki-deidad
This commit is contained in:
154
components/chat-message.tsx
Normal file
154
components/chat-message.tsx
Normal 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
513
components/chat-window.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
67
components/macos-window.tsx
Normal file
67
components/macos-window.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
76
components/model-selector.tsx
Normal file
76
components/model-selector.tsx
Normal 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
175
components/theme-panel.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
11
components/theme-provider.tsx
Normal file
11
components/theme-provider.tsx
Normal 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
178
components/token-screen.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user