commit efdbd95700910237fcc77db958baa90817e201e8 Author: aguitauwu Date: Mon Feb 9 19:36:27 2026 -0600 yuuki-deidad diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..9aac940 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Yuuki y OpceanAI + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..9b3ef9e --- /dev/null +++ b/README.md @@ -0,0 +1,620 @@ +
+ +
+ +Yuuki Chat + +

+ +# AI Chat Interface for Yuuki Models + +**macOS-inspired chat UI. Three model variants. Web research. YouTube search.**
+**Customizable themes with dark, pastel, and light modes plus custom hex accents.** + +
+ +Features +   +Live App +   +Sponsor + +

+ +[![License](https://img.shields.io/badge/MIT-222222?style=flat-square&logo=opensourceinitiative&logoColor=white)](LICENSE) +  +[![Next.js](https://img.shields.io/badge/Next.js_16-222222?style=flat-square&logo=nextdotjs&logoColor=white)](https://nextjs.org/) +  +[![React](https://img.shields.io/badge/React_19-222222?style=flat-square&logo=react&logoColor=white)](https://react.dev/) +  +[![Tailwind](https://img.shields.io/badge/Tailwind_v4-222222?style=flat-square&logo=tailwindcss&logoColor=white)](https://tailwindcss.com/) +  +[![TypeScript](https://img.shields.io/badge/TypeScript-222222?style=flat-square&logo=typescript&logoColor=white)](https://www.typescriptlang.org/) +  +[![Vercel](https://img.shields.io/badge/Vercel-222222?style=flat-square&logo=vercel&logoColor=white)](https://vercel.com/) + +
+ +--- + +
+ + + + + + +
+ +**Full-featured chat client.**

+Three Yuuki model variants.
+macOS-style window chrome UI.
+Conversation history sidebar.
+Tavily-powered web research mode.
+YouTube video search integration.
+Markdown message rendering.
+Copy-to-clipboard on all responses. + +
+ +**Deeply customizable.**

+Dark, pastel, and light themes.
+12 preset accent colors.
+Native color picker integration.
+Custom HEX input field.
+Persistent theme preferences.
+
+Optimized for Vercel deployment. + +
+ +
+ +
+ +--- + +
+ +
+ +## What is Yuuki Chat? + +
+ +
+ +**Yuuki Chat** is a beautiful, macOS-inspired chat interface for the [Yuuki language models](https://huggingface.co/YuuKi-OS). It provides two authentication paths -- users can connect with a **Yuuki API key** (`yk-xxxxxxxx`) from [yuuki-api.vercel.app](https://yuuki-api.vercel.app) or with a **Hugging Face token** (`hf_xxxxxxxx`) for direct inference. A built-in **demo mode** lets users try the app instantly using a server-provided token. + +The interface follows a ChatGPT-style conversational layout wrapped in macOS window chrome with traffic light buttons, a conversation sidebar, model switching, and two special modes: **Research** (powered by Tavily) for web search with sourced answers, and **YouTube** for video search via the YouTube Data API v3. + +Built with **Next.js 16**, **Tailwind CSS v4**, and **TypeScript**. Fully optimized for Vercel deployment. + +
+ +--- + +
+ +
+ +## Features + +
+ +
+ + + + + + +
+ +

Dual Authentication

+ +Two large buttons on the token screen: **Yuuki API** (for `yk-` tokens from yuuki-api.vercel.app) and **Hugging Face** (for `hf_` tokens from huggingface.co). A small **"Use demo"** button uses the server's `HF_DEMO_TOKEN` for instant access. Yuuki API tokens route through the OpenAI-compatible endpoint at yuuki-api.vercel.app, while HF tokens call the Inference API directly. + +
+ +

macOS Window Chrome

+ +Reusable `MacOSWindow` component with red/yellow/green traffic light dots and a monospace title bar. Used for the token screen, theme settings panel, and visual consistency throughout. Smooth entry animations with backdrop blur effects. + +
+ +

Three Model Variants

+ +Dropdown selector to switch between **Yuuki Best** (flagship), **Yuuki 3.7** (balanced), and **Yuuki v0.1** (fast). Each model maps to its HuggingFace endpoint or Yuuki API model ID. Selection persists across messages within a conversation. + +
+ +

Conversation Management

+ +Sidebar with conversation history, auto-generated titles from the first message, new chat creation, and per-conversation deletion. Conversations are managed client-side with React state. The sidebar collapses on mobile with an overlay. + +
+ +

Web Research Mode

+ +Toggle to enable Tavily-powered web research. When active, user queries are sent to the Tavily Search API with advanced depth and answer generation. Results include a synthesized answer plus source cards with titles, URLs, and content snippets. Sources are clickable links. + +
+ +

YouTube Search Mode

+ +Toggle to search YouTube via the Data API v3. Returns video cards with thumbnails, titles, channel names, and direct links. Thumbnails are rendered inline in the chat. Mutually exclusive with Research mode. + +
+ +

Theme Customization

+ +Full theme panel with three appearance modes: **Dark** (near-black), **Pastel** (warm cream tones), and **Light** (clean white). 12 preset accent colors for message bubbles and the send button. A native `` picker and a custom HEX text input for precise color selection. All preferences saved to localStorage. + +
+ +

Responsive Design

+ +Mobile-first layout. Sidebar collapses to an overlay on small screens. Input area auto-resizes. Research/YouTube toggle labels hide on mobile, showing only icons. Glass morphism effects on the top bar and input area. + +
+ +
+ +--- + +
+ +
+ +## Authentication Flow + +
+ +
+ +``` + Token Screen + | + +--------------+--------------+ + | | | + Yuuki API Hugging Face Demo + (yk-xxxx) (hf_xxxx) (server) + | | | + v v v + yuuki-api.vercel.app HuggingFace HF_DEMO_TOKEN + /api/chat Inference API (server-side) + (OpenAI-compatible) (direct) via HF API + | | | + +--------------+--------------+ + | + Chat Response +``` + +| Method | Token Format | Backend Route | +|:-------|:-------------|:--------------| +| Yuuki API | `yk-xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx` | `POST yuuki-api.vercel.app/api/chat` (OpenAI-compatible) | +| Hugging Face | `hf_xxxxxxxxxxxxxxxxxxxxxxxx` | `POST api-inference.huggingface.co/models/YuuKi-OS/*` | +| Demo | None (server-managed) | Uses `HF_DEMO_TOKEN` env var server-side | + +
+ +--- + +
+ +
+ +## Models + +
+ +
+ +| Model ID | Name | HuggingFace Endpoint | Description | +|:---------|:-----|:---------------------|:------------| +| `yuuki-best` | Yuuki Best | `YuuKi-OS/Yuuki-best` | Flagship model with best overall quality | +| `yuuki-3.7` | Yuuki 3.7 | `YuuKi-OS/Yuuki-3.7` | Balanced model for speed and quality | +| `yuuki-v0.1` | Yuuki v0.1 | `YuuKi-OS/Yuuki-v0.1` | Lightweight first generation model | + +
+ +--- + +
+ +
+ +## Design System + +
+ +
+ +### Color Themes + +| Theme | Background | Foreground | Card | Accent | +|:------|:-----------|:-----------|:-----|:-------| +| **Dark** | `#09090b` | `#fafafa` | `#111113` | User-selected | +| **Pastel** | `#fdf6f0` | `#2d2420` | `#fff9f5` | User-selected | +| **Light** | `#fafafa` | `#0a0a0a` | `#ffffff` | User-selected | + +
+ +### Typography + +| Role | Font | Weight | +|:-----|:-----|:-------| +| Headings | Geist | Bold (700) | +| Body text | Geist | Regular (400) | +| Code / Labels | Geist Mono | Regular (400) | + +
+ +### Design Principles + +- **macOS window chrome.** Traffic light dots, monospace title bars, bordered panels. Consistent visual language across all modal surfaces. +- **Three appearance modes.** Dark (default), Pastel (warm), Light (clean). Each with carefully tuned background, foreground, card, muted, and border tokens. +- **User-controlled accent.** 12 preset colors plus a native color picker and HEX input. Accent color applies to user message bubbles and the send button. +- **Glass morphism.** Top bar and input area use backdrop blur with semi-transparent backgrounds for depth. +- **Mobile-first responsive.** Flexbox layouts, collapsible sidebar, auto-resizing textarea, responsive toggle labels. + +
+ +--- + +
+ +
+ +## Tech Stack + +
+ +
+ +| Technology | Version | Purpose | +|:-----------|:--------|:--------| +| **Next.js** | 16 | React framework, App Router, API Routes | +| **React** | 19 | UI component library | +| **TypeScript** | 5.x | Type safety | +| **Tailwind CSS** | 4 | Utility-first styling with `@theme inline` tokens | +| **shadcn/ui** | Latest | Base component primitives | +| **Lucide React** | Latest | Icon library | +| **Geist** | Latest | Font family (sans + mono) | +| **SWR** | Latest | Client-side data fetching | +| **Sonner** | Latest | Toast notifications | +| **Vercel Analytics** | Latest | Page view tracking | + +
+ +### External Services + +| Service | Method | Purpose | +|:--------|:-------|:--------| +| Yuuki API | REST (OpenAI-compatible) | Chat completions via `yk-` tokens | +| HuggingFace Inference API | REST | Direct model inference via `hf_` tokens | +| Tavily Search API | REST | Web research with sourced answers | +| YouTube Data API v3 | REST | Video search results | + +
+ +--- + +
+ +
+ +## Architecture + +
+ +
+ +``` + User (Browser) + | + Token Screen (3 options) + | + +-------+-------+ + | | + Authenticated Demo Mode + (yk- or hf_) (server token) + | | + v v + +-------------------------------------------------------------+ + | Yuuki Chat (Next.js 16) | + | | + | app/ | + | layout.tsx Root layout, fonts, metadata | + | globals.css Tailwind v4, 3 theme modes | + | page.tsx Auth gate -> TokenScreen / Chat | + | | + | app/api/ | + | chat/route.ts Routes to YuukiAPI or HF | + | research/route.ts Tavily web search | + | youtube/route.ts YouTube Data API v3 | + | | + | components/ | + | token-screen.tsx Auth with 3 options | + | chat-window.tsx Main chat layout + sidebar | + | chat-message.tsx Message bubbles + research/yt | + | model-selector.tsx Model dropdown | + | theme-panel.tsx Theme customization modal | + | macos-window.tsx Reusable window chrome | + | | + | lib/ | + | auth-context.tsx Token & source state | + | theme-context.tsx Theme mode & accent color | + +-------------------+-------------------+---------------------+ + | | + +-------------+--+ +----------+---------+ + | Yuuki API | | HuggingFace | + | vercel.app | | Inference API | + | | | | + | yk- tokens | | hf_ tokens | + | OpenAI compat | | Direct inference | + +----------------+ +--------------------+ + | + +--------------+--------------+ + | | + +-----+------+ +---------+--------+ + | Tavily | | YouTube | + | Search | | Data API v3 | + +------------+ +------------------+ +``` + +
+ +### Source Layout + +``` +yuuki-chat/ + app/ + layout.tsx # root layout, Geist fonts, metadata + globals.css # Tailwind v4, 3 theme modes, glass effects + page.tsx # auth gate (token screen vs chat) + api/ + chat/route.ts # routes to Yuuki API or HuggingFace + research/route.ts # Tavily web search + youtube/route.ts # YouTube Data API v3 search + components/ + token-screen.tsx # 3-option auth screen + chat-window.tsx # main chat UI with sidebar + chat-message.tsx # message bubbles with research/yt + model-selector.tsx # model dropdown + theme-panel.tsx # theme customization modal + macos-window.tsx # reusable macOS window chrome + lib/ + auth-context.tsx # token & source state management + theme-context.tsx # theme mode & accent color state + utils.ts # cn utility +``` + +
+ +--- + +
+ +
+ +## Installation + +
+ +
+ +### Prerequisites + +- [Node.js](https://nodejs.org/) 18 or later +- [pnpm](https://pnpm.io/) (recommended) or npm + +
+ +### Clone and Run + +```bash +git clone https://github.com/YuuKi-OS/yuuki-chat +cd yuuki-chat +pnpm install +pnpm dev +``` + +The app will be available at `http://localhost:3000`. + +
+ +### Build for Production + +```bash +pnpm build +pnpm start +``` + +
+ +--- + +
+ +
+ +## Deploy to Vercel + +
+ +
+ +The recommended deployment method. Connect the repo to Vercel and configure the environment variables. + +```bash +# Or deploy manually with the Vercel CLI +npx vercel +``` + +
+ +### Environment Variables + +Configure these in your Vercel project settings under **Settings > Environment Variables**: + +| Variable | Required | Description | +|:---------|:---------|:------------| +| `HF_DEMO_TOKEN` | **Yes** | Hugging Face API token used for the "Use demo" button. Get one at [huggingface.co/settings/tokens](https://huggingface.co/settings/tokens) | +| `TAVILY_API_KEY` | **Yes** | Tavily API key for the Research mode. Get one at [tavily.com](https://tavily.com) | +| `YOUTUBE_API_KEY` | **Yes** | YouTube Data API v3 key for YouTube search. Get one at [console.cloud.google.com](https://console.cloud.google.com) | + +> **Note:** User-provided tokens (`yk-` and `hf_`) are sent from the client per-request and are never stored server-side. Only the demo token lives as an environment variable. + +
+ +--- + +
+ +
+ +## Configuration + +
+ +
+ +### Metadata + +SEO metadata is configured in `app/layout.tsx`: + +```typescript +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...", +}; +``` + +### Theme Tokens + +All design tokens are defined in `app/globals.css` using Tailwind v4's `@theme inline` directive. Three complete theme sets (`:root`, `.dark`, `.pastel`) with full token coverage. + +### Models + +Model configuration is defined in `app/api/chat/route.ts`: + +```typescript +const HF_MODELS = { + "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", +}; +``` + +
+ +--- + +
+ +
+ +## Related Projects + +
+ +
+ +| Project | Description | +|:--------|:------------| +| [Yuuki API](https://github.com/YuuKi-OS/Yuuki-api) | Inference API platform with key management and usage tracking | +| [Yuuki Web](https://github.com/YuuKi-OS/yuuki-web) | Official landing page for the Yuuki project | +| [yuy](https://github.com/YuuKi-OS/yuy) | CLI for downloading, managing, and running Yuuki models | +| [yuy-chat](https://github.com/YuuKi-OS/yuy-chat) | TUI chat interface for local AI conversations | +| [Yuuki-best](https://huggingface.co/OpceanAI/Yuuki-best) | Best checkpoint model weights | +| [Yuuki Space](https://huggingface.co/spaces/OpceanAI/Yuuki) | Web-based interactive demo | + +
+ +--- + +
+ +
+ +## Links + +
+ +
+ +
+ +[![Yuuki API](https://img.shields.io/badge/Yuuki_API-Vercel-000000?style=for-the-badge&logo=vercel&logoColor=white)](https://yuuki-api.vercel.app) +  +[![Model Weights](https://img.shields.io/badge/Model_Weights-Hugging_Face-ffd21e?style=for-the-badge&logo=huggingface&logoColor=black)](https://huggingface.co/OpceanAI/Yuuki-best) +  +[![Live Demo](https://img.shields.io/badge/Live_Demo-Spaces-ffd21e?style=for-the-badge&logo=huggingface&logoColor=black)](https://huggingface.co/spaces/OpceanAI/Yuuki) + +
+ +[![YUY CLI](https://img.shields.io/badge/Yuy_CLI-GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/YuuKi-OS/yuy) +  +[![YUY Chat](https://img.shields.io/badge/Yuy_Chat-GitHub-181717?style=for-the-badge&logo=github&logoColor=white)](https://github.com/YuuKi-OS/yuy-chat) +  +[![Sponsor](https://img.shields.io/badge/Sponsor-GitHub_Sponsors-ea4aaa?style=for-the-badge&logo=githubsponsors&logoColor=white)](https://github.com/sponsors/aguitauwu) + +
+ +
+ +--- + +
+ +
+ +## License + +
+ +
+ +``` +MIT License + +Copyright (c) 2026 Yuuki Project + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + +
+ +--- + +
+ +
+ +**Built with patience, a phone, and zero budget.** + +
+ +[![Yuuki Project](https://img.shields.io/badge/Yuuki_Project-2026-000000?style=for-the-badge)](https://huggingface.co/OpceanAI) + +
+ +
diff --git a/app/api/chat/route.ts b/app/api/chat/route.ts new file mode 100644 index 0000000..e312fa5 --- /dev/null +++ b/app/api/chat/route.ts @@ -0,0 +1,189 @@ +import { NextRequest, NextResponse } from "next/server"; + +const HF_MODELS: Record = { + "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 = { + "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 }); + } +} diff --git a/app/api/research/route.ts b/app/api/research/route.ts new file mode 100644 index 0000000..f2c5d35 --- /dev/null +++ b/app/api/research/route.ts @@ -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 }); + } +} diff --git a/app/api/youtube/route.ts b/app/api/youtube/route.ts new file mode 100644 index 0000000..29b8092 --- /dev/null +++ b/app/api/youtube/route.ts @@ -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 }); + } +} diff --git a/app/globals.css b/app/globals.css new file mode 100644 index 0000000..c3eda3c --- /dev/null +++ b/app/globals.css @@ -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; +} diff --git a/app/layout.tsx b/app/layout.tsx new file mode 100644 index 0000000..1d775ca --- /dev/null +++ b/app/layout.tsx @@ -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 ( + + + + + {children} + + + + + + + ); +} diff --git a/app/page.tsx b/app/page.tsx new file mode 100644 index 0000000..244a3f7 --- /dev/null +++ b/app/page.tsx @@ -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 ; + } + + return ; +} diff --git a/components.json b/components.json new file mode 100644 index 0000000..4ee62ee --- /dev/null +++ b/components.json @@ -0,0 +1,21 @@ +{ + "$schema": "https://ui.shadcn.com/schema.json", + "style": "new-york", + "rsc": true, + "tsx": true, + "tailwind": { + "config": "", + "css": "app/globals.css", + "baseColor": "neutral", + "cssVariables": true, + "prefix": "" + }, + "aliases": { + "components": "@/components", + "utils": "@/lib/utils", + "ui": "@/components/ui", + "lib": "@/lib", + "hooks": "@/hooks" + }, + "iconLibrary": "lucide" +} diff --git a/components/chat-message.tsx b/components/chat-message.tsx new file mode 100644 index 0000000..1dc925e --- /dev/null +++ b/components/chat-message.tsx @@ -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 ( +
+ {!isUser && ( +
+ {message.type === "research" ? ( + + ) : message.type === "youtube" ? ( + + ) : ( + + )} +
+ )} + +
+
+ {/* Research results */} + {message.type === "research" && message.researchData && ( +
+ {message.researchData.answer && ( +

{message.researchData.answer}

+ )} + {message.researchData.results.length > 0 && ( +
+ Sources + {message.researchData.results.map((r, i) => ( + + {r.title} + {r.content} + + ))} +
+ )} +
+ )} + + {/* YouTube results */} + {message.type === "youtube" && message.youtubeData && ( +
+ {message.youtubeData.videos.map((v) => ( + + {v.thumbnail && ( + {v.title} + )} +
+ {v.title} + {v.channel} +
+
+ ))} +
+ )} + + {/* Regular text */} + {(!message.type || message.type === "text") && ( + + {message.content} + {message.isStreaming && |} + + )} +
+ + {/* Copy button for assistant messages */} + {!isUser && !message.isStreaming && message.content && ( + + )} +
+ + {isUser && ( +
+ +
+ )} +
+ ); +} diff --git a/components/chat-window.tsx b/components/chat-window.tsx new file mode 100644 index 0000000..37bf7ef --- /dev/null +++ b/components/chat-window.tsx @@ -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([ + { 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(null); + const inputRef = useRef(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) => { + setInput(e.target.value); + // Auto resize + const el = e.target; + el.style.height = "auto"; + el.style.height = Math.min(el.scrollHeight, 150) + "px"; + }; + + return ( +
+ {/* Sidebar overlay for mobile */} + {sidebarOpen && ( +
setSidebarOpen(false)} + /> + )} + + {/* Sidebar */} + + + {/* Main chat area */} +
+ {/* Top bar */} +
+
+ + + + {tokenSource === "demo" + ? "demo" + : tokenSource === "yuuki-api" + ? "yk-api" + : "hf"} + +
+ +
+ {/* Research toggle */} + + + {/* YouTube toggle */} + + + {/* Theme button (desktop only) */} + +
+
+ + {/* Messages */} +
+ {messages.length === 0 ? ( +
+
+ Y +
+
+

Yuuki Chat

+

+ {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.` + } +

+
+
+ {["Tell me about yourself", "Write a short poem", "Explain quantum computing"].map((s) => ( + + ))} +
+
+ ) : ( +
+ {messages.map((msg) => ( + + ))} + {loading && ( +
+
+ +
+
+ + + +
+
+ )} +
+
+ )} +
+ + {/* Input area */} +
+
+ {/* Active mode indicator */} + {(researchEnabled || youtubeEnabled) && ( +
+ {researchEnabled && ( + <> + + Research mode active - results from Tavily + + )} + {youtubeEnabled && ( + <> + + YouTube mode active - searching videos + + )} +
+ )} +
+
+