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:
189
app/api/chat/route.ts
Normal file
189
app/api/chat/route.ts
Normal file
@@ -0,0 +1,189 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
const HF_MODELS: Record<string, string> = {
|
||||
"yuuki-v0.1":
|
||||
"https://api-inference.huggingface.co/models/YuuKi-OS/Yuuki-v0.1",
|
||||
"yuuki-3.7":
|
||||
"https://api-inference.huggingface.co/models/YuuKi-OS/Yuuki-3.7",
|
||||
"yuuki-best":
|
||||
"https://api-inference.huggingface.co/models/YuuKi-OS/Yuuki-best",
|
||||
};
|
||||
|
||||
const YUUKI_API_MODELS: Record<string, string> = {
|
||||
"yuuki-v0.1": "yuuki-v0.1",
|
||||
"yuuki-3.7": "yuuki-3.7",
|
||||
"yuuki-best": "yuuki-best",
|
||||
};
|
||||
|
||||
/**
|
||||
* Calls the Yuuki API (yuuki-api.vercel.app) with a yk- token.
|
||||
* This is an OpenAI-compatible endpoint.
|
||||
*/
|
||||
async function callYuukiApi(
|
||||
token: string,
|
||||
model: string,
|
||||
messages: { role: string; content: string }[]
|
||||
) {
|
||||
const modelId = YUUKI_API_MODELS[model] || "yuuki-best";
|
||||
|
||||
const response = await fetch("https://yuuki-api.vercel.app/api/chat", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify({
|
||||
model: modelId,
|
||||
messages,
|
||||
max_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`Yuuki API error (${response.status}): ${errorText.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
const content =
|
||||
data.choices?.[0]?.message?.content || data.content || "No response";
|
||||
return { content, id: data.id || `chatcmpl-${Date.now()}`, model: modelId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Calls HuggingFace Inference API directly with an hf_ token.
|
||||
*/
|
||||
async function callHuggingFace(
|
||||
token: string,
|
||||
model: string,
|
||||
messages: { role: string; content: string }[]
|
||||
) {
|
||||
const modelUrl = HF_MODELS[model] || HF_MODELS["yuuki-best"];
|
||||
|
||||
const prompt =
|
||||
messages
|
||||
.map((m) => {
|
||||
if (m.role === "system") return `System: ${m.content}`;
|
||||
if (m.role === "user") return `User: ${m.content}`;
|
||||
if (m.role === "assistant") return `Assistant: ${m.content}`;
|
||||
return m.content;
|
||||
})
|
||||
.join("\n") + "\nAssistant:";
|
||||
|
||||
const response = await fetch(modelUrl, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
inputs: prompt,
|
||||
parameters: {
|
||||
max_new_tokens: 1024,
|
||||
temperature: 0.7,
|
||||
top_p: 0.9,
|
||||
repetition_penalty: 1.1,
|
||||
return_full_text: false,
|
||||
},
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorText = await response.text();
|
||||
throw new Error(
|
||||
`HuggingFace error (${response.status}): ${errorText.slice(0, 200)}`
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
let generatedText = "";
|
||||
|
||||
if (Array.isArray(data) && data[0]?.generated_text) {
|
||||
generatedText = data[0].generated_text.trim();
|
||||
} else if (typeof data === "string") {
|
||||
generatedText = data.trim();
|
||||
} else if (data?.generated_text) {
|
||||
generatedText = data.generated_text.trim();
|
||||
} else {
|
||||
generatedText = JSON.stringify(data);
|
||||
}
|
||||
|
||||
// Clean up artifacts
|
||||
const cutoffs = ["User:", "System:", "\nUser", "\nSystem"];
|
||||
for (const cutoff of cutoffs) {
|
||||
const idx = generatedText.indexOf(cutoff);
|
||||
if (idx > 0) generatedText = generatedText.substring(0, idx).trim();
|
||||
}
|
||||
|
||||
return {
|
||||
content:
|
||||
generatedText ||
|
||||
"I received your message but couldn't generate a response. Please try again.",
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
model,
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { messages, model, token, tokenSource } = body;
|
||||
|
||||
if (!messages || !Array.isArray(messages)) {
|
||||
return NextResponse.json(
|
||||
{ error: "messages is required" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const modelKey = model || "yuuki-best";
|
||||
if (!HF_MODELS[modelKey]) {
|
||||
return NextResponse.json({ error: "Invalid model" }, { status: 400 });
|
||||
}
|
||||
|
||||
let result;
|
||||
|
||||
if (tokenSource === "demo") {
|
||||
// Demo mode: use server-side HF_DEMO_TOKEN directly against HuggingFace
|
||||
const demoToken = process.env.HF_DEMO_TOKEN;
|
||||
if (!demoToken) {
|
||||
return NextResponse.json(
|
||||
{ error: "Demo token not configured on server" },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
result = await callHuggingFace(demoToken, modelKey, messages);
|
||||
} else if (tokenSource === "yuuki-api") {
|
||||
// Yuuki API: yk- tokens go to yuuki-api.vercel.app
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "No API token provided" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
result = await callYuukiApi(token, modelKey, messages);
|
||||
} else if (tokenSource === "huggingface") {
|
||||
// HuggingFace: hf_ tokens go directly to HF Inference API
|
||||
if (!token) {
|
||||
return NextResponse.json(
|
||||
{ error: "No API token provided" },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
result = await callHuggingFace(token, modelKey, messages);
|
||||
} else {
|
||||
return NextResponse.json(
|
||||
{ error: "Invalid token source" },
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(result);
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
54
app/api/research/route.ts
Normal file
54
app/api/research/route.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { query } = await req.json();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "query is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.TAVILY_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "Tavily API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
const response = await fetch("https://api.tavily.com/search", {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
api_key: apiKey,
|
||||
query,
|
||||
search_depth: "advanced",
|
||||
include_answer: true,
|
||||
include_raw_content: false,
|
||||
max_results: 5,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: `Tavily error: ${response.status}`, details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
return NextResponse.json({
|
||||
answer: data.answer || "",
|
||||
results: (data.results || []).map(
|
||||
(r: { title: string; url: string; content: string; score: number }) => ({
|
||||
title: r.title,
|
||||
url: r.url,
|
||||
content: r.content,
|
||||
score: r.score,
|
||||
})
|
||||
),
|
||||
});
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
62
app/api/youtube/route.ts
Normal file
62
app/api/youtube/route.ts
Normal file
@@ -0,0 +1,62 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
|
||||
export async function POST(req: NextRequest) {
|
||||
try {
|
||||
const { query } = await req.json();
|
||||
|
||||
if (!query) {
|
||||
return NextResponse.json({ error: "query is required" }, { status: 400 });
|
||||
}
|
||||
|
||||
const apiKey = process.env.YOUTUBE_API_KEY;
|
||||
if (!apiKey) {
|
||||
return NextResponse.json({ error: "YouTube API key not configured" }, { status: 500 });
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
part: "snippet",
|
||||
q: query,
|
||||
key: apiKey,
|
||||
maxResults: "5",
|
||||
type: "video",
|
||||
});
|
||||
|
||||
const response = await fetch(
|
||||
`https://www.googleapis.com/youtube/v3/search?${params.toString()}`
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
const text = await response.text();
|
||||
return NextResponse.json(
|
||||
{ error: `YouTube error: ${response.status}`, details: text },
|
||||
{ status: response.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
|
||||
const videos = (data.items || []).map(
|
||||
(item: {
|
||||
id: { videoId: string };
|
||||
snippet: {
|
||||
title: string;
|
||||
description: string;
|
||||
channelTitle: string;
|
||||
thumbnails: { medium: { url: string } };
|
||||
};
|
||||
}) => ({
|
||||
id: item.id.videoId,
|
||||
title: item.snippet.title,
|
||||
description: item.snippet.description,
|
||||
channel: item.snippet.channelTitle,
|
||||
thumbnail: item.snippet.thumbnails?.medium?.url || "",
|
||||
url: `https://youtube.com/watch?v=${item.id.videoId}`,
|
||||
})
|
||||
);
|
||||
|
||||
return NextResponse.json({ videos });
|
||||
} catch (error) {
|
||||
const message = error instanceof Error ? error.message : "Unknown error";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
}
|
||||
}
|
||||
200
app/globals.css
Normal file
200
app/globals.css
Normal file
@@ -0,0 +1,200 @@
|
||||
@import 'tailwindcss';
|
||||
@import 'tw-animate-css';
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
|
||||
/* ── Light ── */
|
||||
:root {
|
||||
--background: #fafafa;
|
||||
--foreground: #0a0a0a;
|
||||
--card: #ffffff;
|
||||
--card-foreground: #0a0a0a;
|
||||
--popover: #ffffff;
|
||||
--popover-foreground: #0a0a0a;
|
||||
--primary: #0a0a0a;
|
||||
--primary-foreground: #fafafa;
|
||||
--secondary: #f0f0f0;
|
||||
--secondary-foreground: #0a0a0a;
|
||||
--muted: #f0f0f0;
|
||||
--muted-foreground: #6b6b6b;
|
||||
--accent: #e8e8e8;
|
||||
--accent-foreground: #0a0a0a;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #e0e0e0;
|
||||
--input: #e0e0e0;
|
||||
--ring: #0a0a0a;
|
||||
--radius: 0.625rem;
|
||||
--user-accent: #0a0a0a;
|
||||
--glass: rgba(255, 255, 255, 0.72);
|
||||
--glass-border: rgba(0, 0, 0, 0.08);
|
||||
/* Shadcn/ui default additions */
|
||||
--chart-1: oklch(0.646 0.222 41.116);
|
||||
--chart-2: oklch(0.6 0.118 184.704);
|
||||
--chart-3: oklch(0.398 0.07 227.392);
|
||||
--chart-4: oklch(0.828 0.189 84.429);
|
||||
--chart-5: oklch(0.769 0.188 70.08);
|
||||
--sidebar: oklch(0.985 0 0);
|
||||
--sidebar-foreground: oklch(0.145 0 0);
|
||||
--sidebar-primary: oklch(0.205 0 0);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.97 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.205 0 0);
|
||||
--sidebar-border: oklch(0.922 0 0);
|
||||
--sidebar-ring: oklch(0.708 0 0);
|
||||
}
|
||||
|
||||
/* ── Dark ── */
|
||||
.dark {
|
||||
--background: #09090b;
|
||||
--foreground: #fafafa;
|
||||
--card: #111113;
|
||||
--card-foreground: #fafafa;
|
||||
--popover: #111113;
|
||||
--popover-foreground: #fafafa;
|
||||
--primary: #fafafa;
|
||||
--primary-foreground: #09090b;
|
||||
--secondary: #1c1c1f;
|
||||
--secondary-foreground: #fafafa;
|
||||
--muted: #1c1c1f;
|
||||
--muted-foreground: #8b8b8b;
|
||||
--accent: #252528;
|
||||
--accent-foreground: #fafafa;
|
||||
--destructive: #dc2626;
|
||||
--destructive-foreground: #fafafa;
|
||||
--border: #252528;
|
||||
--input: #252528;
|
||||
--ring: #fafafa;
|
||||
--user-accent: #fafafa;
|
||||
--glass: rgba(17, 17, 19, 0.78);
|
||||
--glass-border: rgba(255, 255, 255, 0.06);
|
||||
/* Shadcn/ui default additions */
|
||||
--chart-1: oklch(0.488 0.243 264.376);
|
||||
--chart-2: oklch(0.696 0.17 162.48);
|
||||
--chart-3: oklch(0.769 0.188 70.08);
|
||||
--chart-4: oklch(0.627 0.265 303.9);
|
||||
--chart-5: oklch(0.645 0.246 16.439);
|
||||
--sidebar: oklch(0.205 0 0);
|
||||
--sidebar-foreground: oklch(0.985 0 0);
|
||||
--sidebar-primary: oklch(0.488 0.243 264.376);
|
||||
--sidebar-primary-foreground: oklch(0.985 0 0);
|
||||
--sidebar-accent: oklch(0.269 0 0);
|
||||
--sidebar-accent-foreground: oklch(0.985 0 0);
|
||||
--sidebar-border: oklch(0.269 0 0);
|
||||
--sidebar-ring: oklch(0.439 0 0);
|
||||
}
|
||||
|
||||
/* ── Pastel ── */
|
||||
.pastel {
|
||||
--background: #fdf6f0;
|
||||
--foreground: #2d2420;
|
||||
--card: #fff9f5;
|
||||
--card-foreground: #2d2420;
|
||||
--popover: #fff9f5;
|
||||
--popover-foreground: #2d2420;
|
||||
--primary: #c08b6e;
|
||||
--primary-foreground: #fff9f5;
|
||||
--secondary: #f5ece5;
|
||||
--secondary-foreground: #2d2420;
|
||||
--muted: #f5ece5;
|
||||
--muted-foreground: #8a7568;
|
||||
--accent: #ecddd1;
|
||||
--accent-foreground: #2d2420;
|
||||
--destructive: #c97070;
|
||||
--destructive-foreground: #fff9f5;
|
||||
--border: #e8d8cc;
|
||||
--input: #e8d8cc;
|
||||
--ring: #c08b6e;
|
||||
--user-accent: #c08b6e;
|
||||
--glass: rgba(253, 246, 240, 0.78);
|
||||
--glass-border: rgba(192, 139, 110, 0.12);
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--font-sans: 'Geist', 'Geist Fallback', system-ui, sans-serif;
|
||||
--font-mono: 'Geist Mono', 'Geist Mono Fallback', monospace;
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--color-card: var(--card);
|
||||
--color-card-foreground: var(--card-foreground);
|
||||
--color-popover: var(--popover);
|
||||
--color-popover-foreground: var(--popover-foreground);
|
||||
--color-primary: var(--primary);
|
||||
--color-primary-foreground: var(--primary-foreground);
|
||||
--color-secondary: var(--secondary);
|
||||
--color-secondary-foreground: var(--secondary-foreground);
|
||||
--color-muted: var(--muted);
|
||||
--color-muted-foreground: var(--muted-foreground);
|
||||
--color-accent: var(--accent);
|
||||
--color-accent-foreground: var(--accent-foreground);
|
||||
--color-destructive: var(--destructive);
|
||||
--color-destructive-foreground: var(--destructive-foreground);
|
||||
--color-border: var(--border);
|
||||
--color-input: var(--input);
|
||||
--color-ring: var(--ring);
|
||||
--radius-sm: calc(var(--radius) - 4px);
|
||||
--radius-md: calc(var(--radius) - 2px);
|
||||
--radius-lg: var(--radius);
|
||||
--radius-xl: calc(var(--radius) + 4px);
|
||||
/* Shadcn/ui default additions */
|
||||
--color-chart-1: var(--chart-1);
|
||||
--color-chart-2: var(--chart-2);
|
||||
--color-chart-3: var(--chart-3);
|
||||
--color-chart-4: var(--chart-4);
|
||||
--color-chart-5: var(--chart-5);
|
||||
--color-sidebar: var(--sidebar);
|
||||
--color-sidebar-foreground: var(--sidebar-foreground);
|
||||
--color-sidebar-primary: var(--sidebar-primary);
|
||||
--color-sidebar-primary-foreground: var(--sidebar-primary-foreground);
|
||||
--color-sidebar-accent: var(--sidebar-accent);
|
||||
--color-sidebar-accent-foreground: var(--sidebar-accent-foreground);
|
||||
--color-sidebar-border: var(--sidebar-border);
|
||||
--color-sidebar-ring: var(--sidebar-ring);
|
||||
}
|
||||
|
||||
@layer base {
|
||||
* {
|
||||
@apply border-border outline-ring/50;
|
||||
}
|
||||
body {
|
||||
@apply bg-background text-foreground;
|
||||
}
|
||||
}
|
||||
|
||||
/* Custom scrollbar */
|
||||
.custom-scrollbar::-webkit-scrollbar {
|
||||
width: 6px;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb {
|
||||
background: var(--muted-foreground);
|
||||
border-radius: 3px;
|
||||
opacity: 0.3;
|
||||
}
|
||||
.custom-scrollbar::-webkit-scrollbar-thumb:hover {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
/* Typing animation */
|
||||
@keyframes blink {
|
||||
0%, 100% { opacity: 1; }
|
||||
50% { opacity: 0; }
|
||||
}
|
||||
.cursor-blink {
|
||||
animation: blink 1s step-end infinite;
|
||||
}
|
||||
|
||||
/* Glass morphism utility */
|
||||
.glass {
|
||||
background: var(--glass);
|
||||
backdrop-filter: blur(20px);
|
||||
-webkit-backdrop-filter: blur(20px);
|
||||
border-color: var(--glass-border);
|
||||
}
|
||||
|
||||
/* Smooth transitions for theme changes */
|
||||
html {
|
||||
transition: background-color 0.3s ease, color 0.3s ease;
|
||||
}
|
||||
71
app/layout.tsx
Normal file
71
app/layout.tsx
Normal file
@@ -0,0 +1,71 @@
|
||||
import React from "react";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import { Analytics } from "@vercel/analytics/next";
|
||||
import { Toaster } from "sonner";
|
||||
import { YuukiThemeProvider } from "@/lib/theme-context";
|
||||
import { AuthProvider } from "@/lib/auth-context";
|
||||
import "./globals.css";
|
||||
|
||||
const _geist = Geist({ subsets: ["latin"] });
|
||||
const _geistMono = Geist_Mono({ subsets: ["latin"] });
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Yuuki Chat - AI Chat Interface for Yuuki Models",
|
||||
description:
|
||||
"A beautiful macOS-inspired AI chat interface powered by Yuuki language models. Features research, YouTube search, and customizable themes.",
|
||||
icons: {
|
||||
icon: [
|
||||
{
|
||||
url: "/icon-light-32x32.png",
|
||||
media: "(prefers-color-scheme: light)",
|
||||
},
|
||||
{
|
||||
url: "/icon-dark-32x32.png",
|
||||
media: "(prefers-color-scheme: dark)",
|
||||
},
|
||||
{
|
||||
url: "/icon.svg",
|
||||
type: "image/svg+xml",
|
||||
},
|
||||
],
|
||||
apple: "/apple-icon.png",
|
||||
},
|
||||
generator: 'v0.app'
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
themeColor: [
|
||||
{ media: "(prefers-color-scheme: light)", color: "#fafafa" },
|
||||
{ media: "(prefers-color-scheme: dark)", color: "#09090b" },
|
||||
],
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
children: React.ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en" className="dark" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
<AuthProvider>
|
||||
<YuukiThemeProvider>
|
||||
{children}
|
||||
<Toaster
|
||||
position="top-center"
|
||||
toastOptions={{
|
||||
className: "bg-card text-card-foreground border-border",
|
||||
}}
|
||||
/>
|
||||
</YuukiThemeProvider>
|
||||
</AuthProvider>
|
||||
<Analytics />
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
15
app/page.tsx
Normal file
15
app/page.tsx
Normal file
@@ -0,0 +1,15 @@
|
||||
"use client";
|
||||
|
||||
import { useAuth } from "@/lib/auth-context";
|
||||
import { TokenScreen } from "@/components/token-screen";
|
||||
import { ChatWindow } from "@/components/chat-window";
|
||||
|
||||
export default function Home() {
|
||||
const { isAuthenticated } = useAuth();
|
||||
|
||||
if (!isAuthenticated) {
|
||||
return <TokenScreen />;
|
||||
}
|
||||
|
||||
return <ChatWindow />;
|
||||
}
|
||||
Reference in New Issue
Block a user