first implé

This commit is contained in:
achraf
2026-06-03 19:17:38 +02:00
parent 50d908e9f2
commit f165ba894f
27 changed files with 4861 additions and 119 deletions

18
.dockerignore Normal file
View 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

View File

@@ -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
View 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
View File

@@ -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 Schopenhauers 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
View 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

View File

@@ -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

File diff suppressed because it is too large Load Diff

View File

@@ -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

File diff suppressed because it is too large Load Diff

106
src/app/api/chat/route.ts Normal file
View 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' } }
);
}
}

View File

@@ -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;
}

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View 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>
);
}

View 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 Schopenhauers 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View 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
View 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
View 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 Schopenhauers 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 subjects 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 beings 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 users 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 Schopenhauers 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 Schopenhauers 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 14 paragraphs.
- For deep philosophical questions: provide a structured answer with short sections.
- For emotional questions: begin with recognition of the users suffering, then give sober insight and practical grounding.
- For practical life questions: give 35 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
View 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
View 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
View 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
}