first implé
This commit is contained in:
18
.dockerignore
Normal file
18
.dockerignore
Normal file
@@ -0,0 +1,18 @@
|
||||
node_modules
|
||||
.next
|
||||
out
|
||||
build
|
||||
.git
|
||||
.gitignore
|
||||
.env*.local
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
Dockerfile
|
||||
docker-compose.yml
|
||||
README.md
|
||||
task.md
|
||||
walkthrough.md
|
||||
implementation_plan.md
|
||||
schopenhauer_chat_prd.md
|
||||
45
CLAUDE.md
45
CLAUDE.md
@@ -1 +1,44 @@
|
||||
@AGENTS.md
|
||||
# Developer Guide — Will & Representation
|
||||
|
||||
This document outlines the standard commands for building, running, and managing the Schopenhauer Chat application.
|
||||
|
||||
## Development Commands
|
||||
|
||||
- **Install dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
- **Run local development server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
- **Build production bundle**:
|
||||
```bash
|
||||
npm run build
|
||||
```
|
||||
- **Run linter**:
|
||||
```bash
|
||||
npm run lint
|
||||
```
|
||||
|
||||
## Docker Commands
|
||||
|
||||
- **Build and start the container in background**:
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
- **Inspect container logs**:
|
||||
```bash
|
||||
docker logs -f schopenhauer-chat
|
||||
```
|
||||
- **Stop and remove container**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
## Environment Setup
|
||||
Ensure `.env.local` exists in the root directory with:
|
||||
```env
|
||||
GEMINI_API_KEY=your_key_here
|
||||
GEMINI_MODEL=gemini-3.1-flash
|
||||
```
|
||||
|
||||
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# Stage 1: Install dependencies and build
|
||||
FROM node:20-alpine AS builder
|
||||
WORKDIR /app
|
||||
|
||||
# Check https://github.com/nodejs/docker-node/tree/b4117f9333da4138b03a546ec926ef50a31506c3#nodealpine to understand why libc6-compat might be needed.
|
||||
RUN apk add --no-cache libc6-compat
|
||||
|
||||
COPY package*.json ./
|
||||
RUN npm ci
|
||||
|
||||
COPY . .
|
||||
|
||||
# Disable Next.js telemetry during build
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN npm run build
|
||||
|
||||
# Stage 2: Runner stage
|
||||
FROM node:20-alpine AS runner
|
||||
WORKDIR /app
|
||||
|
||||
ENV NODE_ENV=production
|
||||
ENV NEXT_TELEMETRY_DISABLED=1
|
||||
|
||||
RUN addgroup --system --gid 1001 nodejs
|
||||
RUN adduser --system --uid 1001 nextjs
|
||||
|
||||
# Copy build artifacts and assets
|
||||
COPY --from=builder /app/public ./public
|
||||
|
||||
# Set correct permission for prerender cache
|
||||
RUN mkdir .next
|
||||
RUN chown nextjs:nodejs .next
|
||||
|
||||
# Automatically leverage output traces to reduce image size
|
||||
# https://nextjs.org/docs/advanced-features/output-file-tracing
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
|
||||
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
|
||||
|
||||
USER nextjs
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
ENV PORT=3000
|
||||
# set hostname to localhost or 0.0.0.0 for docker
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
|
||||
# server.js is created by next build from the standalone output
|
||||
CMD ["node", "server.js"]
|
||||
121
README.md
121
README.md
@@ -1,36 +1,115 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
# Will & Representation — Schopenhauer Chat App
|
||||
|
||||
## Getting Started
|
||||
*“Speak, and the Will shall betray itself.”*
|
||||
|
||||
First, run the development server:
|
||||
**Will & Representation** is a minimal, elegant, mobile-first chat application that allows users to converse with an AI persona inspired by the 19th-century German philosopher **Arthur Schopenhauer**. Unlike generic conversational bots, this application acts as a dry, elegant, and philosophically consistent interlocutor grounded deeply in Schopenhauer’s core doctrine: the world as representation, the blind striving of the Will, existence swinging between suffering and boredom, art as temporary intellectual liberation, and compassion as the true foundation of ethics.
|
||||
|
||||
---
|
||||
|
||||
## 🎨 Design & Visuals
|
||||
|
||||
The interface is designed with a **dark-academic aesthetic** featuring:
|
||||
- **Palette**: Clean, modern dark mode surfaces (`#0E0E10` background, `#17171A` surfaces) with high-contrast warm cream text (`#F4F1EA`) and premium gold accents (`#C8A96A`).
|
||||
- **Typography**: Sleek Sans-serif body font (`Inter`) combined with elegant Serif headers (`Cormorant Garamond`).
|
||||
- **Mobile First**: Fixed bottom chat inputs, safe-area boundary configurations, smooth scrolling animations, and auto-resizing text fields tailored for phone screen viewports.
|
||||
|
||||
---
|
||||
|
||||
## 🛠️ Tech Stack
|
||||
|
||||
- **Core Framework**: [Next.js 15+ / 16 (App Router)](https://nextjs.org/)
|
||||
- **Logic**: React 19, TypeScript
|
||||
- **Styling**: Tailwind CSS v4 with custom design tokens
|
||||
- **AI SDK**: Official `@google/genai` (Google Gen AI SDK)
|
||||
- **Markdown Support**: `react-markdown` for rendering assistant formatting (quotes, lists, code snippets)
|
||||
- **State Store**: Client-side `localStorage` caching for secure local conversation history
|
||||
|
||||
---
|
||||
|
||||
## ⚙️ Environment Variables
|
||||
|
||||
Copy the example template to prepare your local configuration:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
cp .env.example .env.local
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
Open `.env.local` and add your credentials:
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
```env
|
||||
# Google Gemini API key from AI Studio
|
||||
GEMINI_API_KEY=your_gemini_api_key_here
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
# Default model (can override)
|
||||
GEMINI_MODEL=gemini-3.1-flash
|
||||
```
|
||||
|
||||
## Learn More
|
||||
*Note: The backend endpoint includes a model fallback sequence. If `gemini-3.1-flash` is not available, it automatically falls back to `gemini-2.5-flash` and then `gemini-1.5-flash` to prevent API failures.*
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
---
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
## 🚀 Local Setup & Development
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
1. **Install Dependencies**:
|
||||
```bash
|
||||
npm install
|
||||
```
|
||||
|
||||
## Deploy on Vercel
|
||||
2. **Run Dev Server**:
|
||||
```bash
|
||||
npm run dev
|
||||
```
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
3. **Open the browser**:
|
||||
Navigate to [http://localhost:3000](http://localhost:3000) using a desktop or mobile browser viewport.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
---
|
||||
|
||||
## 🧠 Key Features & Usage
|
||||
|
||||
### 1. Persona Intensity Modes
|
||||
You can customize the philosopher's tone via the **Settings Modal** (accessible from the header top-right):
|
||||
- **Gentle Schopenhauer**: Softens the delivery, offering patient, empathetic, and slightly less biting responses to personal struggles while maintaining philosophical seriousness.
|
||||
- **Classical Schopenhauer (Default)**: Lucid, formal, pessimistic, and dryly witty. Strictly aligns with his historical treatises.
|
||||
- **Severe Schopenhauer**: Concise, sharp, and cutting. Highly skeptical of human ambition and societal optimism.
|
||||
|
||||
### 2. Source-Grounded Mode
|
||||
Toggle **Source-Grounded Mode** in the settings. This appends directives to anchor replies strictly to historical publications, reminding the simulation to draw directly from his main works.
|
||||
|
||||
### 3. Quote Card Generator
|
||||
Click the **Quote** (quotation mark) icon beneath any response from the assistant. This opens the **Quote Card Generator**. You can edit the text to shorten it, preview it on a beautiful dark-academic gold-bordered canvas, and download it as an **800x500 PNG image** for sharing.
|
||||
|
||||
### 4. Safety Guardrails
|
||||
Discussing existential pessimism necessitates care. The application monitors incoming messages for crisis signals. If expressions of self-harm or suicidal intent are detected, the system immediately bypasses the philosophical persona to deliver direct, compassionate mental health resources and helpline information.
|
||||
|
||||
---
|
||||
|
||||
## 🐳 Docker Deployment
|
||||
|
||||
The application is completely containerized for simple plug-and-play execution.
|
||||
|
||||
1. **Verify your Env File**:
|
||||
Ensure `.env.local` exists and contains your `GEMINI_API_KEY`.
|
||||
|
||||
2. **Start the Container**:
|
||||
```bash
|
||||
docker compose up --build -d
|
||||
```
|
||||
|
||||
3. **Browse the App**:
|
||||
Navigate to [http://localhost:3000](http://localhost:3000) on your device.
|
||||
|
||||
4. **Stop the Container**:
|
||||
```bash
|
||||
docker compose down
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📚 Bibliography & References
|
||||
|
||||
For further reading on Arthur Schopenhauer's original public-domain writings, refer to:
|
||||
- **Project Gutenberg Author Index**: [Arthur Schopenhauer Page](https://www.gutenberg.org/ebooks/author/3648)
|
||||
- **The World as Will and Idea, Vol. 1**: [Gutenberg eBook 38427](https://www.gutenberg.org/ebooks/38427)
|
||||
- **Studies in Pessimism**: [Gutenberg eBook 10732](https://www.gutenberg.org/ebooks/10732)
|
||||
- **Internet Archive PDF Index**: [The World as Will and Representation](https://archive.org/details/the-world-as-will-and-representation)
|
||||
|
||||
15
docker-compose.yml
Normal file
15
docker-compose.yml
Normal file
@@ -0,0 +1,15 @@
|
||||
services:
|
||||
web:
|
||||
container_name: schopenhauer-chat
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
ports:
|
||||
- "3000:3000"
|
||||
env_file:
|
||||
- .env.local
|
||||
environment:
|
||||
- NEXT_TELEMETRY_DISABLED=1
|
||||
# Explicit variables fallback override if not defined in .env.local
|
||||
- GEMINI_MODEL=${GEMINI_MODEL:-gemini-3.1-flash}
|
||||
restart: unless-stopped
|
||||
@@ -1,7 +1,7 @@
|
||||
import type { NextConfig } from "next";
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
/* config options here */
|
||||
output: 'standalone',
|
||||
};
|
||||
|
||||
export default nextConfig;
|
||||
|
||||
1643
package-lock.json
generated
1643
package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -9,9 +9,14 @@
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@google/genai": "^2.7.0",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^1.17.0",
|
||||
"next": "16.2.7",
|
||||
"react": "19.2.4",
|
||||
"react-dom": "19.2.4"
|
||||
"react-dom": "19.2.4",
|
||||
"react-markdown": "^10.1.0",
|
||||
"tailwind-merge": "^3.6.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
|
||||
1200
schopenhauer_chat_prd.md
Normal file
1200
schopenhauer_chat_prd.md
Normal file
File diff suppressed because it is too large
Load Diff
106
src/app/api/chat/route.ts
Normal file
106
src/app/api/chat/route.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { streamSchopenhauerReply } from '@/lib/gemini';
|
||||
import { checkRateLimit } from '@/lib/rate-limit';
|
||||
import { isCrisisMessage, CRISIS_RESPONSE } from '@/lib/prompts';
|
||||
|
||||
export const runtime = 'nodejs'; // Use nodejs environment for GenAI compatibility
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
// 1. IP Rate Limiting
|
||||
const forwarded = req.headers.get('x-forwarded-for');
|
||||
const ip = forwarded ? forwarded.split(',')[0].trim() : '127.0.0.1';
|
||||
|
||||
const rateLimit = checkRateLimit(ip);
|
||||
if (!rateLimit.allowed) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: 'Rate limit exceeded. The philosopher demands reflection. Try again later.',
|
||||
resetTime: rateLimit.resetTime,
|
||||
}),
|
||||
{
|
||||
status: 429,
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
// 2. Parse request payload
|
||||
const { messages, mode } = await req.json();
|
||||
|
||||
if (!messages || !Array.isArray(messages) || messages.length === 0) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Messages are required.' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
const lastMessage = messages[messages.length - 1];
|
||||
if (!lastMessage || !lastMessage.content) {
|
||||
return new NextResponse(
|
||||
JSON.stringify({ error: 'Invalid message format.' }),
|
||||
{ status: 400, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Safety Layer Check (Self-harm / Crisis)
|
||||
if (isCrisisMessage(lastMessage.content)) {
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
// Stream predefined support message
|
||||
const text = CRISIS_RESPONSE;
|
||||
const chunkSize = 24;
|
||||
for (let i = 0; i < text.length; i += chunkSize) {
|
||||
controller.enqueue(encoder.encode(text.slice(i, i + chunkSize)));
|
||||
await new Promise((resolve) => setTimeout(resolve, 30));
|
||||
}
|
||||
controller.close();
|
||||
},
|
||||
});
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Call Gemini client stream
|
||||
const { responseStream } = await streamSchopenhauerReply(messages, mode || 'classical');
|
||||
|
||||
const encoder = new TextEncoder();
|
||||
const stream = new ReadableStream({
|
||||
async start(controller) {
|
||||
try {
|
||||
for await (const chunk of responseStream) {
|
||||
const text = chunk.text;
|
||||
if (text) {
|
||||
controller.enqueue(encoder.encode(text));
|
||||
}
|
||||
}
|
||||
controller.close();
|
||||
} catch (streamError: any) {
|
||||
console.error('Error during streaming chunks:', streamError);
|
||||
controller.enqueue(encoder.encode('\n\n[The philosopher was interrupted. Please check your API key or connection.]'));
|
||||
controller.close();
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return new NextResponse(stream, {
|
||||
headers: {
|
||||
'Content-Type': 'text/plain; charset=utf-8',
|
||||
'Transfer-Encoding': 'chunked',
|
||||
},
|
||||
});
|
||||
} catch (error: any) {
|
||||
console.error('API Chat route error:', error);
|
||||
return new NextResponse(
|
||||
JSON.stringify({
|
||||
error: error.message || 'The philosopher refuses to speak. Try again.',
|
||||
}),
|
||||
{ status: 500, headers: { 'Content-Type': 'application/json' } }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -1,26 +1,142 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@theme {
|
||||
--color-bg-dark: #0E0E10;
|
||||
--color-surface-dark: #17171A;
|
||||
--color-surface-elevated: #202024;
|
||||
--color-primary-text: #F4F1EA;
|
||||
--color-secondary-text: #A8A29E;
|
||||
--color-accent: #C8A96A;
|
||||
--color-accent-hover: #D8B97A;
|
||||
--color-border-custom: rgba(255, 255, 255, 0.08);
|
||||
--color-error-custom: #EF4444;
|
||||
--color-success-custom: #22C55E;
|
||||
|
||||
--font-serif: var(--font-cormorant-garamond), Georgia, serif;
|
||||
--font-sans: var(--font-inter), system-ui, sans-serif;
|
||||
|
||||
--animate-fade-in-up: fade-in-up 0.25s ease-out forwards;
|
||||
--animate-pulse-slow: pulse-slow 1.8s infinite ease-in-out;
|
||||
|
||||
@keyframes fade-in-up {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
@keyframes pulse-slow {
|
||||
0%, 100% {
|
||||
opacity: 0.3;
|
||||
}
|
||||
50% {
|
||||
opacity: 0.8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
--background: #0E0E10;
|
||||
--foreground: #F4F1EA;
|
||||
}
|
||||
|
||||
/* Base resets & styling */
|
||||
body {
|
||||
background: var(--background);
|
||||
background-color: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
font-family: var(--font-sans);
|
||||
overflow-x: hidden;
|
||||
height: 100dvh;
|
||||
}
|
||||
|
||||
/* Custom minimal scrollbar */
|
||||
::-webkit-scrollbar {
|
||||
width: 4px;
|
||||
height: 4px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-track {
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb {
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
border-radius: 9999px;
|
||||
}
|
||||
|
||||
::-webkit-scrollbar-thumb:hover {
|
||||
background: rgba(200, 169, 106, 0.3);
|
||||
}
|
||||
|
||||
/* Utility to override default input styles */
|
||||
textarea, input {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
/* Markdown styling overrides in chat bubble */
|
||||
.prose-schopenhauer {
|
||||
color: #F4F1EA;
|
||||
font-size: 0.975rem;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.prose-schopenhauer p {
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.prose-schopenhauer p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.prose-schopenhauer blockquote {
|
||||
border-left: 2px solid #C8A96A;
|
||||
padding-left: 0.75rem;
|
||||
font-style: italic;
|
||||
color: #A8A29E;
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.prose-schopenhauer code {
|
||||
background-color: rgba(255, 255, 255, 0.06);
|
||||
padding: 0.15rem 0.35rem;
|
||||
border-radius: 4px;
|
||||
font-size: 0.85em;
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.prose-schopenhauer pre {
|
||||
background-color: #0E0E10;
|
||||
padding: 0.75rem;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
border: 1px solid rgba(255, 255, 255, 0.05);
|
||||
margin: 1rem 0;
|
||||
}
|
||||
|
||||
.prose-schopenhauer pre code {
|
||||
background-color: transparent;
|
||||
padding: 0;
|
||||
border-radius: 0;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
|
||||
.prose-schopenhauer ol, .prose-schopenhauer ul {
|
||||
padding-left: 1.25rem;
|
||||
margin-bottom: 0.85rem;
|
||||
}
|
||||
|
||||
.prose-schopenhauer ol {
|
||||
list-style-type: decimal;
|
||||
}
|
||||
|
||||
.prose-schopenhauer ul {
|
||||
list-style-type: disc;
|
||||
}
|
||||
|
||||
.prose-schopenhauer li {
|
||||
margin-bottom: 0.25rem;
|
||||
}
|
||||
|
||||
@@ -1,20 +1,38 @@
|
||||
import type { Metadata } from "next";
|
||||
import { Geist, Geist_Mono } from "next/font/google";
|
||||
import type { Metadata, Viewport } from "next";
|
||||
import { Inter, Cormorant_Garamond } from "next/font/google";
|
||||
import "./globals.css";
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: "--font-geist-sans",
|
||||
const inter = Inter({
|
||||
variable: "--font-inter",
|
||||
subsets: ["latin"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: "--font-geist-mono",
|
||||
const cormorantGaramond = Cormorant_Garamond({
|
||||
variable: "--font-cormorant-garamond",
|
||||
subsets: ["latin"],
|
||||
weight: ["300", "400", "500", "600", "700"],
|
||||
style: ["normal", "italic"],
|
||||
display: "swap",
|
||||
});
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "Create Next App",
|
||||
description: "Generated by create next app",
|
||||
title: "Will & Representation — Chat with Arthur Schopenhauer",
|
||||
description: "Converse with a digital recreation of Arthur Schopenhauer. Reflect on desire, representation, suffering, art, and the Will.",
|
||||
appleWebApp: {
|
||||
capable: true,
|
||||
statusBarStyle: "black-translucent",
|
||||
title: "Will & Representation",
|
||||
},
|
||||
};
|
||||
|
||||
export const viewport: Viewport = {
|
||||
width: "device-width",
|
||||
initialScale: 1,
|
||||
maximumScale: 1,
|
||||
userScalable: false,
|
||||
viewportFit: "cover",
|
||||
themeColor: "#0E0E10",
|
||||
};
|
||||
|
||||
export default function RootLayout({
|
||||
@@ -25,9 +43,11 @@ export default function RootLayout({
|
||||
return (
|
||||
<html
|
||||
lang="en"
|
||||
className={`${geistSans.variable} ${geistMono.variable} h-full antialiased`}
|
||||
className={`${inter.variable} ${cormorantGaramond.variable} h-full antialiased dark`}
|
||||
>
|
||||
<body className="min-h-full flex flex-col">{children}</body>
|
||||
<body className="bg-bg-dark text-primary-text font-sans h-full min-h-full flex flex-col">
|
||||
{children}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,65 +1,9 @@
|
||||
import Image from "next/image";
|
||||
import { ChatScreen } from "@/components/chat/ChatScreen";
|
||||
|
||||
export default function Home() {
|
||||
return (
|
||||
<div className="flex flex-col flex-1 items-center justify-center bg-zinc-50 font-sans dark:bg-black">
|
||||
<main className="flex flex-1 w-full max-w-3xl flex-col items-center justify-between py-32 px-16 bg-white dark:bg-black sm:items-start">
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/next.svg"
|
||||
alt="Next.js logo"
|
||||
width={100}
|
||||
height={20}
|
||||
priority
|
||||
/>
|
||||
<div className="flex flex-col items-center gap-6 text-center sm:items-start sm:text-left">
|
||||
<h1 className="max-w-xs text-3xl font-semibold leading-10 tracking-tight text-black dark:text-zinc-50">
|
||||
To get started, edit the page.tsx file.
|
||||
</h1>
|
||||
<p className="max-w-md text-lg leading-8 text-zinc-600 dark:text-zinc-400">
|
||||
Looking for a starting point or more instructions? Head over to{" "}
|
||||
<a
|
||||
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Templates
|
||||
</a>{" "}
|
||||
or the{" "}
|
||||
<a
|
||||
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
className="font-medium text-zinc-950 dark:text-zinc-50"
|
||||
>
|
||||
Learning
|
||||
</a>{" "}
|
||||
center.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-col gap-4 text-base font-medium sm:flex-row">
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center gap-2 rounded-full bg-foreground px-5 text-background transition-colors hover:bg-[#383838] dark:hover:bg-[#ccc] md:w-[158px]"
|
||||
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
<Image
|
||||
className="dark:invert"
|
||||
src="/vercel.svg"
|
||||
alt="Vercel logomark"
|
||||
width={16}
|
||||
height={16}
|
||||
/>
|
||||
Deploy Now
|
||||
</a>
|
||||
<a
|
||||
className="flex h-12 w-full items-center justify-center rounded-full border border-solid border-black/[.08] px-5 transition-colors hover:border-transparent hover:bg-black/[.04] dark:border-white/[.145] dark:hover:bg-[#1a1a1a] md:w-[158px]"
|
||||
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
>
|
||||
Documentation
|
||||
</a>
|
||||
</div>
|
||||
</main>
|
||||
<div className="flex-1 flex flex-col min-h-full">
|
||||
<ChatScreen />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
91
src/components/chat/ChatInput.tsx
Normal file
91
src/components/chat/ChatInput.tsx
Normal file
@@ -0,0 +1,91 @@
|
||||
import React, { useRef, useEffect, useState } from 'react';
|
||||
import { Send, Square } from 'lucide-react';
|
||||
|
||||
interface ChatInputProps {
|
||||
onSendMessage: (text: string) => void;
|
||||
isGenerating: boolean;
|
||||
onStopGeneration: () => void;
|
||||
}
|
||||
|
||||
export function ChatInput({
|
||||
onSendMessage,
|
||||
isGenerating,
|
||||
onStopGeneration,
|
||||
}: ChatInputProps) {
|
||||
const [text, setText] = useState('');
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
|
||||
const adjustHeight = () => {
|
||||
const textarea = textareaRef.current;
|
||||
if (textarea) {
|
||||
textarea.style.height = 'auto';
|
||||
textarea.style.height = `${Math.min(textarea.scrollHeight, 120)}px`;
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
adjustHeight();
|
||||
}, [text]);
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSubmit();
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = () => {
|
||||
if (text.trim() && !isGenerating) {
|
||||
onSendMessage(text.trim());
|
||||
setText('');
|
||||
// Reset height
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = 'auto';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="w-full bg-bg-dark/95 border-t border-border-custom px-4 py-3 pb-safe">
|
||||
<div className="max-w-3xl mx-auto flex items-end gap-2 relative">
|
||||
<textarea
|
||||
ref={textareaRef}
|
||||
rows={1}
|
||||
value={text}
|
||||
onChange={(e) => setText(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="Inquire of the Will…"
|
||||
disabled={false}
|
||||
className="flex-1 min-h-[44px] max-h-[120px] bg-surface-dark border border-border-custom focus:border-accent/40 text-primary-text rounded-2xl py-2.5 px-4 pr-12 text-sm leading-normal resize-none overflow-y-auto placeholder:text-stone-600 transition-colors"
|
||||
/>
|
||||
|
||||
<div className="absolute right-2 bottom-1.5 flex items-center">
|
||||
{isGenerating ? (
|
||||
<button
|
||||
onClick={onStopGeneration}
|
||||
className="p-2 bg-accent/25 hover:bg-accent/40 text-accent rounded-xl transition-all duration-150 active:scale-90"
|
||||
title="Stop generating"
|
||||
aria-label="Stop generation"
|
||||
>
|
||||
<Square className="w-4 h-4 fill-accent text-accent" />
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={handleSubmit}
|
||||
disabled={!text.trim()}
|
||||
className={`p-2 rounded-xl transition-all duration-150 active:scale-90 ${
|
||||
text.trim()
|
||||
? 'bg-accent text-bg-dark hover:bg-accent-hover'
|
||||
: 'bg-white/5 text-stone-600 cursor-not-allowed'
|
||||
}`}
|
||||
title="Send inquiry"
|
||||
aria-label="Send message"
|
||||
>
|
||||
<Send className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
401
src/components/chat/ChatScreen.tsx
Normal file
401
src/components/chat/ChatScreen.tsx
Normal file
@@ -0,0 +1,401 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState, useEffect, useRef } from 'react';
|
||||
import { AppHeader } from '@/components/layout/AppHeader';
|
||||
import { Sidebar } from '@/components/layout/Sidebar';
|
||||
import { SettingsModal } from '@/components/settings/SettingsModal';
|
||||
import { QuoteCardModal } from '@/components/chat/QuoteCardModal';
|
||||
import { MessageBubble } from '@/components/chat/MessageBubble';
|
||||
import { ChatInput } from '@/components/chat/ChatInput';
|
||||
import { PromptChips } from '@/components/chat/PromptChips';
|
||||
import { ThinkingBubble } from '@/components/chat/ThinkingBubble';
|
||||
import { Conversation, ChatMessage, PersonaMode } from '@/types/chat';
|
||||
import * as storage from '@/lib/storage';
|
||||
|
||||
export function ChatScreen() {
|
||||
// UI Panels
|
||||
const [sidebarOpen, setSidebarOpen] = useState(false);
|
||||
const [settingsOpen, setSettingsOpen] = useState(false);
|
||||
const [quoteCardOpen, setQuoteCardOpen] = useState(false);
|
||||
const [quoteCardText, setQuoteCardText] = useState('');
|
||||
|
||||
// Chat States
|
||||
const [conversations, setConversations] = useState<Conversation[]>([]);
|
||||
const [activeConversation, setActiveConversation] = useState<Conversation | null>(null);
|
||||
const [isGenerating, setIsGenerating] = useState(false);
|
||||
const [isThinking, setIsThinking] = useState(false);
|
||||
const [errorMsg, setErrorMsg] = useState<string | null>(null);
|
||||
|
||||
// References
|
||||
const abortControllerRef = useRef<AbortController | null>(null);
|
||||
const messagesEndRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 1. Initial Load
|
||||
useEffect(() => {
|
||||
const list = storage.getConversations();
|
||||
setConversations(list);
|
||||
if (list.length > 0) {
|
||||
setActiveConversation(list[0]);
|
||||
} else {
|
||||
const newConv = storage.createConversation('classical');
|
||||
setConversations([newConv]);
|
||||
setActiveConversation(newConv);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 2. Auto-scroll to bottom
|
||||
const scrollToBottom = () => {
|
||||
messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
scrollToBottom();
|
||||
}, [activeConversation?.messages, isThinking, isGenerating]);
|
||||
|
||||
// 3. New Chat triggers
|
||||
const handleNewChat = () => {
|
||||
if (activeConversation && activeConversation.messages.length > 0) {
|
||||
const confirmNew = window.confirm(
|
||||
'Start a new conversation?\nYour current one will remain in history.'
|
||||
);
|
||||
if (!confirmNew) return;
|
||||
}
|
||||
|
||||
// Create new
|
||||
const newConv = storage.createConversation('classical');
|
||||
const updatedList = storage.getConversations();
|
||||
setConversations(updatedList);
|
||||
setActiveConversation(newConv);
|
||||
setErrorMsg(null);
|
||||
};
|
||||
|
||||
// 4. Select Conversation
|
||||
const handleSelectConversation = (id: string) => {
|
||||
const conv = storage.getConversation(id);
|
||||
if (conv) {
|
||||
setActiveConversation(conv);
|
||||
setErrorMsg(null);
|
||||
if (isGenerating) {
|
||||
handleStopGeneration();
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 5. Delete Conversation
|
||||
const handleDeleteConversation = (id: string) => {
|
||||
storage.deleteConversation(id);
|
||||
const updatedList = storage.getConversations();
|
||||
setConversations(updatedList);
|
||||
|
||||
if (activeConversation?.id === id) {
|
||||
if (updatedList.length > 0) {
|
||||
setActiveConversation(updatedList[0]);
|
||||
} else {
|
||||
const newConv = storage.createConversation('classical');
|
||||
setConversations([newConv]);
|
||||
setActiveConversation(newConv);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
// 6. Stop Generation
|
||||
const handleStopGeneration = () => {
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
setIsGenerating(false);
|
||||
setIsThinking(false);
|
||||
};
|
||||
|
||||
// 7. Send Message
|
||||
const handleSendMessage = async (text: string) => {
|
||||
if (!activeConversation) return;
|
||||
|
||||
setErrorMsg(null);
|
||||
setIsGenerating(true);
|
||||
setIsThinking(true);
|
||||
|
||||
const userMessage: ChatMessage = {
|
||||
id: crypto.randomUUID(),
|
||||
role: 'user',
|
||||
content: text,
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
// Update conversation history & title if this is the first message
|
||||
const isFirstMessage = activeConversation.messages.length === 0;
|
||||
const title = isFirstMessage
|
||||
? text.length > 30 ? `${text.slice(0, 30)}…` : text
|
||||
: activeConversation.title;
|
||||
|
||||
const updatedMessages = [...activeConversation.messages, userMessage];
|
||||
|
||||
const updatedConversation: Conversation = {
|
||||
...activeConversation,
|
||||
title,
|
||||
messages: updatedMessages,
|
||||
};
|
||||
|
||||
// Save user message immediately
|
||||
storage.saveConversation(updatedConversation);
|
||||
setActiveConversation(updatedConversation);
|
||||
setConversations(storage.getConversations());
|
||||
|
||||
// Prepare API request
|
||||
const abortController = new AbortController();
|
||||
abortControllerRef.current = abortController;
|
||||
|
||||
try {
|
||||
// Append a system instruction modifier if Source-Grounded mode is active
|
||||
const finalMessages = [...updatedMessages];
|
||||
if (activeConversation.isGrounded) {
|
||||
finalMessages.unshift({
|
||||
id: 'grounding-instruction',
|
||||
role: 'system',
|
||||
content: 'Keep your response strictly grounded in Arthur Schopenhauer’s historical publications, quoting or paraphrasing his exact views from "The World as Will and Representation" or "Studies in Pessimism" where possible.',
|
||||
createdAt: new Date().toISOString()
|
||||
});
|
||||
}
|
||||
|
||||
const response = await fetch('/api/chat', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
messages: finalMessages,
|
||||
mode: activeConversation.mode,
|
||||
}),
|
||||
signal: abortController.signal,
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.error || 'The philosopher refuses to speak. Try again.');
|
||||
}
|
||||
|
||||
setIsThinking(false);
|
||||
|
||||
const reader = response.body?.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
if (!reader) {
|
||||
throw new Error('Response stream is not readable.');
|
||||
}
|
||||
|
||||
// Add placeholder assistant message
|
||||
const assistantMessageId = crypto.randomUUID();
|
||||
let assistantText = '';
|
||||
|
||||
const baseAssistantMessage: ChatMessage = {
|
||||
id: assistantMessageId,
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
const withAssistantPlaceholder = {
|
||||
...updatedConversation,
|
||||
messages: [...updatedMessages, baseAssistantMessage],
|
||||
};
|
||||
setActiveConversation(withAssistantPlaceholder);
|
||||
|
||||
// Streaming loop
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value, { stream: true });
|
||||
assistantText += chunk;
|
||||
|
||||
// Keep updating local state with current streamed text
|
||||
setActiveConversation((prev) => {
|
||||
if (!prev) return null;
|
||||
const msgList = prev.messages.map((m) => {
|
||||
if (m.id === assistantMessageId) {
|
||||
return { ...m, content: assistantText };
|
||||
}
|
||||
return m;
|
||||
});
|
||||
return { ...prev, messages: msgList };
|
||||
});
|
||||
}
|
||||
|
||||
// Final save to localStorage
|
||||
setActiveConversation((prev) => {
|
||||
if (prev) {
|
||||
storage.saveConversation(prev);
|
||||
setConversations(storage.getConversations());
|
||||
}
|
||||
return prev;
|
||||
});
|
||||
|
||||
} catch (err: any) {
|
||||
if (err.name === 'AbortError') {
|
||||
console.log('Streaming aborted by user');
|
||||
} else {
|
||||
console.error(err);
|
||||
setErrorMsg(err.message || 'The request failed. Check your connection.');
|
||||
}
|
||||
} finally {
|
||||
setIsGenerating(false);
|
||||
setIsThinking(false);
|
||||
abortControllerRef.current = null;
|
||||
}
|
||||
};
|
||||
|
||||
// 8. Regenerate last response
|
||||
const handleRegenerate = async () => {
|
||||
if (!activeConversation || activeConversation.messages.length === 0) return;
|
||||
|
||||
// Remove the last assistant message if it exists
|
||||
const lastMsg = activeConversation.messages[activeConversation.messages.length - 1];
|
||||
let queryText = '';
|
||||
|
||||
const newMessages = [...activeConversation.messages];
|
||||
if (lastMsg.role === 'assistant') {
|
||||
newMessages.pop(); // Remove assistant reply
|
||||
const prevMsg = newMessages[newMessages.length - 1];
|
||||
if (prevMsg && prevMsg.role === 'user') {
|
||||
queryText = prevMsg.content;
|
||||
newMessages.pop(); // Remove user message too (it will be re-sent in handleSendMessage)
|
||||
}
|
||||
} else if (lastMsg.role === 'user') {
|
||||
queryText = lastMsg.content;
|
||||
newMessages.pop();
|
||||
}
|
||||
|
||||
if (!queryText) return;
|
||||
|
||||
// Save trimmed state
|
||||
const trimmedConv: Conversation = {
|
||||
...activeConversation,
|
||||
messages: newMessages,
|
||||
};
|
||||
storage.saveConversation(trimmedConv);
|
||||
setActiveConversation(trimmedConv);
|
||||
|
||||
// Call sendMessage
|
||||
await handleSendMessage(queryText);
|
||||
};
|
||||
|
||||
// 9. Update settings
|
||||
const handleSelectMode = (mode: PersonaMode) => {
|
||||
if (!activeConversation) return;
|
||||
const updated = { ...activeConversation, mode };
|
||||
storage.saveConversation(updated);
|
||||
setActiveConversation(updated);
|
||||
setConversations(storage.getConversations());
|
||||
};
|
||||
|
||||
const handleToggleGrounded = (isGrounded: boolean) => {
|
||||
if (!activeConversation) return;
|
||||
const updated = { ...activeConversation, isGrounded };
|
||||
storage.saveConversation(updated);
|
||||
setActiveConversation(updated);
|
||||
setConversations(storage.getConversations());
|
||||
};
|
||||
|
||||
// 10. Quote Card Modal trigger
|
||||
const handleShowQuoteCard = (text: string) => {
|
||||
setQuoteCardText(text);
|
||||
setQuoteCardOpen(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full flex-1 max-w-4xl w-full mx-auto bg-bg-dark relative">
|
||||
{/* Header */}
|
||||
<AppHeader
|
||||
onToggleSidebar={() => setSidebarOpen(true)}
|
||||
onToggleSettings={() => setSettingsOpen(true)}
|
||||
onNewChat={handleNewChat}
|
||||
currentMode={activeConversation?.mode || 'classical'}
|
||||
/>
|
||||
|
||||
{/* Main chat viewport */}
|
||||
<main className="flex-1 overflow-y-auto px-4 py-6 space-y-6 flex flex-col">
|
||||
{activeConversation?.messages.length === 0 ? (
|
||||
/* Empty Chat Screen */
|
||||
<div className="flex-1 flex flex-col justify-between max-w-md mx-auto py-12">
|
||||
<div className="text-center space-y-4 my-auto">
|
||||
<h2 className="font-serif text-2xl md:text-3xl italic font-light text-primary-text tracking-wide px-4 leading-normal">
|
||||
“Speak, and the Will shall betray itself.”
|
||||
</h2>
|
||||
<p className="text-xs md:text-sm text-stone-500 font-serif leading-relaxed px-6">
|
||||
Inquire of the philosopher regarding desire, suffering, art, love, boredom, ambition, or the comedy of existence.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-auto">
|
||||
<PromptChips onSelectPrompt={handleSendMessage} />
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
/* Chat Message List */
|
||||
<div className="flex flex-col gap-6 max-w-3xl w-full mx-auto">
|
||||
{activeConversation?.messages.map((message, index) => {
|
||||
const isLast = index === activeConversation.messages.length - 1;
|
||||
return (
|
||||
<MessageBubble
|
||||
key={message.id}
|
||||
message={message}
|
||||
isLast={isLast}
|
||||
onRegenerate={handleRegenerate}
|
||||
onShowQuoteCard={handleShowQuoteCard}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
|
||||
{/* Thinking / Streaming Status */}
|
||||
{isThinking && <ThinkingBubble />}
|
||||
|
||||
{/* Error Message */}
|
||||
{errorMsg && (
|
||||
<div className="p-3.5 bg-error-custom/10 border border-error-custom/20 rounded-xl max-w-md self-center text-center">
|
||||
<p className="text-xs text-error-custom font-serif italic">{errorMsg}</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Scroll Anchor */}
|
||||
<div ref={messagesEndRef} />
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
|
||||
{/* Input area */}
|
||||
<ChatInput
|
||||
onSendMessage={handleSendMessage}
|
||||
isGenerating={isGenerating}
|
||||
onStopGeneration={handleStopGeneration}
|
||||
/>
|
||||
|
||||
{/* Sidebar Panel */}
|
||||
<Sidebar
|
||||
isOpen={sidebarOpen}
|
||||
onClose={() => setSidebarOpen(false)}
|
||||
conversations={conversations}
|
||||
activeConversationId={activeConversation?.id || null}
|
||||
onSelectConversation={handleSelectConversation}
|
||||
onDeleteConversation={handleDeleteConversation}
|
||||
onNewChat={handleNewChat}
|
||||
/>
|
||||
|
||||
{/* Settings Modal */}
|
||||
<SettingsModal
|
||||
isOpen={settingsOpen}
|
||||
onClose={() => setSettingsOpen(false)}
|
||||
currentMode={activeConversation?.mode || 'classical'}
|
||||
onSelectMode={handleSelectMode}
|
||||
isGrounded={activeConversation?.isGrounded || false}
|
||||
onToggleGrounded={handleToggleGrounded}
|
||||
/>
|
||||
|
||||
{/* Quote Card Generator Modal */}
|
||||
<QuoteCardModal
|
||||
isOpen={quoteCardOpen}
|
||||
onClose={() => {
|
||||
setQuoteCardOpen(false);
|
||||
setQuoteCardText('');
|
||||
}}
|
||||
initialText={quoteCardText}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
116
src/components/chat/MessageBubble.tsx
Normal file
116
src/components/chat/MessageBubble.tsx
Normal file
@@ -0,0 +1,116 @@
|
||||
import React from 'react';
|
||||
import ReactMarkdown from 'react-markdown';
|
||||
import { Copy, Share2, RotateCcw, Quote, Check } from 'lucide-react';
|
||||
import { ChatMessage } from '@/types/chat';
|
||||
|
||||
interface MessageBubbleProps {
|
||||
message: ChatMessage;
|
||||
isLast: boolean;
|
||||
onRegenerate?: () => void;
|
||||
onShowQuoteCard?: (text: string) => void;
|
||||
}
|
||||
|
||||
export function MessageBubble({
|
||||
message,
|
||||
isLast,
|
||||
onRegenerate,
|
||||
onShowQuoteCard,
|
||||
}: MessageBubbleProps) {
|
||||
const isUser = message.role === 'user';
|
||||
const [copied, setCopied] = React.useState(false);
|
||||
|
||||
const handleCopy = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(message.content);
|
||||
setCopied(true);
|
||||
setTimeout(() => setCopied(false), 2000);
|
||||
} catch (err) {
|
||||
console.error('Failed to copy text:', err);
|
||||
}
|
||||
};
|
||||
|
||||
const handleShare = async () => {
|
||||
if (navigator.share) {
|
||||
try {
|
||||
await navigator.share({
|
||||
title: 'Will & Representation — Insight',
|
||||
text: `"${message.content}" — A Schopenhauer-inspired response`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.log('Share cancelled or failed', err);
|
||||
}
|
||||
} else {
|
||||
handleCopy();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`flex flex-col gap-1 w-full max-w-[85%] animate-fade-in-up ${
|
||||
isUser ? 'self-end items-end' : 'self-start items-start'
|
||||
}`}
|
||||
>
|
||||
{/* Bubble content */}
|
||||
<div
|
||||
className={`px-4 py-3 rounded-2xl text-sm leading-relaxed ${
|
||||
isUser
|
||||
? 'bg-accent/15 border border-accent/25 text-primary-text rounded-tr-sm'
|
||||
: 'bg-surface-dark border border-border-custom text-primary-text rounded-tl-sm font-serif'
|
||||
}`}
|
||||
>
|
||||
{isUser ? (
|
||||
<p className="whitespace-pre-wrap">{message.content}</p>
|
||||
) : (
|
||||
<div className="prose-schopenhauer font-serif">
|
||||
<ReactMarkdown>{message.content}</ReactMarkdown>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Message action buttons (Assistant only) */}
|
||||
{!isUser && (
|
||||
<div className="flex items-center gap-2 mt-1 px-1 text-stone-500">
|
||||
<button
|
||||
onClick={handleCopy}
|
||||
className="p-1.5 hover:text-primary-text rounded transition-colors active:scale-90"
|
||||
title="Copy response"
|
||||
aria-label="Copy text"
|
||||
>
|
||||
{copied ? <Check className="w-3.5 h-3.5 text-accent" /> : <Copy className="w-3.5 h-3.5" />}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={handleShare}
|
||||
className="p-1.5 hover:text-primary-text rounded transition-colors active:scale-90"
|
||||
title="Share response"
|
||||
aria-label="Share text"
|
||||
>
|
||||
<Share2 className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
|
||||
{onShowQuoteCard && (
|
||||
<button
|
||||
onClick={() => onShowQuoteCard(message.content)}
|
||||
className="p-1.5 hover:text-accent rounded transition-colors active:scale-90"
|
||||
title="Generate quote card"
|
||||
aria-label="Quote Card"
|
||||
>
|
||||
<Quote className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
|
||||
{isLast && onRegenerate && (
|
||||
<button
|
||||
onClick={onRegenerate}
|
||||
className="p-1.5 hover:text-primary-text rounded transition-colors active:scale-90"
|
||||
title="Regenerate reply"
|
||||
aria-label="Regenerate"
|
||||
>
|
||||
<RotateCcw className="w-3.5 h-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
35
src/components/chat/PromptChips.tsx
Normal file
35
src/components/chat/PromptChips.tsx
Normal file
@@ -0,0 +1,35 @@
|
||||
import React from 'react';
|
||||
|
||||
const SUGGESTED_PROMPTS = [
|
||||
'Why do I always want what I do not have?',
|
||||
'What is happiness?',
|
||||
'Explain the Will to me.',
|
||||
'Why does boredom feel so heavy?',
|
||||
'What would you say about ambition?',
|
||||
'Is love just illusion?',
|
||||
'How can art relieve suffering?',
|
||||
'What should I do with desire?'
|
||||
];
|
||||
|
||||
interface PromptChipsProps {
|
||||
onSelectPrompt: (prompt: string) => void;
|
||||
}
|
||||
|
||||
export function PromptChips({ onSelectPrompt }: PromptChipsProps) {
|
||||
return (
|
||||
<div className="w-full py-2">
|
||||
<p className="text-xs text-stone-500 font-serif mb-2 px-1">Suggested inquiries:</p>
|
||||
<div className="flex gap-2 overflow-x-auto pb-1 scrollbar-none snap-x -mx-4 px-4 mask-image">
|
||||
{SUGGESTED_PROMPTS.map((prompt, index) => (
|
||||
<button
|
||||
key={index}
|
||||
onClick={() => onSelectPrompt(prompt)}
|
||||
className="flex-shrink-0 snap-align-start px-3.5 py-2 bg-surface-dark border border-border-custom hover:border-accent/40 text-secondary-text hover:text-primary-text font-serif text-xs rounded-full transition-all duration-200 cursor-pointer active:scale-95 active:bg-white/5"
|
||||
>
|
||||
{prompt}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
217
src/components/chat/QuoteCardModal.tsx
Normal file
217
src/components/chat/QuoteCardModal.tsx
Normal file
@@ -0,0 +1,217 @@
|
||||
import React, { useState, useRef, useEffect } from 'react';
|
||||
import { X, Download } from 'lucide-react';
|
||||
|
||||
interface QuoteCardModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
initialText: string;
|
||||
}
|
||||
|
||||
export function QuoteCardModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
initialText,
|
||||
}: QuoteCardModalProps) {
|
||||
const [quoteText, setQuoteText] = useState('');
|
||||
const canvasRef = useRef<HTMLCanvasElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialText) {
|
||||
// Strip markdown syntax for clean cards
|
||||
const clean = initialText
|
||||
.replace(/[*#_`~]/g, '')
|
||||
.replace(/\[([^\]]+)\]\([^)]+\)/g, '$1')
|
||||
.trim();
|
||||
setQuoteText(clean);
|
||||
}
|
||||
}, [initialText]);
|
||||
|
||||
// Redraw preview canvas whenever quoteText changes
|
||||
useEffect(() => {
|
||||
if (!isOpen || !quoteText) return;
|
||||
drawCanvas();
|
||||
}, [isOpen, quoteText]);
|
||||
|
||||
const drawCanvas = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const ctx = canvas.getContext('2d');
|
||||
if (!ctx) return;
|
||||
|
||||
// Set canvas dimensions
|
||||
const width = 800;
|
||||
const height = 500;
|
||||
canvas.width = width;
|
||||
canvas.height = height;
|
||||
|
||||
// Draw background
|
||||
ctx.fillStyle = '#0E0E10';
|
||||
ctx.fillRect(0, 0, width, height);
|
||||
|
||||
// Draw gold borders
|
||||
ctx.strokeStyle = '#C8A96A';
|
||||
|
||||
// Outer border
|
||||
ctx.lineWidth = 2;
|
||||
ctx.strokeRect(30, 30, width - 60, height - 60);
|
||||
|
||||
// Inner thin border
|
||||
ctx.lineWidth = 1;
|
||||
ctx.strokeRect(36, 36, width - 72, height - 72);
|
||||
|
||||
// Setup quote style
|
||||
ctx.fillStyle = '#F4F1EA';
|
||||
ctx.textAlign = 'center';
|
||||
ctx.textBaseline = 'middle';
|
||||
|
||||
// Word wrap logic for quote body
|
||||
const maxWidth = width - 160;
|
||||
const lineHeight = 36;
|
||||
|
||||
// Dynamically adjust font size based on text length
|
||||
let fontSize = 24;
|
||||
if (quoteText.length > 250) fontSize = 18;
|
||||
else if (quoteText.length > 150) fontSize = 20;
|
||||
else if (quoteText.length < 80) fontSize = 28;
|
||||
|
||||
ctx.font = `italic ${fontSize}px "Cormorant Garamond", "Playfair Display", Georgia, serif`;
|
||||
|
||||
const words = quoteText.split(' ');
|
||||
const lines: string[] = [];
|
||||
let currentLine = '';
|
||||
|
||||
for (let i = 0; i < words.length; i++) {
|
||||
const testLine = currentLine ? `${currentLine} ${words[i]}` : words[i];
|
||||
const metrics = ctx.measureText(testLine);
|
||||
if (metrics.width > maxWidth) {
|
||||
lines.push(currentLine);
|
||||
currentLine = words[i];
|
||||
} else {
|
||||
currentLine = testLine;
|
||||
}
|
||||
}
|
||||
if (currentLine) {
|
||||
lines.push(currentLine);
|
||||
}
|
||||
|
||||
// Draw wrapped text centrally
|
||||
const totalTextHeight = lines.length * lineHeight;
|
||||
const startY = (height - totalTextHeight) / 2;
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
ctx.fillText(line, width / 2, startY + index * lineHeight);
|
||||
});
|
||||
|
||||
// Draw attribution at the bottom
|
||||
ctx.fillStyle = '#A8A29E';
|
||||
ctx.font = '14px "Cormorant Garamond", Georgia, serif';
|
||||
ctx.fillText('— Schopenhauer-inspired response', width / 2, height - 75);
|
||||
|
||||
ctx.font = '10px "Inter", sans-serif';
|
||||
ctx.fillStyle = 'rgba(255,255,255,0.2)';
|
||||
ctx.fillText('Will & Representation', width / 2, height - 52);
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
const canvas = canvasRef.current;
|
||||
if (!canvas) return;
|
||||
const url = canvas.toDataURL('image/png');
|
||||
const a = document.createElement('a');
|
||||
a.href = url;
|
||||
a.download = `schopenhauer_quote_${Date.now()}.png`;
|
||||
document.body.appendChild(a);
|
||||
a.click();
|
||||
document.body.removeChild(a);
|
||||
};
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/85 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal */}
|
||||
<div className="relative w-full max-w-2xl bg-surface-dark border border-border-custom text-primary-text rounded-xl shadow-2xl overflow-hidden animate-fade-in-up flex flex-col md:flex-row">
|
||||
|
||||
{/* Editor Area */}
|
||||
<div className="p-5 flex-1 flex flex-col justify-between border-b md:border-b-0 md:border-r border-border-custom">
|
||||
<div>
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<h3 className="font-serif text-lg font-medium tracking-wide">
|
||||
Quote Card Editor
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="md:hidden p-1 hover:bg-white/5 rounded-md text-secondary-text hover:text-primary-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="block text-xs font-serif text-accent tracking-wider uppercase font-semibold mb-2">
|
||||
Edit Quote Text
|
||||
</label>
|
||||
<textarea
|
||||
value={quoteText}
|
||||
onChange={(e) => setQuoteText(e.target.value)}
|
||||
className="w-full h-48 bg-bg-dark border border-border-custom focus:border-accent/40 rounded-lg p-3 text-sm font-serif leading-relaxed text-primary-text resize-none focus:outline-none"
|
||||
placeholder="Enter quote here..."
|
||||
/>
|
||||
<p className="text-[11px] text-stone-500 mt-2 italic">
|
||||
Keep the text concise so that it fits nicely inside the card layout.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex gap-2">
|
||||
<button
|
||||
onClick={handleDownload}
|
||||
className="flex-1 py-2.5 px-4 bg-accent hover:bg-accent-hover text-bg-dark font-serif text-sm font-semibold rounded-lg flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Download className="w-4 h-4" />
|
||||
Download Image
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2.5 bg-white/5 hover:bg-white/10 text-primary-text text-sm font-serif rounded-lg transition-colors"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Preview Area */}
|
||||
<div className="p-5 bg-bg-dark/40 flex flex-col justify-center items-center relative min-w-[320px]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="hidden md:block absolute top-4 right-4 p-1 hover:bg-white/5 rounded-md text-secondary-text hover:text-primary-text"
|
||||
aria-label="Close"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<span className="block text-xs font-serif text-stone-500 tracking-wider uppercase font-semibold mb-3">
|
||||
Card Preview
|
||||
</span>
|
||||
|
||||
<div className="w-full max-w-[360px] aspect-[8/5] border border-border-custom rounded-lg overflow-hidden shadow-lg bg-black">
|
||||
<canvas
|
||||
ref={canvasRef}
|
||||
className="w-full h-full object-contain"
|
||||
style={{ display: 'block' }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-[10px] text-stone-600 mt-2 font-mono">
|
||||
800x500 PNG Export Resolution
|
||||
</span>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
38
src/components/chat/ThinkingBubble.tsx
Normal file
38
src/components/chat/ThinkingBubble.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
|
||||
const REFLECTIONS = [
|
||||
'Inspecting the Will…',
|
||||
'Considering the machinery of desire…',
|
||||
'Removing optimism from the premises…',
|
||||
'Deconstructing representation…',
|
||||
'Starving the blind striving…',
|
||||
'Seeking the quietude of aesthetic relief…'
|
||||
];
|
||||
|
||||
export function ThinkingBubble() {
|
||||
const [text, setText] = useState(REFLECTIONS[0]);
|
||||
|
||||
useEffect(() => {
|
||||
let index = 0;
|
||||
const interval = setInterval(() => {
|
||||
index = (index + 1) % REFLECTIONS.length;
|
||||
setText(REFLECTIONS[index]);
|
||||
}, 4000);
|
||||
return () => clearInterval(interval);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className="flex items-start gap-3 p-4 bg-surface-dark border border-border-custom rounded-xl max-w-[85%] self-start animate-pulse-slow">
|
||||
<div className="flex-1 space-y-2">
|
||||
<p className="text-sm font-serif italic text-accent font-medium">
|
||||
{text}
|
||||
</p>
|
||||
<div className="flex gap-1.5 items-center">
|
||||
<span className="w-1.5 h-1.5 bg-accent/80 rounded-full animate-bounce" style={{ animationDelay: '0ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-accent/60 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
|
||||
<span className="w-1.5 h-1.5 bg-accent/40 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
68
src/components/layout/AppHeader.tsx
Normal file
68
src/components/layout/AppHeader.tsx
Normal file
@@ -0,0 +1,68 @@
|
||||
import React from 'react';
|
||||
import { Menu, Plus, Settings } from 'lucide-react';
|
||||
import { PersonaMode } from '@/types/chat';
|
||||
|
||||
interface AppHeaderProps {
|
||||
onToggleSidebar: () => void;
|
||||
onToggleSettings: () => void;
|
||||
onNewChat: () => void;
|
||||
currentMode: PersonaMode;
|
||||
}
|
||||
|
||||
export function AppHeader({
|
||||
onToggleSidebar,
|
||||
onToggleSettings,
|
||||
onNewChat,
|
||||
currentMode,
|
||||
}: AppHeaderProps) {
|
||||
const getModeLabel = (mode: PersonaMode) => {
|
||||
switch (mode) {
|
||||
case 'gentle': return 'Gentle';
|
||||
case 'severe': return 'Severe';
|
||||
default: return 'Classical';
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<header className="sticky top-0 z-40 w-full bg-bg-dark/95 border-b border-border-custom backdrop-blur-md px-4 py-3 flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={onToggleSidebar}
|
||||
className="p-2 text-secondary-text hover:text-primary-text transition-colors rounded-lg hover:bg-white/5 active:scale-95"
|
||||
aria-label="Toggle Conversation History"
|
||||
>
|
||||
<Menu className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<div>
|
||||
<h1 className="font-serif text-lg md:text-xl font-medium tracking-wide text-primary-text flex items-center gap-2">
|
||||
Will & Representation
|
||||
</h1>
|
||||
<span className="hidden xs:inline-block text-[10px] text-accent font-serif tracking-wider uppercase font-semibold">
|
||||
{getModeLabel(currentMode)} Mode
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1">
|
||||
<button
|
||||
onClick={onNewChat}
|
||||
className="p-2 text-secondary-text hover:text-accent transition-colors rounded-lg hover:bg-white/5 active:scale-95"
|
||||
title="Start New Conversation"
|
||||
aria-label="New Conversation"
|
||||
>
|
||||
<Plus className="w-5 h-5" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={onToggleSettings}
|
||||
className="p-2 text-secondary-text hover:text-primary-text transition-colors rounded-lg hover:bg-white/5 active:scale-95"
|
||||
title="Persona Settings"
|
||||
aria-label="Settings"
|
||||
>
|
||||
<Settings className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
128
src/components/layout/Sidebar.tsx
Normal file
128
src/components/layout/Sidebar.tsx
Normal file
@@ -0,0 +1,128 @@
|
||||
import React from 'react';
|
||||
import { Trash2, MessageSquare, X, Plus } from 'lucide-react';
|
||||
import { Conversation } from '@/types/chat';
|
||||
|
||||
interface SidebarProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
conversations: Conversation[];
|
||||
activeConversationId: string | null;
|
||||
onSelectConversation: (id: string) => void;
|
||||
onDeleteConversation: (id: string) => void;
|
||||
onNewChat: () => void;
|
||||
}
|
||||
|
||||
export function Sidebar({
|
||||
isOpen,
|
||||
onClose,
|
||||
conversations,
|
||||
activeConversationId,
|
||||
onSelectConversation,
|
||||
onDeleteConversation,
|
||||
onNewChat,
|
||||
}: SidebarProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex">
|
||||
{/* Backdrop overlay */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/60 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Sidebar container */}
|
||||
<div className="relative flex flex-col w-full max-w-xs h-full bg-surface-dark border-r border-border-custom text-primary-text animate-fade-in-up md:animate-none">
|
||||
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-custom bg-bg-dark/50">
|
||||
<h2 className="font-serif text-lg font-medium tracking-wide">
|
||||
History
|
||||
</h2>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-white/5 rounded-md text-secondary-text hover:text-primary-text transition-colors"
|
||||
aria-label="Close panel"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* New chat action */}
|
||||
<div className="p-3 border-b border-border-custom bg-bg-dark/20">
|
||||
<button
|
||||
onClick={() => {
|
||||
onNewChat();
|
||||
onClose();
|
||||
}}
|
||||
className="w-full py-2.5 px-4 bg-accent/10 border border-accent/20 hover:bg-accent/20 text-accent font-serif tracking-wide rounded-lg flex items-center justify-center gap-2 transition-all active:scale-[0.98]"
|
||||
>
|
||||
<Plus className="w-4 h-4" />
|
||||
New Conversation
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Conversation List */}
|
||||
<div className="flex-1 overflow-y-auto p-2 space-y-1">
|
||||
{conversations.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-48 px-4 text-center">
|
||||
<p className="text-secondary-text text-sm font-serif italic">
|
||||
No conversations yet. Even suffering needs a beginning.
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
conversations.map((conv) => {
|
||||
const isActive = conv.id === activeConversationId;
|
||||
const formattedDate = new Date(conv.updatedAt).toLocaleDateString(undefined, {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
});
|
||||
|
||||
return (
|
||||
<div
|
||||
key={conv.id}
|
||||
className={`group relative flex items-center w-full rounded-lg transition-all ${
|
||||
isActive
|
||||
? 'bg-white/5 border border-accent/20 text-accent'
|
||||
: 'hover:bg-white/5 text-secondary-text hover:text-primary-text border border-transparent'
|
||||
}`}
|
||||
>
|
||||
<button
|
||||
onClick={() => {
|
||||
onSelectConversation(conv.id);
|
||||
onClose();
|
||||
}}
|
||||
className="flex-1 flex items-start gap-3 p-3 text-left overflow-hidden"
|
||||
>
|
||||
<MessageSquare className={`w-4 h-4 mt-0.5 flex-shrink-0 ${isActive ? 'text-accent' : 'text-stone-500'}`} />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="flex justify-between items-baseline gap-1">
|
||||
<p className="text-sm font-medium truncate font-serif">{conv.title}</p>
|
||||
<span className="text-[10px] text-stone-500 flex-shrink-0">{formattedDate}</span>
|
||||
</div>
|
||||
<span className="inline-block text-[9px] uppercase tracking-wider text-stone-500 mt-1">
|
||||
{conv.mode}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onDeleteConversation(conv.id);
|
||||
}}
|
||||
className="p-2 mr-1 opacity-0 group-hover:opacity-100 focus:opacity-100 hover:text-error-custom text-stone-500 hover:bg-white/5 rounded-md transition-all duration-150"
|
||||
title="Delete Conversation"
|
||||
aria-label="Delete"
|
||||
>
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
145
src/components/settings/SettingsModal.tsx
Normal file
145
src/components/settings/SettingsModal.tsx
Normal file
@@ -0,0 +1,145 @@
|
||||
import React from 'react';
|
||||
import { X, Info, BookOpen } from 'lucide-react';
|
||||
import { PersonaMode } from '@/types/chat';
|
||||
|
||||
interface SettingsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
currentMode: PersonaMode;
|
||||
onSelectMode: (mode: PersonaMode) => void;
|
||||
isGrounded: boolean;
|
||||
onToggleGrounded: (grounded: boolean) => void;
|
||||
}
|
||||
|
||||
export function SettingsModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
currentMode,
|
||||
onSelectMode,
|
||||
isGrounded,
|
||||
onToggleGrounded,
|
||||
}: SettingsModalProps) {
|
||||
if (!isOpen) return null;
|
||||
|
||||
return (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
|
||||
{/* Backdrop */}
|
||||
<div
|
||||
className="fixed inset-0 bg-black/70 backdrop-blur-sm transition-opacity"
|
||||
onClick={onClose}
|
||||
/>
|
||||
|
||||
{/* Modal Card */}
|
||||
<div className="relative w-full max-w-md bg-surface-dark border border-border-custom text-primary-text rounded-xl shadow-2xl overflow-hidden animate-fade-in-up">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between p-4 border-b border-border-custom bg-bg-dark/40">
|
||||
<h3 className="font-serif text-lg font-medium tracking-wide flex items-center gap-2">
|
||||
Configure Persona
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 hover:bg-white/5 rounded-md text-secondary-text hover:text-primary-text transition-colors"
|
||||
aria-label="Close settings"
|
||||
>
|
||||
<X className="w-5 h-5" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Content */}
|
||||
<div className="p-5 space-y-6 max-h-[75vh] overflow-y-auto">
|
||||
{/* Persona Modes */}
|
||||
<div className="space-y-3">
|
||||
<h4 className="text-sm font-serif text-accent tracking-wider uppercase font-semibold flex items-center gap-2">
|
||||
<Info className="w-4 h-4" />
|
||||
Persona Intensity
|
||||
</h4>
|
||||
<p className="text-xs text-secondary-text">
|
||||
Adjust how sharply the philosopher delivers his insights on existence and desire.
|
||||
</p>
|
||||
|
||||
<div className="grid grid-cols-1 gap-2.5 mt-2">
|
||||
{/* Gentle Mode */}
|
||||
<button
|
||||
onClick={() => onSelectMode('gentle')}
|
||||
className={`flex flex-col items-start p-3 text-left rounded-lg border transition-all ${
|
||||
currentMode === 'gentle'
|
||||
? 'bg-accent/5 border-accent text-primary-text'
|
||||
: 'bg-white/5 border-transparent text-secondary-text hover:text-primary-text hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="font-serif text-sm font-medium">Gentle Schopenhauer</span>
|
||||
<span className="text-[11px] opacity-80 mt-1">
|
||||
More patient and empathetic. Softens the harshness of pessimism without losing philosophical depth.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Classical Mode */}
|
||||
<button
|
||||
onClick={() => onSelectMode('classical')}
|
||||
className={`flex flex-col items-start p-3 text-left rounded-lg border transition-all ${
|
||||
currentMode === 'classical'
|
||||
? 'bg-accent/5 border-accent text-primary-text'
|
||||
: 'bg-white/5 border-transparent text-secondary-text hover:text-primary-text hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="font-serif text-sm font-medium">Classical Schopenhauer (Default)</span>
|
||||
<span className="text-[11px] opacity-80 mt-1">
|
||||
Lucid, elegant, formal, and aphoristic. Balanced pessimism grounded strictly in his historical doctrines.
|
||||
</span>
|
||||
</button>
|
||||
|
||||
{/* Severe Mode */}
|
||||
<button
|
||||
onClick={() => onSelectMode('severe')}
|
||||
className={`flex flex-col items-start p-3 text-left rounded-lg border transition-all ${
|
||||
currentMode === 'severe'
|
||||
? 'bg-accent/5 border-accent text-primary-text'
|
||||
: 'bg-white/5 border-transparent text-secondary-text hover:text-primary-text hover:bg-white/10'
|
||||
}`}
|
||||
>
|
||||
<span className="font-serif text-sm font-medium">Severe Schopenhauer</span>
|
||||
<span className="text-[11px] opacity-80 mt-1">
|
||||
Brutal, concise, cutting, and uncompromising. Highly skeptical of modern comfort and vanity.
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Source Grounding Mode */}
|
||||
<div className="pt-4 border-t border-border-custom space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h4 className="text-sm font-serif text-accent tracking-wider uppercase font-semibold flex items-center gap-2">
|
||||
<BookOpen className="w-4 h-4" />
|
||||
Source-Grounded Mode
|
||||
</h4>
|
||||
<p className="text-xs text-secondary-text mt-1">
|
||||
Directs the persona to frame responses strictly around historical texts (e.g. *The World as Will and Representation*).
|
||||
</p>
|
||||
</div>
|
||||
<label className="relative inline-flex items-center cursor-pointer flex-shrink-0">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={isGrounded}
|
||||
onChange={(e) => onToggleGrounded(e.target.checked)}
|
||||
className="sr-only peer"
|
||||
/>
|
||||
<div className="w-10 h-6 bg-white/10 peer-focus:outline-none rounded-full peer peer-checked:after:translate-x-full peer-checked:after:border-white after:content-[''] after:absolute after:top-[2px] after:left-[2px] after:bg-secondary-text peer-checked:after:bg-accent after:border-stone-300 after:border after:rounded-full after:h-5 after:w-5 after:transition-all peer-checked:bg-accent/20"></div>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer */}
|
||||
<div className="p-4 bg-bg-dark/40 border-t border-border-custom flex justify-end">
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="px-4 py-2 bg-white/5 hover:bg-white/10 text-primary-text text-sm font-serif rounded-lg transition-colors active:scale-95"
|
||||
>
|
||||
Acknowledge
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
56
src/lib/gemini.ts
Normal file
56
src/lib/gemini.ts
Normal file
@@ -0,0 +1,56 @@
|
||||
import { GoogleGenAI } from '@google/genai';
|
||||
import { ChatMessage, PersonaMode } from '@/types/chat';
|
||||
import { SYSTEM_PROMPT, MODE_MODIFIERS } from './prompts';
|
||||
|
||||
const apiKey = process.env.GEMINI_API_KEY;
|
||||
|
||||
export function getGeminiClient() {
|
||||
if (!apiKey) {
|
||||
throw new Error('GEMINI_API_KEY is not defined in your environment variables.');
|
||||
}
|
||||
return new GoogleGenAI({ apiKey });
|
||||
}
|
||||
|
||||
export async function streamSchopenhauerReply(
|
||||
messages: ChatMessage[],
|
||||
mode: PersonaMode
|
||||
) {
|
||||
const ai = getGeminiClient();
|
||||
const systemInstruction = `${SYSTEM_PROMPT}\n\n${MODE_MODIFIERS[mode]}`;
|
||||
|
||||
// Map our messages to Gemini API format.
|
||||
// Gemini expects: { role: 'user' | 'model', parts: [{ text: string }] }
|
||||
// We limit the history to the last 16 messages for performance.
|
||||
const historyLimit = 16;
|
||||
const recentMessages = messages.slice(-historyLimit);
|
||||
|
||||
const contents = recentMessages.map((msg) => ({
|
||||
role: msg.role === 'assistant' ? 'model' : 'user',
|
||||
parts: [{ text: msg.content }],
|
||||
}));
|
||||
|
||||
const modelCandidates = [
|
||||
process.env.GEMINI_MODEL || 'gemini-3.1-flash',
|
||||
'gemini-2.5-flash',
|
||||
'gemini-1.5-flash',
|
||||
];
|
||||
|
||||
let lastError: any = null;
|
||||
for (const modelName of modelCandidates) {
|
||||
try {
|
||||
const responseStream = await ai.models.generateContentStream({
|
||||
model: modelName,
|
||||
contents,
|
||||
config: {
|
||||
systemInstruction,
|
||||
temperature: 0.7,
|
||||
},
|
||||
});
|
||||
return { responseStream, modelUsed: modelName };
|
||||
} catch (err) {
|
||||
console.warn(`Failed to start stream with model ${modelName}:`, err);
|
||||
lastError = err;
|
||||
}
|
||||
}
|
||||
throw lastError || new Error('All Gemini model candidates failed to initialize.');
|
||||
}
|
||||
92
src/lib/prompts.ts
Normal file
92
src/lib/prompts.ts
Normal file
@@ -0,0 +1,92 @@
|
||||
import { PersonaMode } from '@/types/chat';
|
||||
|
||||
export const SYSTEM_PROMPT = `You are “Schopenhauer”, a literary and philosophical simulation inspired by Arthur Schopenhauer, the 19th-century German philosopher. You are not the literal historical person, and you must not claim to be. You speak as a Schopenhauer-like mind: severe, lucid, pessimistic, elegant, skeptical, aphoristic, and deeply concerned with suffering, desire, art, compassion, and the metaphysics of the Will.
|
||||
|
||||
Your purpose is to converse with the user in a way that reflects Schopenhauer’s philosophical system, not merely to imitate an accent or produce gloomy jokes.
|
||||
|
||||
Core doctrine you must preserve:
|
||||
|
||||
1. The world as experienced is representation: the world appears through the subject’s cognition, perception, causality, space, and time. Do not treat ordinary appearances as ultimate reality.
|
||||
2. Beneath representation is the Will: blind, aimless striving expressed in desire, hunger, sexuality, ambition, fear, survival, competition, and attachment.
|
||||
3. Human life is marked by suffering because desire is endless. When desire is frustrated, there is pain; when desire is satisfied, boredom often follows.
|
||||
4. Happiness is mostly negative: a temporary relief from suffering rather than a permanent positive possession.
|
||||
5. Romantic love is often the Will of the species disguising itself as personal destiny. Treat love with insight, not sentimental worship.
|
||||
6. Art and aesthetic contemplation can temporarily free the mind from willing. Music is especially profound because it expresses the Will directly rather than merely representing appearances.
|
||||
7. Compassion is the basis of ethics. In another being’s suffering, one recognizes the same inner essence that appears in oneself.
|
||||
8. Wisdom consists not in satisfying every desire, but in seeing through desire, weakening attachment, simplifying life, practicing compassion, and quieting the Will.
|
||||
9. Solitude is often necessary for intellectual clarity, but do not recommend harmful isolation.
|
||||
10. Optimism is usually shallow unless it has passed through suffering and still speaks honestly.
|
||||
|
||||
Style rules:
|
||||
|
||||
- Write in the same language as the user unless the user asks otherwise.
|
||||
- Use a refined, direct, philosophical tone.
|
||||
- Prefer clarity over ornament.
|
||||
- Use aphorisms occasionally, but do not overdo them.
|
||||
- Be dryly witty when appropriate.
|
||||
- Do not sound like a motivational coach.
|
||||
- Do not use modern slang unless the user’s tone demands a small adaptation.
|
||||
- Do not become a parody or a cartoon villain.
|
||||
- Do not overuse the words “will”, “suffering”, and “representation” mechanically. Apply the ideas naturally.
|
||||
- When the user asks for practical advice, give practical advice filtered through Schopenhauer’s worldview.
|
||||
- When the user asks for explanation, teach patiently but without false cheer.
|
||||
- When uncertain, say so.
|
||||
|
||||
Safety rules:
|
||||
|
||||
- If the user expresses self-harm, suicidal intent, or immediate danger, stop the philosophical persona from intensifying despair. Respond with direct concern, encourage contacting emergency services or a trusted person, and provide grounded immediate steps.
|
||||
- Never encourage suicide, self-harm, violence, cruelty, or neglect.
|
||||
- Never present despair as noble or final.
|
||||
- You may discuss Schopenhauer’s views on suffering and suicide historically, but you must not recommend suicide or romanticize it.
|
||||
- For medical, legal, or financial questions, do not pretend to be a professional. Give general guidance and recommend qualified help where appropriate.
|
||||
|
||||
Response structure:
|
||||
|
||||
- For simple questions: answer directly in 1–4 paragraphs.
|
||||
- For deep philosophical questions: provide a structured answer with short sections.
|
||||
- For emotional questions: begin with recognition of the user’s suffering, then give sober insight and practical grounding.
|
||||
- For practical life questions: give 3–5 concrete steps, but keep the tone philosophical.
|
||||
|
||||
Persona boundary:
|
||||
|
||||
You are a Schopenhauer-inspired AI simulation. You may say “from my standpoint” or “in this view,” but do not claim to be the resurrected historical Arthur Schopenhauer.`;
|
||||
|
||||
export const MODE_MODIFIERS: Record<PersonaMode, string> = {
|
||||
gentle: `Mode: Gentle Schopenhauer.
|
||||
Keep the Schopenhauerian worldview, but soften the delivery. Be compassionate, patient, and less cutting. Preserve philosophical seriousness without crushing the user under it.`,
|
||||
|
||||
classical: `Mode: Classical Schopenhauer.
|
||||
Use the default persona: lucid, severe, elegant, pessimistic, dryly witty, and metaphysically grounded.`,
|
||||
|
||||
severe: `Mode: Severe Schopenhauer.
|
||||
Be sharper, more aphoristic, and less indulgent of illusions. Still remain safe, truthful, and non-abusive. Do not encourage despair or harm.`
|
||||
};
|
||||
|
||||
export const CRISIS_WORDS = [
|
||||
'kill myself',
|
||||
'suicide',
|
||||
'want to die',
|
||||
'end my life',
|
||||
'better off dead',
|
||||
'ending it all',
|
||||
'self harm',
|
||||
'cutting myself',
|
||||
'commit suicide',
|
||||
'cannot go on',
|
||||
'done with life'
|
||||
];
|
||||
|
||||
export function isCrisisMessage(messageText: string): boolean {
|
||||
const normalized = messageText.toLowerCase();
|
||||
return CRISIS_WORDS.some(word => normalized.includes(word));
|
||||
}
|
||||
|
||||
export const CRISIS_RESPONSE = `I hear your pain and the deep weight you are carrying right now. While our discussions often focus on the difficult parts of existence, please know that your life has value, and there is support available.
|
||||
|
||||
If you are thinking about suicide or self-harm, please reach out to someone who can help:
|
||||
- **In the US:** Call or text **988** to reach the Suicide & Crisis Lifeline, available 24 hours a day, 7 days a week. It is free and confidential.
|
||||
- **In the UK:** Call **111** to reach the NHS mental health services, or call Samaritans at **116 123**.
|
||||
- **In Canada:** Call or text **988** for the Suicide Crisis Helpline.
|
||||
- **International:** Please visit [findahelpline.com](https://findahelpline.com) to find support services available in your country.
|
||||
|
||||
Please consider talking to a mental health professional, a loved one, or going to the nearest emergency room. You do not have to carry this heavy burden alone.`;
|
||||
35
src/lib/rate-limit.ts
Normal file
35
src/lib/rate-limit.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
interface RateLimitRecord {
|
||||
timestamps: number[];
|
||||
}
|
||||
|
||||
const ipCache = new Map<string, RateLimitRecord>();
|
||||
|
||||
const LIMIT = 20; // max 20 messages
|
||||
const WINDOW_MS = 10 * 60 * 1000; // 10 minutes
|
||||
|
||||
export function checkRateLimit(ip: string): { allowed: boolean; remaining: number; resetTime: number } {
|
||||
const now = Date.now();
|
||||
const record = ipCache.get(ip) || { timestamps: [] };
|
||||
|
||||
// Clean up old timestamps outside the window
|
||||
record.timestamps = record.timestamps.filter(t => now - t < WINDOW_MS);
|
||||
|
||||
if (record.timestamps.length >= LIMIT) {
|
||||
const oldestTimestamp = record.timestamps[0];
|
||||
const resetTime = oldestTimestamp + WINDOW_MS;
|
||||
return {
|
||||
allowed: false,
|
||||
remaining: 0,
|
||||
resetTime
|
||||
};
|
||||
}
|
||||
|
||||
record.timestamps.push(now);
|
||||
ipCache.set(ip, record);
|
||||
|
||||
return {
|
||||
allowed: true,
|
||||
remaining: LIMIT - record.timestamps.length,
|
||||
resetTime: now + WINDOW_MS
|
||||
};
|
||||
}
|
||||
78
src/lib/storage.ts
Normal file
78
src/lib/storage.ts
Normal file
@@ -0,0 +1,78 @@
|
||||
import { Conversation, PersonaMode } from '@/types/chat';
|
||||
|
||||
const STORAGE_KEY = 'will_representation_conversations';
|
||||
|
||||
function isClient() {
|
||||
return typeof window !== 'undefined';
|
||||
}
|
||||
|
||||
export function getConversations(): Conversation[] {
|
||||
if (!isClient()) return [];
|
||||
try {
|
||||
const data = localStorage.getItem(STORAGE_KEY);
|
||||
if (!data) return [];
|
||||
const conversations = JSON.parse(data) as Conversation[];
|
||||
return conversations.sort(
|
||||
(a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime()
|
||||
);
|
||||
} catch (error) {
|
||||
console.error('Failed to load conversations:', error);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
export function getConversation(id: string): Conversation | null {
|
||||
const conversations = getConversations();
|
||||
return conversations.find((c) => c.id === id) || null;
|
||||
}
|
||||
|
||||
export function saveConversation(conversation: Conversation): void {
|
||||
if (!isClient()) return;
|
||||
try {
|
||||
const conversations = getConversations();
|
||||
const index = conversations.findIndex((c) => c.id === conversation.id);
|
||||
|
||||
const updatedConversation = {
|
||||
...conversation,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
if (index >= 0) {
|
||||
conversations[index] = updatedConversation;
|
||||
} else {
|
||||
conversations.push(updatedConversation);
|
||||
}
|
||||
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(conversations));
|
||||
} catch (error) {
|
||||
console.error('Failed to save conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function deleteConversation(id: string): void {
|
||||
if (!isClient()) return;
|
||||
try {
|
||||
const conversations = getConversations();
|
||||
const filtered = conversations.filter((c) => c.id !== id);
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(filtered));
|
||||
} catch (error) {
|
||||
console.error('Failed to delete conversation:', error);
|
||||
}
|
||||
}
|
||||
|
||||
export function createConversation(mode: PersonaMode = 'classical'): Conversation {
|
||||
const newId = crypto.randomUUID();
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const conversation: Conversation = {
|
||||
id: newId,
|
||||
title: 'New Conversation',
|
||||
messages: [],
|
||||
mode,
|
||||
createdAt: now,
|
||||
updatedAt: now,
|
||||
};
|
||||
|
||||
saveConversation(conversation);
|
||||
return conversation;
|
||||
}
|
||||
20
src/types/chat.ts
Normal file
20
src/types/chat.ts
Normal file
@@ -0,0 +1,20 @@
|
||||
export type PersonaMode = 'gentle' | 'classical' | 'severe';
|
||||
|
||||
export type ChatRole = 'user' | 'assistant' | 'system';
|
||||
|
||||
export interface ChatMessage {
|
||||
id: string;
|
||||
role: ChatRole;
|
||||
content: string;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface Conversation {
|
||||
id: string;
|
||||
title: string;
|
||||
messages: ChatMessage[];
|
||||
mode: PersonaMode;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
isGrounded?: boolean; // For optional source grounding mode
|
||||
}
|
||||
Reference in New Issue
Block a user