yuuki-deidad
21
LICENSE
Normal file
@@ -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.
|
||||||
620
README.md
Normal file
@@ -0,0 +1,620 @@
|
|||||||
|
<div align="center">
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<img src="https://img.shields.io/badge/%E2%9C%A6-YUUKI--CHAT-000000?style=for-the-badge&labelColor=000000" alt="Yuuki Chat" height="50">
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
# AI Chat Interface for Yuuki Models
|
||||||
|
|
||||||
|
**macOS-inspired chat UI. Three model variants. Web research. YouTube search.**<br>
|
||||||
|
**Customizable themes with dark, pastel, and light modes plus custom hex accents.**
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<a href="#features"><img src="https://img.shields.io/badge/FEATURES-000000?style=for-the-badge" alt="Features"></a>
|
||||||
|
|
||||||
|
<a href="https://yuuki-chat.vercel.app"><img src="https://img.shields.io/badge/LIVE_APP-000000?style=for-the-badge" alt="Live App"></a>
|
||||||
|
|
||||||
|
<a href="https://github.com/sponsors/aguitauwu"><img src="https://img.shields.io/badge/SPONSOR-000000?style=for-the-badge" alt="Sponsor"></a>
|
||||||
|
|
||||||
|
<br><br>
|
||||||
|
|
||||||
|
[](LICENSE)
|
||||||
|
|
||||||
|
[](https://nextjs.org/)
|
||||||
|
|
||||||
|
[](https://react.dev/)
|
||||||
|
|
||||||
|
[](https://tailwindcss.com/)
|
||||||
|
|
||||||
|
[](https://www.typescriptlang.org/)
|
||||||
|
|
||||||
|
[](https://vercel.com/)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
**Full-featured chat client.**<br><br>
|
||||||
|
Three Yuuki model variants.<br>
|
||||||
|
macOS-style window chrome UI.<br>
|
||||||
|
Conversation history sidebar.<br>
|
||||||
|
Tavily-powered web research mode.<br>
|
||||||
|
YouTube video search integration.<br>
|
||||||
|
Markdown message rendering.<br>
|
||||||
|
Copy-to-clipboard on all responses.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
**Deeply customizable.**<br><br>
|
||||||
|
Dark, pastel, and light themes.<br>
|
||||||
|
12 preset accent colors.<br>
|
||||||
|
Native color picker integration.<br>
|
||||||
|
Custom HEX input field.<br>
|
||||||
|
Persistent theme preferences.<br>
|
||||||
|
<br>
|
||||||
|
Optimized for Vercel deployment.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## What is Yuuki Chat?
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
**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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Features
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<table>
|
||||||
|
<tr>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
<h3>Dual Authentication</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>macOS Window Chrome</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>Three Model Variants</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>Conversation Management</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
<td width="50%" valign="top">
|
||||||
|
|
||||||
|
<h3>Web Research Mode</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>YouTube Search Mode</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>Theme Customization</h3>
|
||||||
|
|
||||||
|
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 `<input type="color">` picker and a custom HEX text input for precise color selection. All preferences saved to localStorage.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<h3>Responsive Design</h3>
|
||||||
|
|
||||||
|
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.
|
||||||
|
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</table>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Authentication Flow
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
```
|
||||||
|
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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Models
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Design System
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Typography
|
||||||
|
|
||||||
|
| Role | Font | Weight |
|
||||||
|
|:-----|:-----|:-------|
|
||||||
|
| Headings | Geist | Bold (700) |
|
||||||
|
| Body text | Geist | Regular (400) |
|
||||||
|
| Code / Labels | Geist Mono | Regular (400) |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
```
|
||||||
|
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 |
|
||||||
|
+------------+ +------------------+
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Installation
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Prerequisites
|
||||||
|
|
||||||
|
- [Node.js](https://nodejs.org/) 18 or later
|
||||||
|
- [pnpm](https://pnpm.io/) (recommended) or npm
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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`.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### Build for Production
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pnpm build
|
||||||
|
pnpm start
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Deploy to Vercel
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
The recommended deployment method. Connect the repo to Vercel and configure the environment variables.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Or deploy manually with the Vercel CLI
|
||||||
|
npx vercel
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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.
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
### 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",
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Related Projects
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
| 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 |
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## Links
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
[](https://yuuki-api.vercel.app)
|
||||||
|
|
||||||
|
[](https://huggingface.co/OpceanAI/Yuuki-best)
|
||||||
|
|
||||||
|
[](https://huggingface.co/spaces/OpceanAI/Yuuki)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
[](https://github.com/YuuKi-OS/yuy)
|
||||||
|
|
||||||
|
[](https://github.com/YuuKi-OS/yuy-chat)
|
||||||
|
|
||||||
|
[](https://github.com/sponsors/aguitauwu)
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
## License
|
||||||
|
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
```
|
||||||
|
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.
|
||||||
|
```
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
<div align="center">
|
||||||
|
|
||||||
|
**Built with patience, a phone, and zero budget.**
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
[](https://huggingface.co/OpceanAI)
|
||||||
|
|
||||||
|
<br>
|
||||||
|
|
||||||
|
</div>
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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 />;
|
||||||
|
}
|
||||||
21
components.json
Normal file
@@ -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"
|
||||||
|
}
|
||||||
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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
96
lib/auth-context.tsx
Normal file
@@ -0,0 +1,96 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, {
|
||||||
|
createContext,
|
||||||
|
useContext,
|
||||||
|
useState,
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
} from "react";
|
||||||
|
|
||||||
|
export type TokenSource = "yuuki-api" | "huggingface" | "demo";
|
||||||
|
|
||||||
|
interface AuthContextValue {
|
||||||
|
token: string | null;
|
||||||
|
tokenSource: TokenSource | null;
|
||||||
|
setAuth: (token: string, source: TokenSource) => void;
|
||||||
|
logout: () => void;
|
||||||
|
isAuthenticated: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function AuthProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [token, setToken] = useState<string | null>(null);
|
||||||
|
const [tokenSource, setTokenSource] = useState<TokenSource | null>(null);
|
||||||
|
const [mounted, setMounted] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("yuuki-token");
|
||||||
|
const storedSource = localStorage.getItem(
|
||||||
|
"yuuki-token-source"
|
||||||
|
) as TokenSource | null;
|
||||||
|
if (stored && storedSource) {
|
||||||
|
setToken(stored);
|
||||||
|
setTokenSource(storedSource);
|
||||||
|
}
|
||||||
|
setMounted(true);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAuth = useCallback((t: string, source: TokenSource) => {
|
||||||
|
setToken(t);
|
||||||
|
setTokenSource(source);
|
||||||
|
if (source === "demo") {
|
||||||
|
// Don't persist demo sessions for security
|
||||||
|
localStorage.setItem("yuuki-token", "__demo__");
|
||||||
|
localStorage.setItem("yuuki-token-source", "demo");
|
||||||
|
} else {
|
||||||
|
localStorage.setItem("yuuki-token", t);
|
||||||
|
localStorage.setItem("yuuki-token-source", source);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const logout = useCallback(() => {
|
||||||
|
setToken(null);
|
||||||
|
setTokenSource(null);
|
||||||
|
localStorage.removeItem("yuuki-token");
|
||||||
|
localStorage.removeItem("yuuki-token-source");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Prevent flash of token screen before localStorage is read
|
||||||
|
if (!mounted) {
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
token: null,
|
||||||
|
tokenSource: null,
|
||||||
|
setAuth,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: false,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{null}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AuthContext.Provider
|
||||||
|
value={{
|
||||||
|
token,
|
||||||
|
tokenSource,
|
||||||
|
setAuth,
|
||||||
|
logout,
|
||||||
|
isAuthenticated: !!token,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
</AuthContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useAuth() {
|
||||||
|
const ctx = useContext(AuthContext);
|
||||||
|
if (!ctx) throw new Error("useAuth must be used within AuthProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
67
lib/theme-context.tsx
Normal file
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React, { createContext, useContext, useState, useEffect, useCallback } from "react";
|
||||||
|
|
||||||
|
export type ThemeMode = "light" | "dark" | "pastel";
|
||||||
|
|
||||||
|
interface ThemeContextValue {
|
||||||
|
mode: ThemeMode;
|
||||||
|
setMode: (mode: ThemeMode) => void;
|
||||||
|
accentColor: string;
|
||||||
|
setAccentColor: (hex: string) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
const ThemeContext = createContext<ThemeContextValue | undefined>(undefined);
|
||||||
|
|
||||||
|
export function YuukiThemeProvider({ children }: { children: React.ReactNode }) {
|
||||||
|
const [mode, setModeState] = useState<ThemeMode>("dark");
|
||||||
|
const [accentColor, setAccentColorState] = useState("#fafafa");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
const stored = localStorage.getItem("yuuki-theme-mode") as ThemeMode | null;
|
||||||
|
const storedAccent = localStorage.getItem("yuuki-accent-color");
|
||||||
|
if (stored) setModeState(stored);
|
||||||
|
if (storedAccent) setAccentColorState(storedAccent);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyMode = useCallback((m: ThemeMode) => {
|
||||||
|
const html = document.documentElement;
|
||||||
|
html.classList.remove("dark", "pastel");
|
||||||
|
if (m === "dark") html.classList.add("dark");
|
||||||
|
if (m === "pastel") html.classList.add("pastel");
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const applyAccent = useCallback((hex: string) => {
|
||||||
|
document.documentElement.style.setProperty("--user-accent", hex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyMode(mode);
|
||||||
|
}, [mode, applyMode]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
applyAccent(accentColor);
|
||||||
|
}, [accentColor, applyAccent]);
|
||||||
|
|
||||||
|
const setMode = useCallback((m: ThemeMode) => {
|
||||||
|
setModeState(m);
|
||||||
|
localStorage.setItem("yuuki-theme-mode", m);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const setAccentColor = useCallback((hex: string) => {
|
||||||
|
setAccentColorState(hex);
|
||||||
|
localStorage.setItem("yuuki-accent-color", hex);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ThemeContext.Provider value={{ mode, setMode, accentColor, setAccentColor }}>
|
||||||
|
{children}
|
||||||
|
</ThemeContext.Provider>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function useYuukiTheme() {
|
||||||
|
const ctx = useContext(ThemeContext);
|
||||||
|
if (!ctx) throw new Error("useYuukiTheme must be used within YuukiThemeProvider");
|
||||||
|
return ctx;
|
||||||
|
}
|
||||||
6
lib/utils.ts
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
import { clsx, type ClassValue } from 'clsx'
|
||||||
|
import { twMerge } from 'tailwind-merge'
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return twMerge(clsx(inputs))
|
||||||
|
}
|
||||||
12
next.config.mjs
Normal file
@@ -0,0 +1,12 @@
|
|||||||
|
/** @type {import('next').NextConfig} */
|
||||||
|
const nextConfig = {
|
||||||
|
typescript: {
|
||||||
|
ignoreBuildErrors: true,
|
||||||
|
},
|
||||||
|
images: {
|
||||||
|
unoptimized: true,
|
||||||
|
},
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
export default nextConfig
|
||||||
47
package.json
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
{
|
||||||
|
"name": "yuuki-chat",
|
||||||
|
"version": "1.0.0",
|
||||||
|
"private": true,
|
||||||
|
"scripts": {
|
||||||
|
"build": "next build",
|
||||||
|
"dev": "next dev",
|
||||||
|
"lint": "eslint .",
|
||||||
|
"start": "next start"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"@radix-ui/react-dialog": "1.1.4",
|
||||||
|
"@radix-ui/react-dropdown-menu": "2.1.4",
|
||||||
|
"@radix-ui/react-label": "2.1.1",
|
||||||
|
"@radix-ui/react-popover": "1.1.4",
|
||||||
|
"@radix-ui/react-scroll-area": "1.2.2",
|
||||||
|
"@radix-ui/react-select": "2.1.4",
|
||||||
|
"@radix-ui/react-separator": "1.1.1",
|
||||||
|
"@radix-ui/react-slot": "1.1.1",
|
||||||
|
"@radix-ui/react-switch": "1.1.2",
|
||||||
|
"@radix-ui/react-tabs": "1.1.2",
|
||||||
|
"@radix-ui/react-tooltip": "1.1.6",
|
||||||
|
"@vercel/analytics": "1.3.1",
|
||||||
|
"class-variance-authority": "^0.7.1",
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.454.0",
|
||||||
|
"next": "16.0.10",
|
||||||
|
"next-themes": "^0.4.6",
|
||||||
|
"react": "19.2.0",
|
||||||
|
"react-dom": "19.2.0",
|
||||||
|
"react-markdown": "^9.0.1",
|
||||||
|
"sonner": "^1.7.4",
|
||||||
|
"swr": "2.4.0",
|
||||||
|
"tailwind-merge": "^3.3.1",
|
||||||
|
"tailwindcss-animate": "^1.0.7"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/postcss": "^4.1.9",
|
||||||
|
"@types/node": "^22",
|
||||||
|
"@types/react": "^19",
|
||||||
|
"@types/react-dom": "^19",
|
||||||
|
"postcss": "^8.5",
|
||||||
|
"tailwindcss": "^4.1.9",
|
||||||
|
"tw-animate-css": "1.3.3",
|
||||||
|
"typescript": "^5"
|
||||||
|
}
|
||||||
|
}
|
||||||
2796
pnpm-lock.yaml
generated
Normal file
8
postcss.config.mjs
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
/** @type {import('postcss-load-config').Config} */
|
||||||
|
const config = {
|
||||||
|
plugins: {
|
||||||
|
'@tailwindcss/postcss': {},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
export default config
|
||||||
BIN
public/apple-icon.png
Normal file
|
After Width: | Height: | Size: 2.6 KiB |
BIN
public/icon-dark-32x32.png
Normal file
|
After Width: | Height: | Size: 585 B |
BIN
public/icon-light-32x32.png
Normal file
|
After Width: | Height: | Size: 566 B |
26
public/icon.svg
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
<svg width="180" height="180" viewBox="0 0 180 180" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<style>
|
||||||
|
@media (prefers-color-scheme: light) {
|
||||||
|
.background { fill: black; }
|
||||||
|
.foreground { fill: white; }
|
||||||
|
}
|
||||||
|
@media (prefers-color-scheme: dark) {
|
||||||
|
.background { fill: white; }
|
||||||
|
.foreground { fill: black; }
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
<g clip-path="url(#clip0_7960_43945)">
|
||||||
|
<rect class="background" width="180" height="180" rx="37" />
|
||||||
|
<g style="transform: scale(95%); transform-origin: center">
|
||||||
|
<path class="foreground"
|
||||||
|
d="M101.141 53H136.632C151.023 53 162.689 64.6662 162.689 79.0573V112.904H148.112V79.0573C148.112 78.7105 148.098 78.3662 148.072 78.0251L112.581 112.898C112.701 112.902 112.821 112.904 112.941 112.904H148.112V126.672H112.941C98.5504 126.672 86.5638 114.891 86.5638 100.5V66.7434H101.141V100.5C101.141 101.15 101.191 101.792 101.289 102.422L137.56 66.7816C137.255 66.7563 136.945 66.7434 136.632 66.7434H101.141V53Z" />
|
||||||
|
<path class="foreground"
|
||||||
|
d="M65.2926 124.136L14 66.7372H34.6355L64.7495 100.436V66.7372H80.1365V118.47C80.1365 126.278 70.4953 129.958 65.2926 124.136Z" />
|
||||||
|
</g>
|
||||||
|
</g>
|
||||||
|
<defs>
|
||||||
|
<clipPath id="clip0_7960_43945">
|
||||||
|
<rect width="180" height="180" fill="white" />
|
||||||
|
</clipPath>
|
||||||
|
</defs>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 1.3 KiB |
BIN
public/placeholder-logo.png
Normal file
|
After Width: | Height: | Size: 568 B |
1
public/placeholder-logo.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="215" height="48" fill="none"><path fill="#000" d="M57.588 9.6h6L73.828 38h-5.2l-2.36-6.88h-11.36L52.548 38h-5.2l10.24-28.4Zm7.16 17.16-4.16-12.16-4.16 12.16h8.32Zm23.694-2.24c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.486-7.72.12 3.4c.534-1.227 1.307-2.173 2.32-2.84 1.04-.693 2.267-1.04 3.68-1.04 1.494 0 2.76.387 3.8 1.16 1.067.747 1.827 1.813 2.28 3.2.507-1.44 1.294-2.52 2.36-3.24 1.094-.747 2.414-1.12 3.96-1.12 1.414 0 2.64.307 3.68.92s1.84 1.52 2.4 2.72c.56 1.2.84 2.667.84 4.4V38h-4.96V25.92c0-1.813-.293-3.187-.88-4.12-.56-.96-1.413-1.44-2.56-1.44-.906 0-1.68.213-2.32.64-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.84-.48 3.04V38h-4.56V25.92c0-1.2-.133-2.213-.4-3.04-.24-.827-.626-1.453-1.16-1.88-.506-.427-1.133-.64-1.88-.64-.906 0-1.68.227-2.32.68-.64.427-1.133 1.053-1.48 1.88-.32.827-.48 1.827-.48 3V38h-4.96V16.8h4.48Zm26.723 10.6c0-2.24.427-4.187 1.28-5.84.854-1.68 2.067-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.84 0 3.494.413 4.96 1.24 1.467.827 2.64 2.08 3.52 3.76.88 1.653 1.347 3.693 1.4 6.12v1.32h-15.08c.107 1.813.614 3.227 1.52 4.24.907.987 2.134 1.48 3.68 1.48.987 0 1.88-.253 2.68-.76a4.803 4.803 0 0 0 1.84-2.2l5.08.36c-.64 2.027-1.84 3.64-3.6 4.84-1.733 1.173-3.733 1.76-6 1.76-2.08 0-3.906-.453-5.48-1.36-1.573-.907-2.786-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84Zm15.16-2.04c-.213-1.733-.76-3.013-1.64-3.84-.853-.827-1.893-1.24-3.12-1.24-1.44 0-2.6.453-3.48 1.36-.88.88-1.44 2.12-1.68 3.72h9.92ZM163.139 9.6V38h-5.04V9.6h5.04Zm8.322 7.2.24 5.88-.64-.36c.32-2.053 1.094-3.56 2.32-4.52 1.254-.987 2.787-1.48 4.6-1.48 2.32 0 4.107.733 5.36 2.2 1.254 1.44 1.88 3.387 1.88 5.84V38h-4.96V25.92c0-1.253-.12-2.28-.36-3.08-.24-.8-.64-1.413-1.2-1.84-.533-.427-1.253-.64-2.16-.64-1.44 0-2.573.48-3.4 1.44-.8.933-1.2 2.307-1.2 4.12V38h-4.96V16.8h4.48Zm30.003 7.72c-.186-1.307-.706-2.32-1.56-3.04-.853-.72-1.866-1.08-3.04-1.08-1.68 0-2.986.613-3.92 1.84-.906 1.227-1.36 2.947-1.36 5.16s.454 3.933 1.36 5.16c.934 1.227 2.24 1.84 3.92 1.84 1.254 0 2.307-.373 3.16-1.12.854-.773 1.387-1.867 1.6-3.28l5.12.24c-.186 1.68-.733 3.147-1.64 4.4-.906 1.227-2.08 2.173-3.52 2.84-1.413.667-2.986 1-4.72 1-2.08 0-3.906-.453-5.48-1.36-1.546-.907-2.76-2.2-3.64-3.88-.853-1.68-1.28-3.627-1.28-5.84 0-2.24.427-4.187 1.28-5.84.88-1.68 2.094-2.973 3.64-3.88 1.574-.907 3.4-1.36 5.48-1.36 1.68 0 3.227.32 4.64.96 1.414.64 2.56 1.56 3.44 2.76.907 1.2 1.454 2.6 1.64 4.2l-5.12.28Zm11.443 8.16V38h-5.6v-5.32h5.6Z"/><path fill="#171717" fill-rule="evenodd" d="m7.839 40.783 16.03-28.054L20 6 0 40.783h7.839Zm8.214 0H40L27.99 19.894l-4.02 7.032 3.976 6.914H20.02l-3.967 6.943Z" clip-rule="evenodd"/></svg>
|
||||||
|
After Width: | Height: | Size: 3.1 KiB |
BIN
public/placeholder-user.jpg
Normal file
|
After Width: | Height: | Size: 1.6 KiB |
BIN
public/placeholder.jpg
Normal file
|
After Width: | Height: | Size: 1.0 KiB |
1
public/placeholder.svg
Normal file
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" width="1200" height="1200" fill="none"><rect width="1200" height="1200" fill="#EAEAEA" rx="3"/><g opacity=".5"><g opacity=".5"><path fill="#FAFAFA" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 736.5c-75.454 0-136.621-61.167-136.621-136.62 0-75.454 61.167-136.621 136.621-136.621 75.453 0 136.62 61.167 136.62 136.621 0 75.453-61.167 136.62-136.62 136.62Z"/></g><path stroke="url(#a)" stroke-width="2.418" d="M0-1.209h553.581" transform="scale(1 -1) rotate(45 1163.11 91.165)"/><path stroke="url(#b)" stroke-width="2.418" d="M404.846 598.671h391.726"/><path stroke="url(#c)" stroke-width="2.418" d="M599.5 795.742V404.017"/><path stroke="url(#d)" stroke-width="2.418" d="m795.717 796.597-391.441-391.44"/><path fill="#fff" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/><g clip-path="url(#e)"><path fill="#666" fill-rule="evenodd" d="M616.426 586.58h-31.434v16.176l3.553-3.554.531-.531h9.068l.074-.074 8.463-8.463h2.565l7.18 7.181V586.58Zm-15.715 14.654 3.698 3.699 1.283 1.282-2.565 2.565-1.282-1.283-5.2-5.199h-6.066l-5.514 5.514-.073.073v2.876a2.418 2.418 0 0 0 2.418 2.418h26.598a2.418 2.418 0 0 0 2.418-2.418v-8.317l-8.463-8.463-7.181 7.181-.071.072Zm-19.347 5.442v4.085a6.045 6.045 0 0 0 6.046 6.045h26.598a6.044 6.044 0 0 0 6.045-6.045v-7.108l1.356-1.355-1.282-1.283-.074-.073v-17.989h-38.689v23.43l-.146.146.146.147Z" clip-rule="evenodd"/></g><path stroke="#C9C9C9" stroke-width="2.418" d="M600.709 656.704c-31.384 0-56.825-25.441-56.825-56.824 0-31.384 25.441-56.825 56.825-56.825 31.383 0 56.824 25.441 56.824 56.825 0 31.383-25.441 56.824-56.824 56.824Z"/></g><defs><linearGradient id="a" x1="554.061" x2="-.48" y1=".083" y2=".087" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="b" x1="796.912" x2="404.507" y1="599.963" y2="599.965" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="c" x1="600.792" x2="600.794" y1="403.677" y2="796.082" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><linearGradient id="d" x1="404.85" x2="796.972" y1="403.903" y2="796.02" gradientUnits="userSpaceOnUse"><stop stop-color="#C9C9C9" stop-opacity="0"/><stop offset=".208" stop-color="#C9C9C9"/><stop offset=".792" stop-color="#C9C9C9"/><stop offset="1" stop-color="#C9C9C9" stop-opacity="0"/></linearGradient><clipPath id="e"><path fill="#fff" d="M581.364 580.535h38.689v38.689h-38.689z"/></clipPath></defs></svg>
|
||||||
|
After Width: | Height: | Size: 3.2 KiB |
125
styles/globals.css
Normal file
@@ -0,0 +1,125 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import 'tw-animate-css';
|
||||||
|
|
||||||
|
@custom-variant dark (&:is(.dark *));
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--background: oklch(1 0 0);
|
||||||
|
--foreground: oklch(0.145 0 0);
|
||||||
|
--card: oklch(1 0 0);
|
||||||
|
--card-foreground: oklch(0.145 0 0);
|
||||||
|
--popover: oklch(1 0 0);
|
||||||
|
--popover-foreground: oklch(0.145 0 0);
|
||||||
|
--primary: oklch(0.205 0 0);
|
||||||
|
--primary-foreground: oklch(0.985 0 0);
|
||||||
|
--secondary: oklch(0.97 0 0);
|
||||||
|
--secondary-foreground: oklch(0.205 0 0);
|
||||||
|
--muted: oklch(0.97 0 0);
|
||||||
|
--muted-foreground: oklch(0.556 0 0);
|
||||||
|
--accent: oklch(0.97 0 0);
|
||||||
|
--accent-foreground: oklch(0.205 0 0);
|
||||||
|
--destructive: oklch(0.577 0.245 27.325);
|
||||||
|
--destructive-foreground: oklch(0.577 0.245 27.325);
|
||||||
|
--border: oklch(0.922 0 0);
|
||||||
|
--input: oklch(0.922 0 0);
|
||||||
|
--ring: oklch(0.708 0 0);
|
||||||
|
--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);
|
||||||
|
--radius: 0.625rem;
|
||||||
|
--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 {
|
||||||
|
--background: oklch(0.145 0 0);
|
||||||
|
--foreground: oklch(0.985 0 0);
|
||||||
|
--card: oklch(0.145 0 0);
|
||||||
|
--card-foreground: oklch(0.985 0 0);
|
||||||
|
--popover: oklch(0.145 0 0);
|
||||||
|
--popover-foreground: oklch(0.985 0 0);
|
||||||
|
--primary: oklch(0.985 0 0);
|
||||||
|
--primary-foreground: oklch(0.205 0 0);
|
||||||
|
--secondary: oklch(0.269 0 0);
|
||||||
|
--secondary-foreground: oklch(0.985 0 0);
|
||||||
|
--muted: oklch(0.269 0 0);
|
||||||
|
--muted-foreground: oklch(0.708 0 0);
|
||||||
|
--accent: oklch(0.269 0 0);
|
||||||
|
--accent-foreground: oklch(0.985 0 0);
|
||||||
|
--destructive: oklch(0.396 0.141 25.723);
|
||||||
|
--destructive-foreground: oklch(0.637 0.237 25.331);
|
||||||
|
--border: oklch(0.269 0 0);
|
||||||
|
--input: oklch(0.269 0 0);
|
||||||
|
--ring: oklch(0.439 0 0);
|
||||||
|
--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);
|
||||||
|
}
|
||||||
|
|
||||||
|
@theme inline {
|
||||||
|
--font-sans: 'Geist', 'Geist Fallback';
|
||||||
|
--font-mono: 'Geist Mono', 'Geist Mono Fallback';
|
||||||
|
--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);
|
||||||
|
--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);
|
||||||
|
--radius-sm: calc(var(--radius) - 4px);
|
||||||
|
--radius-md: calc(var(--radius) - 2px);
|
||||||
|
--radius-lg: var(--radius);
|
||||||
|
--radius-xl: calc(var(--radius) + 4px);
|
||||||
|
--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;
|
||||||
|
}
|
||||||
|
}
|
||||||
27
tsconfig.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
{
|
||||||
|
"compilerOptions": {
|
||||||
|
"lib": ["dom", "dom.iterable", "esnext"],
|
||||||
|
"allowJs": true,
|
||||||
|
"target": "ES6",
|
||||||
|
"skipLibCheck": true,
|
||||||
|
"strict": true,
|
||||||
|
"noEmit": true,
|
||||||
|
"esModuleInterop": true,
|
||||||
|
"module": "esnext",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
"resolveJsonModule": true,
|
||||||
|
"isolatedModules": true,
|
||||||
|
"jsx": "preserve",
|
||||||
|
"incremental": true,
|
||||||
|
"plugins": [
|
||||||
|
{
|
||||||
|
"name": "next"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./*"]
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
|
||||||
|
"exclude": ["node_modules"]
|
||||||
|
}
|
||||||