playtime - 4/5

This commit is contained in:
lovebird 2026-02-08 15:09:32 +01:00
parent f49ee39eb5
commit 0fa17ead0c
70 changed files with 8315 additions and 715 deletions

View File

@ -0,0 +1,39 @@
# 🚀 Deploy Mux Proxy Function
The credentials are now hardcoded in the function. You need to redeploy it:
## Option 1: Via Supabase Dashboard (Easiest)
1. Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions
2. Find **mux-proxy** in the functions list
3. Click the **⋮** menu (three dots) next to it
4. Select **"Deploy"** or **"Deploy new version"**
5. Upload the updated function files from `supabase/functions/mux-proxy/`
## Option 2: Via Supabase CLI
If you have the CLI installed:
```bash
supabase functions deploy mux-proxy
```
## ✅ After Deployment
1. Go to http://localhost:5173/playground/video-player
2. Sign in
3. Try uploading a video
4. Should work now! 🎉
## ⚠️ Important
The credentials are currently HARDCODED in the function. This is for testing only!
**Before deploying to production:**
1. Set MUX_TOKEN_ID and MUX_TOKEN_SECRET as Supabase secrets
2. Uncomment the env loading lines in `supabase/functions/mux-proxy/index.ts`
3. Remove the hardcoded values
4. Redeploy
See `docs/SETUP_MUX_SECRETS.md` for proper setup instructions.

View File

@ -0,0 +1,223 @@
# Mux Video Quick Start Guide
## 🎯 What You Have Now
You now have a complete Mux video integration with:
1. **VideoCard Component** - Display videos with Vidstack player
2. **Mux Uploader Integration** - Upload videos directly to Mux
3. **Video Player Playground** - Test at `/playground/video-player`
4. **Supabase Edge Function** - Secure Mux API proxy
## ⚡ Quick Setup (5 minutes)
### Step 1: Get Mux Credentials
1. Go to https://dashboard.mux.com/signup
2. After signing up, go to **Settings** → **Access Tokens**
3. Click **Generate new token**
4. Name it "pm-pics" and enable **Mux Video** permissions
5. Copy the **Token ID** and **Token Secret**
### Step 2: Configure Supabase Secrets
You need to add your Mux credentials as secrets to your Supabase project:
```bash
# Using Supabase CLI
supabase secrets set MUX_TOKEN_ID=your_token_id_here
supabase secrets set MUX_TOKEN_SECRET=your_token_secret_here
```
Or via Supabase Dashboard:
1. Go to your Supabase project dashboard
2. Navigate to **Project Settings****Edge Functions** → **Secrets**
3. Add `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET`
### Step 3: Deploy the Edge Function
```bash
supabase functions deploy mux-proxy
```
### Step 4: Test It Out
1. Start your dev server: `npm run dev`
2. Navigate to http://localhost:5173/playground/video-player
3. Sign in (required for uploads)
4. Go to the "Upload Video" tab
5. Drag & drop a video or click to select one
6. Watch it upload, process, and play!
## 📝 How It Works
### The Upload Flow
```
User Selects Video
Frontend calls /functions/v1/mux-proxy (create-upload)
Edge Function calls Mux API
Returns signed upload URL
MuxUploader uploads video directly to Mux
Video processes on Mux servers
Poll for asset creation
Get playback ID
Play video using Vidstack player
```
### Key Concepts
- **Upload ID**: Temporary ID for tracking the upload
- **Asset ID**: Permanent ID for managing the video in Mux
- **Playback ID**: Public ID used to stream the video
### URLs You Get
After uploading, you get these URLs:
**HLS Stream (for playback):**
```
https://stream.mux.com/{PLAYBACK_ID}.m3u8
```
**Thumbnail:**
```
https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg
```
**MP4 Download (if enabled):**
```
https://stream.mux.com/{PLAYBACK_ID}/high.mp4
```
## 💾 Save to Database
The playground has a "Save to Database" button that stores:
```typescript
{
user_id: current_user.id,
title: "Video Title",
description: "Description",
video_url: "https://stream.mux.com/{playback_id}.m3u8",
thumbnail_url: "https://image.mux.com/{playback_id}/thumbnail.jpg",
meta: {
mux_asset_id: "asset_abc123",
mux_playback_id: "playback_xyz789"
}
}
```
## 🎨 Using in Your App
### Upload Component
```tsx
import MuxUploader from "@mux/mux-uploader-react";
import { supabase } from "@/integrations/supabase/client";
function MyUploader() {
const fetchUploadUrl = async () => {
const response = await fetch(
`${supabase.supabaseUrl}/functions/v1/mux-proxy`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'create-upload' }),
}
);
const { data } = await response.json();
return data.url;
};
return (
<MuxUploader
endpoint={fetchUploadUrl}
onSuccess={(e) => console.log('Done!', e.detail)}
/>
);
}
```
### Video Player
```tsx
import VideoCard from "@/components/VideoCard";
function MyVideo({ video }) {
return (
<VideoCard
videoId={video.id}
videoUrl={video.video_url}
thumbnailUrl={video.thumbnail_url}
title={video.title}
author={video.author_name}
authorId={video.user_id}
likes={video.likes_count || 0}
comments={video.comments_count || 0}
description={video.description}
/>
);
}
```
## 🆓 Pricing
Mux offers **$20/month in free credits**, which includes:
- ~40 minutes of video encoding
- ~100 hours of video streaming
Perfect for testing and small projects!
## 🔧 Troubleshooting
**Upload button doesn't appear**
- Make sure you're signed in
- Check that edge function is deployed
**Upload fails**
- Verify Mux credentials are set as Supabase secrets
- Check browser console for errors
- Make sure edge function has correct environment variables
**Video stuck processing**
- Large videos take time (can be 5-10 minutes for HD)
- Check Mux dashboard: https://dashboard.mux.com
- Look for the asset in the "Assets" section
**Video won't play**
- Verify the HLS URL format: `https://stream.mux.com/{playback_id}.m3u8`
- Check that playback policy is "public"
- Try the URL directly in your browser
## 📚 More Info
See `docs/mux-integration.md` for detailed documentation including:
- Complete API reference
- Webhook setup
- Advanced configuration
- Production best practices
## 🎉 You're Done!
You now have:
- ✅ VideoCard component for displaying videos
- ✅ Mux upload integration
- ✅ Secure API proxy via Edge Functions
- ✅ Video player playground
- ✅ Database storage ready
Go to `/playground/video-player` and start uploading! 🎬

View File

@ -0,0 +1,89 @@
# 🔐 Setup Mux Secrets in Supabase
## Your Credentials
```
MUX_TOKEN_ID: 3ceb1723-1274-48ed-bc1d-0ab967f2dda5
MUX_TOKEN_SECRET: kYuAFBuOEiA+XZD8qRfgv6rcLVTJWdOLUTrLhiYagVej8UCRdjSzxOAFpvFQJHePcDd/KhqFXcE
```
## ⚡ Quick Setup (2 minutes)
### Step 1: Open Supabase Dashboard
Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/settings/functions
### Step 2: Add Secrets
Look for **"Secrets"** or **"Environment Variables"** section.
Click **"New secret"** or **"Add secret"** and add:
**First Secret:**
- Name: `MUX_TOKEN_ID`
- Value: `3ceb1723-1274-48ed-bc1d-0ab967f2dda5`
**Second Secret:**
- Name: `MUX_TOKEN_SECRET`
- Value: `kYuAFBuOEiA+XZD8qRfgv6rcLVTJWdOLUTrLhiYagVej8UCRdjSzxOAFpvFQJHePcDd/KhqFXcE`
### Step 3: Save & Verify
1. Click **Save** or **Add**
2. You should see both secrets listed (values will be hidden)
### Step 4: Redeploy Function (if needed)
If the function still doesn't work after adding secrets:
1. Go to: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions
2. Find **mux-proxy** in the list
3. Click the **⋮** menu (three dots)
4. Select **"Redeploy"** or **"Deploy new version"**
## ✅ Test It
1. Go to http://localhost:5173/playground/video-player
2. Sign in
3. Try uploading a video
4. Should work now! 🎉
## 🔍 Troubleshooting
### Still getting "Mux credentials not configured"?
**Check #1: Are secrets set?**
- Dashboard → Settings → Functions → Secrets
- You should see `MUX_TOKEN_ID` and `MUX_TOKEN_SECRET` listed
**Check #2: Is function deployed?**
- Dashboard → Edge Functions
- `mux-proxy` should show as "Active" or "Deployed"
**Check #3: Redeploy**
- Sometimes secrets don't update until you redeploy
- Click the ⋮ menu next to mux-proxy → Redeploy
**Check #4: Browser console**
- Open DevTools (F12)
- Look for detailed error messages
### Different error?
Check the browser console and edge function logs:
- Dashboard → Edge Functions → mux-proxy → Logs
## 📝 Notes
- **Local .env file**: Only used for local development, NOT for edge functions
- **Edge function secrets**: Must be set in Supabase Dashboard
- **Security**: Secrets are encrypted and never exposed to the client
- **Updates**: If you change secrets, redeploy the function
## 🎯 Quick Links
- Mux Dashboard: https://dashboard.mux.com
- Supabase Project: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip
- Edge Functions: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/functions
- Function Settings: https://supabase.com/dashboard/project/ytoadlpbdguriiccjnip/settings/functions

View File

@ -0,0 +1,49 @@
# Caching Strategy
## 1. Server-Side Caching (The Fast Layer)
**Goal**: Reduce DB load by caching public reads (Feeds, Profiles).
### Cache Adapter Interface
We will use a platform-agnostic interface to support both Memory (Dev/Single-Node) and Redis (Prod/Cluster).
```typescript
// server/src/commons/cache/types.ts
export interface CacheAdapter {
get<T>(key: string): Promise<T | null>;
set<T>(key: string, value: T, ttl?: number): Promise<void>;
del(key: string): Promise<void>;
flush(pattern?: string): Promise<void>;
}
```
### Implementations
- [ ] **MemoryCache**: Use `lru-cache`. Default for local dev.
- [ ] **RedisCache**: Use `ioredis`. Enabled if `REDIS_URL` is present.
### usage in `ServingProduct`
- **Feed**: Cache `home-feed` for 60 seconds (Target: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)).
- **Profile**: Cache `profile-{id}` for 5 minutes (Target: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)). Invalidate on profile update webhook.
---
## 2. Client-Side Caching (The Smart Layer)
**Goal**: Eliminate "Double Fetching" and provide instant navigation (Back/Forward).
### TanStack Query (React Query)
- [ ] **Config**: Set global `staleTime` to 5 minutes for "Content" (Posts, Pictures) in [`src/App.tsx`](../src/App.tsx).
- [ ] **Prefetching**:
- On hover of a User Link, `queryClient.prefetchQuery(['profile', id])`.
- On hover of a Post Card, `queryClient.prefetchQuery(['post', id])`.
- [ ] **Hydration**:
- Use `HydrationBoundary` to ingest `window.__INITIAL_STATE__` served by the optimized Server Injection in [`src/App.tsx`](../src/App.tsx).
### Optimistic Updates
- [ ] **Likes**: Update UI immediately. Rollback on error in [`src/components/LikeButton.tsx`](../src/components/LikeButton.tsx) (or relevant component).
- [ ] **Edits**: Update Local Cache immediately. Background sync.
---
## 3. CDN & Static Assets
- [ ] Ensure Supabase Storage bucket is behind a CDN (Cloudflare or Supabase built-in).
- [ ] **Thumbnails**: Use the Resizing Proxy (`/api/images/cache/...`) which natively caches processed images on disk/nginx.

View File

@ -0,0 +1,117 @@
# Canvas Inline Editing & Extension Slots Proposal
## Objective
Enable a rich "Design Mode" experience where widget properties (text, images) can be edited directly on the canvas (Inline Editing) and widgets can define "Slots" for nested or extended content.
## 1. Inline Editing
Instead of relying solely on the sidebar or modal settings, we can make properties directly editable within the visual representation of the widget.
### Concept
* **Property Mapping**: Map specific DOM elements in the widget template to widget properties (e.g., `${content}`, `${image-0}`).
* **Editor Activation**: When in "Edit Mode", these elements become interactive targets (contenteditable for text, click-to-pick for images).
* **Data Binding**: Changes to the inline element immediately update the underlying widget property map.
### 1.1 Text Editing
**Example: `text.html`**
Current:
```html
<p style="...${class}">${content}</p>
```
**Proposed Implementation:**
The renderer (HtmlWidget) detects text property placeholders and wraps them in a span that handles the click/edit event.
**Rendered Output (Design Mode):**
```html
<p style="...">
<span
data-widget-prop="content"
contenteditable="true"
onBlur="(e) => updateProp('content', e.target.innerText)"
class="hover:outline-dashed hover:outline-blue-400"
>
ast3
</span>
</p>
```
### 1.2 Image Editing
**Example: `image_col_3.html`**
Current:
```html
<img src="${image-0}" class="${imgClass} ..." ... />
```
**Proposed Implementation:**
The renderer identifies `<img>` tags where the `src` attribute is bound to a variable (e.g., `${image-0}`). It attaches an `onClick` handler to these images in Design Mode.
**Interaction Flow:**
1. **Hover**: Image overlay indicates "Change Image" (e.g., camera icon, dashed border).
2. **Click**: Triggers `ImagePickerDialog.tsx` popup found in `src/components/widgets/ImagePickerDialog.tsx`.
3. **Select**: User picks an image from the gallery (or uploads new).
4. **Update**: On selection (`onSelectPicture`), the `image_url` is mapped back to the widget's property (e.g., `props['image-0'] = picture.image_url`).
## 2. Extension Slots
Widgets should be able to define "Slots" where other widgets or specialized content can be inserted. This effectively allows widgets to act as layout containers for specific sections.
### Concept
* **Slot Definition**: Define areas within a widget template that serve as drop zones or modification points.
* **Extensions**: Other widgets or "Feature Blocks" that can be plugged into these slots.
### Example: `text.html` with Slots
We might want to allow injecting a "Call to Action" button or a "Divider" *inside* the text block structure, or perhaps an "Action Bar" slot that appears on hover.
**Template Definition (`text.html`):**
```html
<td class="long-text ...">
<p>${content}</p>
<!-- Extension Slot: 'footer' -->
<div data-slot="footer" class="slot-placeholder empty:hidden">
<!-- In Design Mode, this shows a "+" button if empty -->
</div>
</td>
```
### Configuration (`library.json`)
```json
{
"name": "Text",
"template": "./text.html",
"slots": {
"footer": {
"allowedWidgets": ["button", "link"],
"maxItems": 1
}
}
}
```
## 3. Combined Workflow (Design Mode)
1. **Select Widget**: The toolbar shows standard settings.
2. **Edit Content**: Strings are editable inline; Images trigger the picker.
3. **Slots**: defined "Slots" highlight for drag-and-drop insertion.
4. **Drop Widget**: Dragging a "Button" widget onto the "footer" slot nests it within the Text widget's data structure (e.g., `props.slots.footer = [ButtonWidget]`).
## Next Steps
1. **Parser Update**: Update `HtmlWidget` to:
* Inject `contenteditable` wrappers for text vars.
* Attach `onClick` to `<img>` tags mapped to variables.
* Parse `data-slot` and render `SlotContainer`.
2. **Interaction Layer**: Connect `ImagePickerDialog` and `onClick` handlers in `GenericCanvas`/`HtmlWidget`.
3. **Data Model**: Update widget property structure to support nested slot content.

View File

@ -0,0 +1,82 @@
# Canvas & HTML Export Architecture
This document outlines the architecture of the **Playground Canvas**, its dependency on the **Unified Layout Manager**, and the **HTML Email Export** workflow.
## 1. Core Architecture: Unified Layout Manager
**Source:** [src/lib/unifiedLayoutManager.ts](../src/lib/unifiedLayoutManager.ts)
The `UnifiedLayoutManager` is the brain of the layout system. It handles the data model and all state mutations.
### Data Model
- **`PageLayout`**: The root object representing a page. Contains a list of `LayoutContainer`s.
- **`LayoutContainer`**: A recursive structure that can contain `WidgetInstance`s or child `LayoutContainer`s. It defines the grid system (`columns`, `gap`).
- **`WidgetInstance`**: A reference to a registered widget (`widgetId`), containing specific properties (`props`) and order.
### Responsibilities
- **CRUD Operations**: `addWidgetToContainer`, `removeWidget`, `updateWidgetProps`.
- **Grid Management**: Logic for `moveWidgetInContainer` (directional swapping) and `updateContainerColumns`.
- **Persistence**: Handles saving/loading from storage/API and import/export to JSON.
## 2. Rendering: Generic Canvas
**Source:** [src/components/hmi/GenericCanvas.tsx](../src/components/hmi/GenericCanvas.tsx)
The `GenericCanvas` is the React presentation layer that consumes the layout data.
- **Recursive Rendering**: It iterates through the `PageLayout` and renders `LayoutContainer` components.
- **Context Usage**: Consumes `useLayout` hook to interact with `UnifiedLayoutManager` without direct coupling.
- **Modes**:
- **Edit Mode**: Renders controls for adding containers, widgets, and drag-and-drop handles.
- **View Mode**: Renders only the final content.
- **Auto-Logging (Hook)**: [src/pages/PlaygroundCanvas.tsx](../src/pages/PlaygroundCanvas.tsx) uses `usePlaygroundLogic` to automatically log the current layout state to the WebSocket server whenever it changes.
## 3. Custom Widgets (Email Bundle)
**Location:** [public/widgets/email/](../public/widgets/email/)
Widgets are defined via an external bundle system, allowing dynamic loading.
- **`library.json`**: The manifest file defining the bundle.
- **`root`**: Path to the root HTML template (e.g., `./body.html`).
- **`widgets`**: List of available widgets, mapping names to template files (e.g., `Text` -> `./text.html`).
- **Templates**: HTML files containing the widget structure and placeholders.
- **Placeholders**: `[[propName]]` syntax is used for dynamic content substitution.
## 4. HTML Email Export
**Source:** [src/lib/emailExporter.ts](../src/lib/emailExporter.ts)
The export machinery transforms the abstract `PageLayout` into a table-based HTML string suitable for email clients.
### Workflow
1. **Fetch Root**: Downloads the `rootTemplate` (e.g., `body.html`).
2. **Generate Body**:
- Iterate through `layout.containers`.
- Construct a `<table>` structure for each container.
- Within containers, iterate through `widgets`.
- Handle columns by creating `<td>` cells with calculated widths.
3. **Render Widgets**:
- For each widget, fetch its specific HTML template (defined in the registry/bundle).
- **Substitution**: Replace `[[key]]` placeholders in the template with values from `widget.props`.
4. **Assembly**: Inject the generated body HTML into the root template (replacing `${SOURCE}` or appending to `<body>`).
### Example Flow
1. **Layout**: Container (1 col) -> Text Widget (`props: { content: "Hello" }`).
2. **Exporter**:
- Fetches `body.html`.
- Fetches `text.html`: `<div>[[content]]</div>`.
- Substitutes: `<div>Hello</div>`.
- Wraps in Table: `<table><tr><td><div>Hello</div></td></tr></table>`.
- Injects into Body.
## Key Paths
- **Layout Logic**: [src/lib/unifiedLayoutManager.ts](../src/lib/unifiedLayoutManager.ts)
- **Canvas UI**: [src/components/hmi/GenericCanvas.tsx](../src/components/hmi/GenericCanvas.tsx)
- **Exporter**: [src/lib/emailExporter.ts](../src/lib/emailExporter.ts)
- **Email Widgets**: [public/widgets/email/](../public/widgets/email/)

View File

@ -0,0 +1,62 @@
# Creating Brand/Context Aware Articles
The AI Page Generator allows you to create highly consistent, on-brand content by combining **visual context** (Reference Images) with **structural context** (Context Templates). This workflow ensures that generated articles not only look like your brand but also follow your specific formatting and content standards.
## The Workflow
### 1. Visual Context (Reference Images)
Use **Reference Images** to establish the visual identity of your article.
* **What to use**: Upload brand assets, logo variations, previous diagram styles, or product photos.
* **How it works**: The AI "sees" these images and uses them to:
* **Style Match**: Generate new images that match the color palette and artistic style of your references.
* **Contextual Description**: Accurately describe visual details in the text (e.g., "As shown in the diagram...").
* **Brand Alignment**: Ensure generated visuals align with your brand's aesthetic.
### 2. Structural Context (Context Templates)
Use a **Context Template** in your prompt to define the exact structure and tone of the article. This serves as a "skeleton" for the AI to fill in.
* **What is a Context Template?**: A markdown structure that defines headers, required sections, image placement, and key points, without the final text.
* **How to use**: Paste a structured template into the prompt area.
#### Example Context Template
```markdown
# [Article Title]
## Overview
[Brief summary of the topic]
## Core Concepts
* Concept A: [Description]
* Concept B: [Description]
## Visual Breakdown
[Instruction: Generate an exploded view diagram here similar to the reference image]
* **Part 1**: Details...
* **Part 2**: Details...
## Technical Specifications
| Spec | Value |
|------|-------|
| [Key Spec] | [Value] |
## Conclusion
[Summarize benefits]
```
## Step-by-Step Guide
1. **Open Page Generator**: Click "Create Page" -> "Generate with AI".
2. **Add Reference Images**: Click the "Add" button and select your brand assets or style references.
3. **Input Context Template**: Paste your structured markdown template into the prompt box.
4. **Refine Prompt**: Add specific instructions above or below the template (e.g., "Fill out this template for a new shelving unit product using the attached technical drawings as reference").
5. **Generate**: The AI will combine your **Visual Context** (images) and **Structural Context** (template) to produce a production-ready article that feels authentic to your brand.
## Best Practices
* **Consistency**: Keep a library of standard templates for different content types (e.g., "Product Launch", "Technical Guide", "Case Study").
* **Quality References**: High-resolution, clear reference images yield better results.
* **Explicit Instructions**: Tell the AI *how* to use the references (e.g., "Use the color scheme from Image 1 for all generated diagrams").

View File

@ -0,0 +1,45 @@
# Custom Canvas & Widget Extension
## Overview
This document describes the mechanism for extending the playground canvas with custom widgets loaded on-demand.
## Widget Library Structure
External widget libraries are defined in a `library.json` file.
Example: `public/widgets/email/library.json`
```json
{
"root": "./body.html",
"name": "email",
"description": "Email widgets",
"widgets": [
{
"name": "HR",
"template": "./hr.html"
},
...
]
}
```
## HtmlWidget Proxy
To render HTML templates within the React-based canvas, we use a generic `HtmlWidget` component.
This component:
1. Accepts a `templateUrl` prop.
2. Fetches the HTML content from the given URL.
3. Renders the content using `dangerouslySetInnerHTML`.
4. Wraps the content in a container that preserves styles/layout.
## Runtime Registration
Widgets are registered dynamically using `widgetRegistry.register()`.
When a context (e.g., "Email") is loaded:
1. The `library.json` is fetched and parsed.
2. For each widget, a wrapper component is created using `HtmlWidget`.
3. The component is registered via `widgetRegistry.register()` with a unique ID (e.g., `email.hr`).
4. The `WidgetPalette` will automatically reflect these new widgets upon next render (open).

View File

@ -0,0 +1,48 @@
# Database & Architecture Todos
## Server-Side & Schema Tasks
### Schema Changes (Postgres/Supabase)
- [ ] **Split `profiles` Table**:
- [ ] Create `user_secrets` table (Columns: `user_id` (PK, FK), `openai_api_key`, `bria_api_key`, `replicate_api_key`, `settings`, `google_api_key`).
- [ ] Migrate data from `profiles` to `user_secrets` (Ref: [`src/integrations/supabase/types.ts`](../src/integrations/supabase/types.ts)).
- [ ] Drop secret columns from `profiles`.
- [ ] Rename `profiles` to `profiles_public` (optional, or just restrict access).
- [ ] **Create `page_collaborators` Table**:
- [ ] Columns: `page_id` (FK), `user_id` (FK), `role` (enum: 'viewer', 'editor', 'owner'), `created_at`.
- [ ] Add unique constraint on `(page_id, user_id)`.
- [ ] **RLS Policies Update**:
- [ ] `user_secrets`: Enable RLS. Policy: `auth.uid() = user_id`.
- [ ] `profiles`: Policy: Public read. Update strictly limited to owner.
- [ ] `pages`: Policy:
- Read: `is_public` OR `auth.uid() = owner` OR `auth.uid() IN (select user_id from page_collaborators)`.
- Update: `auth.uid() = owner` OR `auth.uid() IN (select user_id from page_collaborators where role IN ('editor', 'owner'))`.
### Server Logic (Node/Hono)
- [ ] **Implement `ServingProduct` Endpoints** (Ref: [`server/src/products/serving/index.ts`](../server/src/products/serving/index.ts)):
- [ ] `GET /api/feed`: Returns hydrated feed (Posts + Authors + Cover Images).
- [ ] `GET /api/profile/:id`: Returns public profile + recent posts.
- [ ] `GET /api/me/secrets`: (Secure) Returns user secrets for settings page.
- [ ] **Server-Side Injection**:
- [ ] Update `handleServeApp` in [`ServingProduct`](../server/src/products/serving/index.ts) to pre-fetch User & Feed.
- [ ] Inject into `index.html` as `window.__INITIAL_STATE__`.
---
## Client-Side Tasks
### `src/lib/db.ts` Refactor
- [ ] **Deprecate Direct Selects**: Identify all `supabase.from('posts').select(...)` calls in [`src/lib/db.ts`](../src/lib/db.ts).
- [ ] **Implement Proxy Clients**:
- [ ] Create `fetchFeedFromProxy()` calling `/api/feed` in [`src/lib/db.ts`](../src/lib/db.ts).
- [ ] Create `fetchProfileFromProxy(id)` calling `/api/profile/:id` in [`src/lib/db.ts`](../src/lib/db.ts).
- [ ] **Hydration Logic**:
- [ ] Check `window.__INITIAL_STATE__` on app boot to populate React Query cache before fetching.
### Component Updates
- [ ] **Post Page**:
- [ ] Use `fetchPostFromProxy` (or standard `db.fetchPostById` redirected to proxy) in [`src/pages/Post.tsx`](../src/pages/Post.tsx).
- [ ] Handle 404s gracefully (See Security.md for details).
- [ ] **PageManager**:
- [ ] Update [`src/components/PageManager.tsx`](../src/components/PageManager.tsx) to fetch "My Pages" AND "Shared Pages".

View File

@ -0,0 +1,169 @@
# Short Term DB Caching Proposal
## Objective
Reduce database load and improve response times for high-traffic, read-heavy routes by implementing a short-term caching layer using a **Generically Safe Decorator Pattern**.
## Proposed Solution
Implement a **Generic CachedHandler Utility** (`server/src/commons/decorators.ts`) that:
1. **Auto-Generates Keys**: Defaults to URL + Query.
2. **Auth Protection**: Skips caching for Authenticated requests by default.
3. **Size Protection**: Skips caching for responses larger than a threshold (e.g. 1MB).
4. **Memory Protection**: Enforces LRU/Limits in `MemoryCache`.
### 1. Functional Decorator
```typescript
import { Context } from 'hono';
import { getCache } from '../commons/cache/index.js';
type KeyGenerator = (c: Context) => string;
const defaultKeyInfo = (c: Context) => {
const url = new URL(c.req.url);
// Deterministic Sort: key=a&key=b vs key=b&key=a
// 1. Sort keys
url.searchParams.sort();
return `auto-cache:${c.req.method}:${url.pathname}${url.search}`;
};
export const CachedHandler = (
handler: (c: Context) => Promise<Response>,
options: {
ttl: number,
keyGenerator?: KeyGenerator,
skipAuth?: boolean, // Default true
maxSizeBytes?: number // Default: 1MB
}
) => async (c: Context) => {
// defaults
const ttl = options.ttl;
const skipAuth = options.skipAuth !== false;
const maxSizeBytes = options.maxSizeBytes || 1024 * 1024; // 1MB
const keyGen = options.keyGenerator || defaultKeyInfo;
// 1. Auth Bypass
if (skipAuth && c.req.header('Authorization')) {
return handler(c);
}
const cache = getCache();
const key = keyGen(c);
const bypass = c.req.query('cache') === 'false';
// 2. Hit
if (!bypass) {
const cached = await cache.get(key);
if (cached) {
c.header('X-Cache', 'HIT');
if (cached.contentType) c.header('Content-Type', cached.contentType);
return c.body(cached.data);
}
}
// 3. Miss
const response = await handler(c);
// 4. Save
if (response instanceof Response && response.ok) {
const cloned = response.clone();
try {
const contentType = response.headers.get('Content-Type') || 'application/json';
let data: any;
// Check content length if available
const contentLength = cloned.headers.get('Content-Length');
if (contentLength && parseInt(contentLength) > maxSizeBytes) {
// Too big, skip cache
return response;
}
if (contentType.includes('application/json')) {
const jsonObj = await cloned.json();
data = JSON.stringify(jsonObj);
} else {
data = await cloned.text();
}
// Double check actual size after reading
if (data.length > maxSizeBytes) {
// Too big, skip cache
return response;
}
await cache.set(key, { data, contentType }, ttl);
c.header('X-Cache', bypass ? 'BYPASS' : 'MISS');
} catch (e) {
console.error('Cache interception failed', e);
}
}
return response;
}
```
### 2. Usage Implementation
In `server/src/products/serving/index.ts`:
```typescript
// 5 minute cache, auto-key, skip if auth, max 500kb
this.routes.push({
definition: getApiUserPageRoute,
handler: CachedHandler(handleGetApiUserPage, { ttl: 300, maxSizeBytes: 500 * 1024 })
});
```
### 3. MemoryCache Protection (Limit)
Update `server/src/commons/cache/MemoryCache.ts`:
```typescript
// Add limit
const MAX_KEYS = 1000;
async set(key: string, value: any, ttlSeconds: number): Promise<void> {
this.prune();
if (this.cache.size >= MAX_KEYS) {
const first = this.cache.keys().next().value;
this.cache.delete(first);
}
// ... set logic
}
```
### 4. Summary of Protections
| Protection | Mechanism | Benefit |
| :--- | :--- | :--- |
| **Data Leak** | `skipAuth: true` | Prevents private data being cached/served to public. |
| **Stale Data** | `ttl` | Ensures updates propagate eventually. |
| **OOM (Large Item)** | `maxSizeBytes` | Prevents caching huge responses (e.g. giant JSONs). |
| **OOM (Many Items)** | `MAX_KEYS` | Prevents unlimited growth of the cache map. |
| **Performance** | `X-Cache` | Visibility into hit rates. |
### 5. Sequence Diagram (Final)
```mermaid
sequenceDiagram
participant Client
participant Dec as CachedHandler
participant Cache as MemoryCache
participant H as Handler
Client->>Dec: GET /api/data
Dec->>Dec: Check Auth Header?
opt Authenticated
Dec->>H: Invoke Handler Directly
H-->>Client: Returns Private Data
end
Dec->>Cache: get(key)
alt Hit
Cache-->>Client: Returns Data (HIT)
else Miss
Dec->>H: Invoke Handler
H-->>Dec: Returns Response
Dec->>Dec: Check Size < 1MB?
alt Small Enough
Dec->>Cache: set(key, data)
Dec-->>Client: Returns (MISS)
else Too Big
Dec-->>Client: Returns (MISS - No Cache)
end
end
```

9
packages/ui/docs/db.md Normal file
View File

@ -0,0 +1,9 @@
# [DEPRECATED] Database Consolidation Plan
> **Note**: This document has been split into more specific task lists. Please refer to:
> - [Database Todos & Schema](./database-todos.md)
> - [Security & Auth Plans](./security.md)
> - [Caching Strategy](./caching.md)
This file remains for historical context but may be out of date.

113
packages/ui/docs/editor.md Normal file
View File

@ -0,0 +1,113 @@
# MDXEditor Content Modification Deep Dive
This document outlines the core concepts behind modifying content in the `MDXEditor` and provides a recommended approach for building custom extensions, based on a deep dive into its source code.
## Key Findings: Reactive Architecture (Gurx)
The editor is built on a reactive architecture powered by a library called `@mdxeditor/gurx`. This is the most critical concept to understand.
- **Signals, not direct calls**: Instead of directly calling methods to change content (like `editor.bold()` or `editor.insertText('foo')`), the editor's UI components **publish** their intent to signals (also called "publishers" or "nodes").
- **State management**: The editor's state, including the current markdown content and selection, is managed within a reactive "realm." Various plugins subscribe to signals within this realm.
- **Decoupled logic**: When a signal is published, the corresponding plugin logic is executed. This decouples the UI (e.g., a toolbar button) from the actual implementation of the feature.
## How Toolbar Actions Work (e.g., Bold)
Let's trace the "bold" action, as it's a perfect example of this architecture in action:
1. **UI Component**: `BoldItalicUnderlineToggles.tsx` contains the UI button for toggling bold.
2. **Publishing an action**: When the bold button is clicked, it doesn't directly modify the editor. Instead, it calls `applyFormat('bold')`, which is a publisher for the `applyFormat$` signal.
3. **Core plugin subscription**: The core editor plugin subscribes to `applyFormat$`. When it receives the 'bold' signal, it executes the logic to apply or remove the bold format to the current text selection within the Lexical editor instance.
This approach is highly reliable because it leverages the editor's internal state management system. The UI simply declares what needs to happen, and the editor's core logic handles the update when it's ready.
## The Problem with `insertMarkdown` and Polling
Our initial approach to inserting markdown had issues because it was imperative and fought against the editor's reactive nature:
1. **`insertMarkdown$` is a signal**: The `insertMarkdown` method provided on the editor ref is, under the hood, a publisher for the `insertMarkdown$` signal. It's a "fire-and-forget" operation from the outside.
2. **Asynchronous execution**: When we call `editorRef.current.insertMarkdown('some text')`, we're publishing to a signal. The actual insertion happens asynchronously within the editor's update cycle.
3. **Why polling is bad**: Attempting to immediately read the new markdown with `getMarkdown()` fails because the update hasn't been processed yet. Our polling with `requestAnimationFrame` and `setTimeout` was a fragile attempt to guess when the update would complete.
## The Correct Approach: Using the Signal Architecture
To build reliable extensions or custom functionality, we must follow the editor's architectural pattern:
1. **Create a custom signal**: If you need to perform a custom action, the best approach is to define a new signal within a custom plugin.
2. **Publish from your component**: Your React component (e.g., a custom button or a side panel) should get a publisher for your signal and publish to it when the user takes an action.
3. **Subscribe within a plugin**: The plugin that defines the signal should also contain the logic that subscribes to it. This logic will receive the signal and can then safely interact with the editor state.
By following this pattern, your custom logic will be integrated into the editor's reactive flow, ensuring that state updates are handled correctly and reliably, without the need for hacks like `setTimeout` or `focus()` workarounds.
## Sequence Diagram: Content Modification Flow
Here is a sequence diagram illustrating the flow for a typical toolbar action, like applying bold formatting, in `MarkdownEditorEx`.
```mermaid
sequenceDiagram
participant User
participant ToolbarUI as "React Component (e.g., BoldItalicUnderlineToggles)"
participant Gurx as "Gurx Reactive Realm"
participant CorePlugin as "MDXEditor Core Plugin"
participant Lexical as "Lexical Editor Instance"
User->>ToolbarUI: Clicks 'Bold' button
ToolbarUI->>Gurx: Publishes to 'applyFormat$' signal with 'bold' payload
Gurx-->>CorePlugin: Notifies subscriber of 'applyFormat$' signal
CorePlugin->>Lexical: Dispatches 'FORMAT_TEXT_COMMAND' with 'bold'
Lexical->>Lexical: Updates editor state (applies format)
Lexical-->>Gurx: Broadcasts updated state (e.g., 'currentFormat$')
Gurx-->>ToolbarUI: Notifies component of state change
ToolbarUI->>User: Re-renders with 'Bold' button in active state
```
## Knowing When an Action is "Done"
The editor's architecture is based on signals. Actions like applying formats or inserting markdown are published to the reactive system, which then processes them asynchronously. There is no `Promise` or callback returned from methods like `insertMarkdown` to let you know when the operation is complete.
So, how do we know?
1. **The `onChange` Prop is the Key**: The primary mechanism for an external component to be notified of state changes is the `onChange` prop. When the editor's internal Lexical state is updated and converted back to markdown, `onChange` is triggered with the new content. This is our confirmation that the editor has processed an update.
2. **The Challenge**: The problem is linking a specific action (e.g., our call to `insertMarkdown`) to a subsequent `onChange` event. A simple `useEffect` on the content is not enough, as `onChange` can fire for many reasons (user typing, other plugins, etc.).
3. **The Solution: A Transactional Approach**: We can create a temporary "transaction" to bridge the gap between our action and the resulting state change. The flow looks like this:
1. **Before Action**: Before calling `insertMarkdown`, we store the text we're about to insert and a success callback (e.g., to show a toast) in a `useRef`.
2. **Fire Action**: We call `editorRef.current.insertMarkdown(...)`.
3. **Wait for Confirmation**: The `onChange` handler (`handleContentChange`) is now responsible for checking if the new content contains the text from our transaction.
4. **On Confirmation**: If the text is found, we know our specific insertion was successful. We can then execute the success callback from the ref and clear the transaction.
5. **Safety Net**: A `setTimeout` can be used as a fallback. If `onChange` doesn't fire within a reasonable time, we can assume the update failed or timed out and notify the user accordingly.
This pattern respects the editor's asynchronous and reactive nature while giving us the reliable completion confirmation we need for a smooth user experience.
## Modifying Content Without Stealing Focus
A common requirement is to insert or modify editor content programmatically (e.g., from an AI suggestion) without pulling the user's focus away from their current task (e.g., typing in a prompt input).
- **`insertMarkdown()` Steals Focus**: The `editorRef.current.insertMarkdown()` method is designed for direct user actions. It internally manages focus and selection to place content where the cursor is. As a result, it will almost always steal focus.
- **`setMarkdown()` Does NOT Steal Focus**: The `editorRef.current.setMarkdown()` method is the correct tool for this job. It is designed for programmatic updates and works like setting a prop on a controlled component. It replaces the entire editor content with the new markdown string you provide, without affecting the user's focus.
### Recommended Usage
- **For "Append" or "Replace All"**: `setMarkdown` is perfect. You can get the current content via `getMarkdown()`, construct the new content string (`newContent = oldContent + appendedText`), and then call `setMarkdown(newContent)`. The user's focus remains untouched.
- **For "Insert at Cursor"**: This is trickier. Since `setMarkdown` replaces everything and doesn't know about the cursor, you cannot use it to insert at the current selection. For this specific use case, stealing focus via `insertMarkdown` is often the intended and most intuitive behavior from a user's perspective.
## Summary: Implementing Merge Operations
To robustly implement `append`, `insert`, and `replace`, you need the following from your `MarkdownEditorEx` component:
1. **`editorRef`**: Essential for calling `getMarkdown()`, `setMarkdown()`, and `insertMarkdown()`.
2. **`onSelectionChange` callback**: Required for the **replace** operation to know what text is currently selected.
3. **`onChange` callback**: The key to confirming that any operation has completed successfully, using the "transactional" pattern described above.
### Cheatsheet:
| Operation | Goal | Recommended Method | Steals Focus? | Key Dependency |
| :-------- | :---------------------------------------- | :-------------------- | :------------ | :--------------------- |
| **Append** | Add content to the end of the document | `setMarkdown()` | No | `getMarkdown()` |
| **Insert** | Add content at the user's cursor | `insertMarkdown()` | Yes | User's cursor position |
| **Replace** | Swap selected text with new content | `setMarkdown()` | No | `onSelectionChange` |

100
packages/ui/docs/embed.md Normal file
View File

@ -0,0 +1,100 @@
# Embed Implementation Plan
## Goal
Enable third-party sites to embed posts from our platform using an iframe. This requires a lightweight, dedicated build of the application that renders a single post with minimal distractions (no navigation, no sidebar, etc.).
## Architecture
### 1. Dedicated Output Build
We will create a separate Vite build for the embed view to ensure the bundle size is minimal and isolated from the main application's complexity.
- **Config**: `vite.config.embed.ts`
- **Entry**: `src/main-embed.tsx`
- **Output**: `dist/client/embed/`
- **Feature Flags**: Use Vite `define` to set `__IS_EMBED__` constant to `true` at build time. This allows dead-code elimination (tree-shaking) of unused components in `CompactRenderer`.
### 2. Client-Side Entry Point (`src/main-embed.tsx`)
A simplified entry point that:
- Does **not** include the full `App` router.
- Reads initial state from `window.__INITIAL_STATE__`.
- Renders `EmbedApp`.
### 3. Component Strategy (`EmbedRenderer.tsx`)
Create `src/pages/Post/renderers/EmbedRenderer.tsx`. This component will be:
- **Lightweight**: Minimal imports, no heavy third-party libs (unless critical).
- **Read-Only**: No edit controls, no comments, no wizard.
- **Navigation**:
- Image click → Opens `window.open(postUrl, '_blank')`.
- Title/Author click → Opens `window.open(profileUrl, '_blank')`.
- **Layout**:
- Responsive Media (Image/Video).
- Filmstrip (for galleries).
- Simple footer (Like count, Share button).
### 4. Server-Side Serving (`server/src/products/serving/index.ts`)
A new route `GET /embed/:postId` will be added to the serving product.
- **Logic**:
1. Fetch the post/media.
2. Inject into `window.__INITIAL_STATE__`.
3. Serve `embed.html` (which points to `main-embed.tsx`).
4. Set headers to allow embedding.
### 5. UI Trigger (`ArticleRenderer.tsx`)
Add an "Embed" action button next to "Export to Markdown".
- **Action**: Opens a modal with the iframe code snippet.
## Implementation Steps
### 1. Build Configuration
Create `vite.config.embed.ts`:
```typescript
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react-swc';
import path from 'path';
export default defineConfig({
plugins: [react()],
build: {
outDir: 'dist/embed',
rollupOptions: {
input: 'embed.html', // We might need a dedicated HTML or use index.html with a different entry
},
},
resolve: {
alias: {
"@": path.resolve(__dirname, "./src"),
},
},
});
```
### 2. Embed Entry Point
Create `src/embed.html` (copy of index.html pointing to `src/main-embed.tsx`).
Create `src/main-embed.tsx`.
Create `src/EmbedApp.tsx`.
### 3. Server Route
Update `server/src/products/serving/index.ts`:
```typescript
// Add to routes
this.routes.push({ definition: { method: 'get', path: '/embed/:id' }, handler: this.handleGetEmbed.bind(this) });
// Handler
async handleGetEmbed(c: Context) {
const id = c.req.param('id');
// ... fetch post ...
// ... load embed/index.html ...
// ... inject data ...
return c.html(injected);
}
```
### 4. UI Component
Modify `src/pages/Post/renderers/ArticleRenderer.tsx` to add the Embed button.
## Considerations
- **Styling**: Ensure global styles (`index.css`) are included but verify they don't assume a full page layout that breaks inside a small iframe.
- **Analytics**: Embed views might need distinct tracking.
- **Links**: All links inside the embed should open in a new tab (`target="_blank"`) to avoid navigating the iframe itself.

79
packages/ui/docs/feed.md Normal file
View File

@ -0,0 +1,79 @@
# Instagram-like Feed Implementation Plan
## Objective
Create a responsive, immersive feed experience that adapts to device size:
- **Desktop/Large Screens**: Retain the current `PhotoGrid` (grid view).
- **Mobile**: Implement a new `Feed` view (vertical list) similar to Instagram.
- **Carousel**: Support horizontal swiping (left/right) through multiple pictures within a single post.
- **Performance**: Implement "load ahead" strategy (buffer ~5 posts) to ensure smooth scrolling without loading the entire database.
## Architecture & Components
### 1. Data Layer Enhancements
Current `PhotoGrid` logic fetches posts and selects a single "cover" image.
We need to modify the data transformation to pass *all* visible pictures to the UI components.
- **Query**: Keep fetching `posts` with `pictures`.
- **Transformation**: Instead of flattening to a single `MediaItem`, we need a structure that preserves the list of pictures for each post.
```typescript
interface FeedPost {
id: string; // Post ID
user_id: string; // Author
pictures: MediaItemType[]; // Array of pictures in the post
// ... other post metadata (title, description, etc.)
}
```
### 2. New `Feed` Component (Mobile)
A new component `src/components/Feed.tsx` will be created for the mobile view.
- **Layout**: Vertical list of full-width cards.
- **Virtualization**: Use `react-window` or simpler intersection observer-based rendering to only render posts in (and slightly outside) the viewport.
- **Preloading**: Ensure the next 5 image/video assets are preloaded.
### 3. Updated `MediaCard` / New `FeedCard`
`MediaCard` currently handles a single media item. We have two options:
1. **Refactor `MediaCard`**: Add support for an array of media and internal carousel logic.
2. **Create `FeedCard`**: A specialized card for the Feed view that wraps `MediaCard` or implements its own carousel.
* *Decision*: Use `FeedCard` (or `PostCard`) to encapsulate the carousel logic (Embla Carousel or similar) and use `MediaCard` for individual slides if needed, or implement a lighter slide view.
* **Carousel**: Must support touch gestures for left/right swiping.
### 4. `PhotoGrid` Updates
- **Logic Separation**: Extract the data fetching hook (e.g., `useFeedMedia`) so both `PhotoGrid` and `Feed` can share the same data source and state (likes, etc.).
- **Responsive Switch**: In `Index.tsx`, conditionally render `PhotoGrid` (desktop) or `Feed` (mobile). Or render both and hide via CSS (better for SSR/hydration matching, but heavier on DOM). Better to use a valid hook for `isMobile`.
## Implementation Steps
### Phase 1: Data & Hooks
1. Create `useFeedQuery` hook to fetch posts + pictures.
2. Implement pagination (infinite scroll) logic (load 10, load next 10 when bottom reached).
3. Preloading utility: Function to preload images `n` indexes ahead of the current viewport item.
### Phase 2: Carousel Component
1. Implement a Swipe/Carousel component (using `embla-carousel-react` or purely custom CSS scroll-snap).
2. Ensure it handles image aspect ratios gracefully (Instagram usually restricts to 4:5 or square, but we might support flexible).
### Phase 3: `MobileFeed` Component
1. Create the vertical list layout.
2. Implement the "Load 5 ahead" logic (prefetching images for the next 5 cards).
3. Integrate the Carousel for multi-image posts.
### Phase 4: Integration
1. Update `Index.tsx` to switch between `PhotoGrid` and `MobileFeed`.
2. Ensure shared state (Likes, Comments) works in both views.
## Technical Details
### "Load 5 Ahead" Strategy
- **Intersection Observer**: Watch the last rendered element to trigger fetching the next page.
- **Image Preloading**: Watch the *currently visible* post index. Automatically create `Link rel="preload"` or `new Image()` for the `cover` images of the next 5 posts.
- **Carousel Preloading**: If a user stops on a post, prioritize loading the *next* slide of that specific post.
### Swiping Interaction
- **Carousel (Inner)**: Swiping horizontally moves between pictures of the *same* post.
- **Feed (Outer)**: Scrolling vertically moves between *different* posts.
## Proposed File Structure
- `src/components/feed/Feed.tsx`
- `src/components/feed/FeedCard.tsx` (Handles the carousel)
- `src/components/feed/FeedCarousel.tsx` (The actual swiper)
- `src/hooks/useFeed.ts` (Data fetching logic)

View File

@ -0,0 +1,44 @@
# Layouts
## 2 Columns : PhotoCard & Text
```json
{
"id": "playground-canvas-demo",
"name": "Playground Canvas",
"containers": [
{
"id": "container-1769374479197-drxdzewil",
"type": "container",
"columns": 2,
"gap": 16,
"widgets": [
{
"id": "widget-1769374641802-b2cj664rs",
"widgetId": "photo-card",
"props": {
"pictureId": "41185796-3600-4e0b-988f-e1ac59592492"
},
"order": 0
},
{
"id": "widget-1769374667509-kxctp5bix",
"widgetId": "markdown-text",
"props": {
"content": "sdfdf",
"placeholder": "Enter your text here...",
"templates": []
},
"order": 1
}
],
"children": [],
"order": 0
}
],
"createdAt": 1769374479197,
"updatedAt": 1769374676394
}
```

79
packages/ui/docs/m3u8.md Normal file
View File

@ -0,0 +1,79 @@
# HLS (m3u8) Implementation Plan
## Objective
Transition internal video processing and serving from single MP4 files to HLS (HTTP Live Streaming) to improve playback performance, seekability, and support adaptive bitrate streaming.
## Technical Changes
### 1. Video Processing (Worker)
**File**: `server/src/products/videos/worker.ts`
**Current**: Generates single `jobId.mp4`.
**New**: Generate HLS playlist and segments.
**FFmpeg Command**:
```bash
ffmpeg -i input.mp4 \
-b:v 1M \
-g 60 \
-hls_time 2 \
-hls_list_size 0 \
-hls_segment_size 500000 \
output.m3u8
```
**Storage Structure**:
Instead of flat files in `videos/`, use a directory per job:
```
videos/
├── [jobId]/
│ ├── playlist.m3u8
│ ├── output0.ts
│ ├── output1.ts
│ └── ...
```
### 2. API Endpoints (Product)
**File**: `server/src/products/videos/routes.ts` & `index.ts`
Create new endpoints to serve HLS assets. We need to serve both the playlist (`.m3u8`) and the segment files (`.ts`).
#### Endpoints:
1. **Playlist**: `GET /api/videos/jobs/:id/hls/playlist.m3u8`
* **Handler**: Looks up directory `videos/:id/`, serves `playlist.m3u8`.
* **Content-Type**: `application/vnd.apple.mpegurl` or `application/x-mpegURL`.
2. **Segments**: `GET /api/videos/jobs/:id/hls/:segment`
* **Handler**: Looks up directory `videos/:id/`, serves requested `.ts` file.
* **Content-Type**: `video/MP2T`.
**Note**: The playlist generated by ffmpeg usually contains relative paths (e.g., `output0.ts`). If the browser loads the playlist from `.../hls/playlist.m3u8`, it will resolve segments relative to that base, `.../hls/output0.ts`, which fits the proposed endpoint structure perfectly.
### 3. Frontend (Playground & Components)
**File**: `src/pages/VideoPlayerPlaygroundIntern.tsx`
* Update `pollJob` or `handleUpload` completion logic.
* Instead of setting `videoUrl` to the MP4 download link, set it to the HLS playlist endpoint:
* `http://localhost:3333/api/videos/jobs/[jobId]/hls/playlist.m3u8`
* Pass type `application/x-mpegurl` (or `application/vnd.apple.mpegurl`) to `VideoCard`.
**File**: `src/components/VideoCard.tsx`
* Ensure `Vidstack` / `MediaPlayer` handles the HLS mime type correctly. Vidstack has built-in HLS support (often requires `hls.js` but it's usually bundled or easily added).
* Verify `src` prop handling for HLS.
### 4. Database / Metadata
* Update `resultUrl` in the job completion data (in `pg-boss` or returned to frontend) to point to the `.m3u8` file.
* Supabase `pictures` table: Update `image_url` to store the HLS playlist URL instead of the MP4 download URL.
## Migration Strategy
1. **Implement new Worker logic**: Create a new job name or update existing `video-processing` to output HLS.
2. **Implement new Endpoints**: Add HLS routes to `VideosProduct`.
3. **Update Frontend**: Point playground to new endpoints.
4. **Verify**: Test playback.
5. **Cleanup**: Remove MP4 download endpoint and old worker logic once verified.
## Outstanding Questions
* **Storage Cleanup**: Deleting a video now means deleting a directory. Ensure `handleDelete` is updated.
* **Legacy Content**: Old MP4s will stop working if we aggressively remove the old endpoint.
* *Decision*: Keep the old `download` endpoint for backward compatibility if needed, or migration script to convert old videos (out of scope for now).

View File

@ -0,0 +1,64 @@
# Markdown HTML Widget & Property Type
This document outlines the implementation of the `markdown` property type for widgets in the Polymech Pictures system. This feature allows widget developers to define properties that accept Markdown input, which is then rendered as HTML in the canvas and exported as HTML for emails.
## 1. Configuration Schema
To use the Markdown property type, update your widget's `library.json` definition. Use `"type": "markdown"` in the `configSchema`.
```json
{
"name": "Text",
"template": "./text.html",
"configSchema": {
"content": {
"type": "markdown",
"label": "Content",
"default": "# Hello World\nThis is **markdown**.",
"description": "The main text content."
}
}
}
```
## 2. Widget Properties UI
The `WidgetPropertiesForm` component has been updated to handle the `markdown` type:
* **Input**: Renders a `Textarea` for quick edits.
* **Fullscreen Editor**: Includes a "Fullscreen" button that opens a modal with a rich Markdown editor (`MarkdownEditorEx`).
* **Functionality**:
* Supports splitting view (Editor/Preview).
* Integrates with `ImagePickerDialog` for inserting images.
## 3. Rendering Pipeline
The rendering of Markdown content occurs in two places:
### A. Canvas Rendering (`HtmlWidget.tsx`)
* **Dependency**: Uses `marked` library for parsing.
* **Logic**:
* The `HtmlWidget` checks the widget definition (looked up via `widgetDefId`) for any properties defined as `type: "markdown"`.
* If found, it asynchronously parses the markdown string into HTML using `marked.parse()`.
* The resulting HTML is injected into the template.
* **Inline Editing**: Unlike standard text properties, Markdown properties are **excluded** from the inline `contenteditable` wrappers. This prevents users from accidentally breaking the HTML structure by editing raw HTML output directly on the canvas.
### B. Email Export (`emailExporter.ts`)
* **Logic**:
* During the export process (`generateEmailHtml`), the system iterates through widget properties.
* It checks the `configSchema` for `markdown` types.
* It converts the Markdown content to HTML *before* performing variable substitution in the template.
* This ensures that the final email HTML contains properly formatted tags (e.g., `<h1>`, `<ul>`, `<strong>`) instead of raw markdown characters.
## 4. Technical Implementation Details
* **Dependencies**: Added `marked` to `package.json`.
* **Layout Context**: `LayoutContainer` passes `widgetDefId` to widget components to ensure correct schema lookup in the registry.
* **Registry**: The `WidgetRegistry` is the source of truth for property types, enabling the renderer to know *which* props to compile.
## 5. Usage Notes
* **Preview Mode**: The behavior in the canvas now matches the "Preview" mode more closely for markdown fields.
* **Sanitization**: Standard `marked` parsing is used. Ensure trusted input if extending to user-generated content in the future.

View File

@ -0,0 +1,306 @@
# Mux Video Integration
This project integrates [Mux](https://www.mux.com) for professional video upload, processing, and streaming capabilities.
## Overview
Mux provides:
- **Video Upload**: Drag & drop or click to upload video files
- **Automatic Processing**: Videos are automatically transcoded and optimized
- **HLS Streaming**: Adaptive bitrate streaming for smooth playback
- **Thumbnail Generation**: Automatic thumbnails and poster images
- **Analytics**: Track video views and engagement (optional)
## Architecture
### Flow
1. **Client requests upload URL** → Frontend calls our Supabase Edge Function
2. **Edge Function creates upload** → Calls Mux API to generate signed upload URL
3. **User uploads video** → Mux Uploader handles the upload with progress tracking
4. **Mux processes video** → Transcodes video, creates HLS stream, generates thumbnails
5. **Get playback ID** → Poll for asset creation, retrieve playback ID
6. **Play video** → Use Vidstack player with Mux HLS stream URL
### Components
- **MuxUploader**: React component for uploading videos (`@mux/mux-uploader-react`)
- **VideoCard**: Component for displaying videos with Vidstack player
- **mux-proxy**: Supabase Edge Function that interfaces with Mux API
## Setup
### 1. Get Mux Credentials
1. Sign up at [mux.com](https://www.mux.com)
2. Navigate to **Settings** → **Access Tokens**
3. Create a new access token with permissions:
- `Mux Video` - Read and Write
4. Copy the **Token ID** and **Token Secret**
### 2. Configure Environment Variables
Add these to your Supabase Edge Function environment variables:
```bash
MUX_TOKEN_ID=your_token_id_here
MUX_TOKEN_SECRET=your_token_secret_here
```
To set them in Supabase:
```bash
# Using Supabase CLI
supabase secrets set MUX_TOKEN_ID=your_token_id
supabase secrets set MUX_TOKEN_SECRET=your_token_secret
# Or via Supabase Dashboard
# Project Settings → Edge Functions → Secrets
```
### 3. Deploy Edge Function
```bash
supabase functions deploy mux-proxy
```
## Usage
### Upload Video
```tsx
import MuxUploader from "@mux/mux-uploader-react";
import { supabase } from "@/integrations/supabase/client";
const fetchUploadUrl = async () => {
const response = await fetch(
`${supabase.supabaseUrl}/functions/v1/mux-proxy`,
{
method: 'POST',
headers: {
'Authorization': `Bearer ${session.access_token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ action: 'create-upload' }),
}
);
const { data } = await response.json();
return data.url;
};
function VideoUpload() {
return (
<MuxUploader
endpoint={fetchUploadUrl}
onSuccess={(event) => {
console.log('Upload complete!', event.detail);
}}
/>
);
}
```
### Play Video
Once you have the playback ID from Mux, you can play the video:
```tsx
import VideoCard from "@/components/VideoCard";
function VideoPlayer({ playbackId }: { playbackId: string }) {
const videoUrl = `https://stream.mux.com/${playbackId}.m3u8`;
const thumbnailUrl = `https://image.mux.com/${playbackId}/thumbnail.jpg`;
return (
<VideoCard
videoId="123"
videoUrl={videoUrl}
thumbnailUrl={thumbnailUrl}
title="My Video"
author="User"
authorId="user-id"
likes={0}
comments={0}
/>
);
}
```
## Mux API Actions
### create-upload
Creates a new direct upload URL.
**Request:**
```json
{
"action": "create-upload"
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": "upload_abc123",
"url": "https://storage.googleapis.com/...",
"status": "waiting"
}
}
```
### get-upload
Get the status of an upload and check if asset was created.
**Request:**
```json
{
"action": "get-upload",
"uploadId": "upload_abc123"
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": "upload_abc123",
"status": "asset_created",
"asset_id": "asset_xyz789"
}
}
```
### get-asset
Get asset details including playback IDs.
**Request:**
```json
{
"action": "get-asset",
"assetId": "asset_xyz789"
}
```
**Response:**
```json
{
"success": true,
"data": {
"id": "asset_xyz789",
"status": "ready",
"playback_ids": [
{
"id": "playback_def456",
"policy": "public"
}
]
}
}
```
## Database Schema
Store Mux video data in your `videos` table:
```sql
CREATE TABLE videos (
id UUID PRIMARY KEY DEFAULT uuid_generate_v4(),
user_id UUID NOT NULL,
title TEXT NOT NULL,
description TEXT,
video_url TEXT NOT NULL, -- https://stream.mux.com/{playback_id}.m3u8
thumbnail_url TEXT, -- https://image.mux.com/{playback_id}/thumbnail.jpg
meta JSONB, -- { mux_asset_id, mux_playback_id }
created_at TIMESTAMP DEFAULT NOW()
);
```
Store in meta:
- `mux_asset_id`: For managing the asset via Mux API
- `mux_playback_id`: For generating stream/thumbnail URLs
## Mux URLs
### Stream URL (HLS)
```
https://stream.mux.com/{PLAYBACK_ID}.m3u8
```
This is an HLS stream that works with Vidstack, Mux Player, and most video players.
### Thumbnail URL
```
https://image.mux.com/{PLAYBACK_ID}/thumbnail.jpg
```
Query parameters:
- `?width=1280` - Set width
- `?height=720` - Set height
- `?time=10` - Thumbnail at 10 seconds
### MP4 URL (if enabled)
```
https://stream.mux.com/{PLAYBACK_ID}/high.mp4
```
Available qualities: `low.mp4`, `medium.mp4`, `high.mp4`
## Webhooks (Optional)
For production, set up Mux webhooks to get notified when:
- Upload completes (`video.upload.asset_created`)
- Video is ready (`video.asset.ready`)
- Errors occur (`video.asset.errored`)
This is more efficient than polling. See [Mux Webhooks Docs](https://docs.mux.com/guides/listen-for-webhooks).
## Playground
Test the integration at `/playground/video-player`:
- **Upload tab**: Upload videos using Mux
- **Test with URL tab**: Test Vidstack player with any video URL
## Pricing
Mux charges based on:
- **Encoding**: Minutes of video processed
- **Streaming**: Minutes of video delivered
- **Storage**: GB-months of video stored
See [Mux Pricing](https://www.mux.com/pricing) for current rates.
Free tier includes:
- $20/month in free credits
- Enough for ~40 minutes of encoding + 100 hours of streaming
## Troubleshooting
### Upload fails immediately
- Check that MUX_TOKEN_ID and MUX_TOKEN_SECRET are set in Supabase
- Verify the edge function is deployed
- Check browser console for CORS errors
### Video stuck in "processing"
- Large videos can take several minutes to process
- Check Mux dashboard for asset status
- Verify the upload completed successfully
### Video won't play
- Check that playback policy is set to "public"
- Verify the HLS URL format is correct
- Check browser console for player errors
## Resources
- [Mux Documentation](https://docs.mux.com)
- [Mux Uploader Docs](https://www.mux.com/docs/guides/mux-uploader)
- [Vidstack Player Docs](https://vidstack.io)
- [Mux Dashboard](https://dashboard.mux.com)

View File

@ -0,0 +1,53 @@
# Master Implementation Plan
This document serves as the central roadmap, referencing tasks from:
- [`database-todos.md`](./database-todos.md) (DB)
- [`security.md`](./security.md) (SEC)
- [`caching.md`](./caching.md) (CACHE)
## Phase 1: Foundation (Schema & Data Security)
*Goal: Secure the data layer and enable collaboration primitives.*
- [ ] **[DB] Split `profiles` into `profiles_public` & `user_secrets`**
- [ ] Create table & Migrate data (Ref: [`src/integrations/supabase/types.ts`](../src/integrations/supabase/types.ts)).
- [ ] **[SEC]** Apply RLS to `user_secrets` (`user_id = auth.uid()`).
- [ ] **[DB] Create `page_collaborators` Table**
- [ ] Define columns & Unique Constraints.
- [ ] **[SEC]** Implement RLS for shared Page access (Viewer/Editor logic).
## Phase 2: Server Core & API
*Goal: Build the "Smart Proxy" layer to handle data fetching and caching.*
- [ ] **[CACHE] Implement `CacheAdapter`**
- [ ] Create Interface (Target: `server/src/commons/cache/types.ts`).
- [ ] Implement `MemoryCache` (default) & `RedisCache` (optional).
- [ ] **[DB] Implement Server Endpoints in [`ServingProduct`](../server/src/products/serving/index.ts)**
- [ ] `GET /api/feed` (Hydrated View-Ready Feed).
- [ ] `GET /api/profile/:id` (Public Profile).
- [ ] `GET /api/me/secrets` (Secure Settings access).
- [ ] **[CACHE] Apply Caching to Endpoints**
- [ ] Cache Feed (60s) & Profiles (5m).
## Phase 3: Client Security & Refactor
*Goal: Stop leaking keys and move to the Proxy.*
- [ ] **[SEC] Critical: Remove Client-Side Key Fetching**
- [ ] Scrub `profiles` selects in [`Profile.tsx`](../src/pages/Profile.tsx) and [`db.ts`](../src/lib/db.ts).
- [ ] Remove API Key inputs from Profile UI in [`Profile.tsx`](../src/pages/Profile.tsx).
- [ ] **[DB] Client Data Layer Refactor**
- [ ] Update [`db.ts`](../src/lib/db.ts) to use `fetchFeedFromProxy` / `fetchProfileFromProxy`.
- [ ] Deprecate direct Supabase `select` calls for core content.
- [ ] **[SEC] Hardening**
- [ ] **[SEC]** Handle 404s/403s in [`Post.tsx`](../src/pages/Post.tsx) correctly.
## Phase 4: Performance & Optimization
*Goal: Instant loads and "feels native" speed.*
- [ ] **[DB] Server-Side Injection (SSR-Lite)**
- [ ] Inject `window.__INITIAL_STATE__` into `index.html` via [`ServingProduct`](../server/src/products/serving/index.ts).
- [ ] **[CACHE] Client Hydration**
- [ ] Configure React Query to hydrate from `__INITIAL_STATE__`.
- [ ] Set global `staleTime` to 5m.
- [ ] **[SEC] Rate Limiting**
- [ ] Add limits to API endpoints.

View File

@ -0,0 +1,142 @@
# Polymech Media App
>
> *The next-generation platform for AI-powered content creation, organization, and immersive consumption.*
Our application bridges the gap between professional content management and generative AI creativity. Built for speed, flexibility, and visual impact.
## 🎨 AI Image Wizard
The heart of our creative suite. The **Image Wizard** isn't just an uploader—it's an intelligent studio.
- **Generative AI & Editing**: Create stunning visuals from text prompts or edit existing images using advanced AI models.
- **Voice-to-Prompt**: Speak your ideas. Our integrated voice recorder transcribes and optimizes your speech into high-quality image prompts.
- **Automated Workflows**: Run complex multi-step actions like "Optimize & Enhance" or "Style Transfer" with a single click.
- **Smart Presets**: Save and reuse your best prompt engineerings as templates.
![AI Image Wizard Interface](placeholder_wizard_interface.png)
## 🚀 Seamless Ingestion
Getting content into your library has never been easier.
- **Global Drag & Drop**: Drag files anywhere on the screen to instantly start a new post or upload to your gallery.
- **"Share to" Support**: deeply integrated with mobile OS share sheets. Send images, videos, or text from other apps directly to our **New Post** flow.
- **Universal Media Support**: Native handling for images and videos, including special optimization for short-form content like **TikTok** and **YouTube Shorts**.
## 📝 Rich Content Editor
Go beyond simple captions with our **Markdown Editor Ex**.
- **Hybrid Editing**: Switch instantly between a visual WYSIWYG editor and raw Markdown for precision control.
- **Integrated Asset Picker**: Insert images from your library directly into your articles without leaving the flow.
- **Fullscreen Focus Mode**: Write without distractions.
### 🧠 AI Text Generator
Enhance your writing with our context-aware AI assistant.
- **Context Aware**: Use "Selected Text" or "Entire Document" as context for generation, or just raw prompts.
- **Smart Application**: Choose how to apply the result — **Replace** selection, **Insert** at cursor, or **Append** to the end.
- **Web & Visuals**: Toggle **Web Search** for up-to-date info or **Image Tools** to have the AI generate and embed images directly in your text.
- **Voice Input**: Dictate your prompts for hands-free operation.
![Markdown Editor](placeholder_editor.png)
## 📤 Sharing & Export
Your content isn't locked in. Share it anywhere, in any format.
- **Universal Export**:
- **PDF**: Generate print-ready documents.
- **Markdown**: Export raw source for use in other editors.
- **ZIP Archive**: Download a complete offline package including all media assets.
- **Email HTML**: Get a pre-rendered HTML template ready for your email marketing tools.
- **Embeds**: Copy a simple iframe code snippet to embed your posts on any external website.
- **Social Sharing**: Native OS sharing integration for quick social posting.
## 📱 Immersive Consumption
Experience content exactly how it was meant to be seen.
- **Compact Renderer**: A responsive, high-density view that adapts to your device.
- **Desktop**: Side-by-side media and content view for efficient browsing.
- **Mobile**: Vertical scrolling "Grouped Feed" optimized for touch.
- **Filmstrip Navigation**: Quickly browse through multi-image galleries.
- **Adaptive Video Player**: Smart playback for both landscape and vertical (9:16) video formats.
## 📂 Organization & Management
- **Page Manager**: Create, organize, and publish rich pages. Control visibility (Public/Private) with ease.
- **Multi-Tenancy**: Built-in support for Organizations, allowing separated workspaces and shared resources.
- **Collections & Tags**: Powerful categorization variables to keep your library structured.
## 🎯 Use Cases
### 🏢 Internal Social Platform
Perfect for companies needing a secure, private space for culture and communication.
- **Share Updates**: Post announcements, welcome new hires, or share event photos.
- **Privacy First**: Unlike public social media, your data stays within your organization.
- **Rich Media**: Share training videos, PDF policy documents, and branded assets in one place.
### 🏪 Small Business Media Library
A dedicated Digital Asset Management (DAM) solution without the enterprise price tag.
- **Centralized Assets**: Keep product photos, marketing materials, and brand guides organized.
- **Easy Retrieval**: Find assets quickly with tags and collections compared to messy folder structures.
- **Quick Sharing**: Generate ZIPs or public links for press kits and partners instantly.
### 🎨 Product Design Iterations
Accelerate visual development workflows.
- **AI Prototyping**: Use the Image Wizard to rapidly generate concepts and variations.
- **Version History**: Keep track of design evolution over time.
- **Contextual Feedback**: Annotate designs with rich text descriptions for clear communication.
## 🆚 Comparison
| Feature | Polymech Media App | Google Photos | Instagram | WordPress |
| :--- | :--- | :--- | :--- | :--- |
| **Primary Focus** | **Content Creation & Org** | Storage & Backup | Social Networking | Blogging / CMS |
| **Generative AI** | **Native & Deeply Integrated** | Basic Magic Editor | Filters / Basic AI | Plugin Required |
| **Context** | **Rich Text / Markdown** | Minimal Captions | Short Captions | Rich Text |
| **Privacy** | **Private / Org-based** | Private / Shared Albums | Public / Social | Public / Private |
| **Media Types** | **Mixed (Video, Img, Doc)** | Photo / Video | Photo / Video | Any (Plugin dep.) |
| **Workflow** | **Iterative & Pro** | Consumption focused | Engagement focused | Publishing focused |
## 🔮 Future Workflows (Roadmap)
We are constantly expanding our capabilities. Here is what is on the horizon:
- [ ] **Collaborative Editing**: Real-time multiplayer editing for Markdown and canvas.
- [ ] **Chrome Extension**: One-click clipping of images, full pages, or selected text from any website directly to your library.
- [ ] **OS Explorer Integration**: Mount your library as a native virtual drive (Windows/FUSE). Drag-and-drop support directly from your desktop.
- [ ] **Figma Integration**: Direct import/sync of frames for seamless design reviews.
- [ ] **Slack/Discord Bot**: Push notifications and "Publish from Chat" workflows.
- [ ] **Mobile Native App**: Dedicated iOS and Android experiences with system-level sharing and camera integration.
- [ ] **Advanced Analytics**: Engagement metrics for internal comms (views, read time).
- [ ] **Advanced Analytics**: Engagement metrics for internal comms (views, read time).
## 💰 Monetization Options
### For the Developer (Platform Owner)
- **SaaS Subscription Models**:
- **Free Tier**: Basic features, storage limits, community support.
- **Pro Tier**: Advanced AI tools, unlimited storage, priority support.
- **Enterprise**: SSO, dedicated hosting, SLA, audit logs.
- **AI Credit System**: Pay-per-use model for high-compute generative AI tasks (Image generation, Voice-to-text).
- **White-Label Licensing**: Rebrandable instances for agencies to resell to their clients.
### For Customers (End Users)
- **Digital Asset Marketplace**: Sell templates, prompt packs, stock photos, or design assets directly from your library.
- **Premium Content Gates**: Create "Subscriber Only" pages or collections (Substack-style) for exclusive tutorials or resources.
- **Service Integration**: Showcase your portfolio and include "Hire Me" buttons to monetize your skills directly.
---
*Built with React, Vite, and Supabase.*

View File

@ -0,0 +1,50 @@
# Reference Image Integration in Page Generator
This document explains how user-selected reference images influence the generation process for both text and images within the AI Page Generator.
## Overview
When a user selects reference images in the AI Page Generator, these images are passed to the AI model (LLM) as part of the conversation context. This enables **multimodal generation**, where the AI can "see" the selected images and use that visual understanding to guide its output.
## data Flow
1. **Selection**: Users select images via the `ImagePickerDialog`. These are stored as `referenceImages` state in `AIPageGenerator`.
2. **Submission**: When "Generate" is clicked, the image URLs are collected and passed through `CreationWizardPopup` -> `usePageGenerator` -> `runTools`.
3. **Context Injection**: In `src/lib/openai.ts`, the `runTools` function detects the presence of images. It constructs a **multimodal user message** for the OpenAI API:
```json
{
"role": "user",
"content": [
{ "type": "text", "text": "User's text prompt..." },
{ "type": "image_url", "image_url": { "url": "https://..." } },
{ "type": "image_url", "image_url": { "url": "https://..." } }
]
}
```
## Impact on Generation
### 1. Text Generation (Direct Visual Context)
The LLM (e.g., GPT-4o) directly processes the image data. This allows it to:
* Describe the visible content of the reference images in the generated page.
* Match the tone, style, and mood of the text to the visual aesthetics of the images.
* Extract specific details (colors, objects, setting) from the images and incorporate them into the narrative.
### 2. Image Generation (Indirect Prompt Alignment)
Currently, **reference images are NOT passed as direct inputs** (img2img) to the underlying image generation tools (`generate_image` or `generate_markdown_image`).
Instead, the reference images influence image generation **indirectly via the LLM**:
1. The LLM "sees" the reference images and understands their style, composition, and subject matter.
2. When the LLM decides to generating *new* images for the page (using `generate_text_with_images`), it writes the **image generation prompts** based on this visual understanding.
3. **Result**: The newly generated images are likely to be stylistically consistent with the reference images because the prompts used to generate them were crafted by an AI that "saw" the references.
## Schema Reference
* **`runTools` (`openai.ts`)**: Accepts `images: string[]` and builds the multimodal message.
* **`generate_text_with_images` (`markdownImageTools.ts`)**: Accepts text prompts for new images, but does not accept input images.
* **`generate_image` (`openai.ts`)**: Accepts text prompts, count, and model, but does not accept input images.

View File

@ -0,0 +1,132 @@
# AI Page Generation Wizard
## 1. Overview
The AI Page Generation Wizard introduces a high-level, AI-driven workflow for creating complete pages, not just individual images. It leverages the existing voice input and image generation capabilities to offer a seamless "voice-to-page" experience. Users can dictate an idea, and the AI will generate a fully-formed page containing rich text content, embedded images, and appropriate metadata like tags and a title.
This feature will be accessed through a new, unified creation popup in the header, which will serve as a central starting point for both the existing Image Wizard and the new Page Wizard.
## 2. User Interface & Flow
### New Entry Popup
A new button will be added to the `Header`, triggering a "Creation Wizard" popup. This popup will be the primary entry point for AI-assisted content creation.
**Popup Design:**
- **Title:** What would you like to create?
- **Two Main Options:**
1. **Generate Image:** For creating standalone images.
- **Fast & Direct:** Opens the standard Image Wizard.
- **Smart & Optimized:** Opens the Image Wizard in Agent mode.
- **Voice + AI:** Opens the Image Wizard's voice agent popup directly.
2. **Create Page:** For generating entire pages.
- **From Scratch:** Opens a new page editor.
- **AI Agent:** A future feature for more complex, multi-step page generation.
- **Voice + AI:** This is the primary new flow. It opens a voice recording UI to start the voice-to-page process.
### Voice-to-Page Flow
1. **Initiation:** The user clicks the "Voice + AI" button under "Create Page".
2. **Recording:** A voice recording modal appears (reusing the component from `ImageWizard`). The user describes the page they want to create (e.g., "Write a tutorial on how to brew the perfect cup of green tea, include an image of a serene tea setup").
3. **Processing:** The UI shows a status progression: `Transcribing...` -> `Generating content...` -> `Creating page...`.
4. **Completion:** Once the page is created, the user is automatically redirected to the new page in view mode. A success toast notification confirms the creation.
## 3. Dependencies
This feature will leverage many existing parts of the application and introduce a few new components.
### Existing Components & Modules to Reuse:
- **`Header.tsx`**: To add the new wizard trigger button.
- **`ImageWizard.tsx` / `VoiceRecordingPopup.tsx`**: The UI for voice recording and transcription.
- **`lib/openai.ts`**: The core `runTools` function, `zodFunction` helper, and existing tool definitions (`transcribeAudioTool`). We will add a new preset and tools here.
- **`lib/markdownImageTools.ts`**: The `generateTextWithImagesTool` will be crucial for the AI to generate the main content of the page.
- **`integrations/supabase/client.ts`**: For database interactions within the new page creation tool.
- **`pages/UserPage.tsx`**: The destination view for the newly created page.
### New Components & Modules to Create:
- **`components/CreationWizardPopup.tsx`**: The new modal that serves as the entry point.
- **`hooks/usePageGenerator.ts`**: A new hook to orchestrate the multi-step voice-to-page generation process.
- **`lib/pageTools.ts`**: A new file to house the AI tool(s) responsible for page creation to keep concerns separated.
## 4. Implementation Plan
The implementation can be broken down into the following tasks:
1. **Task 1: Create New AI Tools for Page Management**
- Create a new file: `src/lib/pageTools.ts`.
- Define a new tool `createPageTool` using `zodFunction`.
- **Schema:** `({ title: string, content: string, tags: string[], slug: string, is_public?: boolean, visible?: boolean })`.
- **Functionality:**
- It will accept the page title, markdown content, and tags.
- It must format the markdown content into the required page JSON structure (with `containers`, `widgets`, and `widgetId: "markdown-text"`).
- It will insert a new row into the `pages` table in Supabase.
- It will return the `slug` of the newly created page so the UI can navigate to it.
2. **Task 2: Define a New `runTools` Preset**
- In `src/lib/openai.ts`, create a new preset called `'page-generator'`.
- **Tools:** This preset will include `generateTextWithImagesTool` (from `markdownImageTools.ts`) and the new `createPageTool` (from `pageTools.ts`).
- **System Prompt:** A detailed system prompt will guide the LLM through the process:
1. First, understand the user's request from the transcribed text.
2. Use the `generateTextWithImagesTool` to create rich markdown content, including one or more relevant images.
3. From the generated content, derive a concise title and a list of relevant tags.
4. Generate a URL-friendly slug from the title.
5. Finally, call the `createPageTool` with the title, slug, tags, and the full markdown content to save the page.
3. **Task 3: Develop the Orchestration Logic**
- Create a new hook `usePageGenerator` (`src/hooks/usePageGenerator.ts`).
- This hook will manage the state of the voice-to-page flow (`isTranscribing`, `isGenerating`, `isCreating`).
- It will contain a function, e.g., `generatePageFromVoice(audioFile)`, which:
1. Calls `transcribeAudio`.
2. Calls `runTools` with the `'page-generator'` preset and the transcribed text.
3. Processes the result from `createPageTool` to get the new page slug.
4. Uses the `navigate` function from `react-router-dom` to redirect the user.
4. **Task 4: Build the UI Components**
- Create the `CreationWizardPopup.tsx` component with the layout described in the UI section.
- Add a new state and button to `Header.tsx` to open this popup.
- The "Voice + AI" button for page creation will trigger the `usePageGenerator` logic and display the status to the user.
## 5. Sequence Diagram (Mermaid)
This diagram illustrates the full voice-to-page workflow.
```mermaid
sequenceDiagram
participant User
participant HeaderUI
participant CreationWizardPopup
participant VoiceUI
participant PageGeneratorHook
participant OpenAIApi as OpenAI API
participant SupabaseDB as Supabase DB
User->>HeaderUI: Clicks "Create" button
HeaderUI->>CreationWizardPopup: Opens popup
User->>CreationWizardPopup: Selects "Create Page" -> "Voice + AI"
CreationWizardPopup->>VoiceUI: Opens voice recorder
User->>VoiceUI: Records voice command
VoiceUI-->>PageGeneratorHook: onTranscriptionComplete(audioBlob)
PageGeneratorHook->>OpenAIApi: transcribeAudio(audioBlob)
OpenAIApi-->>PageGeneratorHook: Returns transcribed text
PageGeneratorHook->>OpenAIApi: runTools('page-generator', transcribedText)
note right of OpenAIApi: System prompt instructs AI to:<br/>1. Call generateTextWithImagesTool<br/>2. Call createPageTool
OpenAIApi->>OpenAIApi: 1. generateTextWithImagesTool(prompt)
note right of OpenAIApi: This internally calls<br/>createImage and uploads to storage
OpenAIApi-->>OpenAIApi: Returns markdown with image URLs
OpenAIApi->>OpenAIApi: 2. createPageTool(title, content, ...)
OpenAIApi-->>PageGeneratorHook: Tool call to create page
PageGeneratorHook->>SupabaseDB: INSERT INTO pages (title, content, ...)
SupabaseDB-->>PageGeneratorHook: Returns new page data (incl. slug)
PageGeneratorHook->>CreationWizardPopup: Page creation successful (returns slug)
CreationWizardPopup->>User: Navigates to new page URL (/user/.../pages/new-slug)
```

31
packages/ui/docs/pm.md Normal file
View File

@ -0,0 +1,31 @@
# Polymech Server Operations
## Hot Reloading via API
To update the server code without requiring root access or SSH:
1. **Deploy Code**: Sync the new `dist/` folder to the server (e.g., using `scripts/deploy.sh` or `rclone`).
2. **Trigger Restart**: Send a POST request to the restart endpoint with an admin token.
```bash
curl -X POST https://api.polymech.info/api/admin/system/restart \
-H "Authorization: Bearer <ADMIN_TOKEN>"
```
### How it works
- The endpoint `/api/admin/system/restart` is protected by admin authentication.
- When called, it waits 1 second (to flush the response) and then calls `process.exit(0)`.
- The systemd service is configured with `Restart=always` (or `on-failure`), so it automatically restarts the node process.
- The new process loads the updated code from the disk.
### Systemd Configuration
Ensure your `pm-pics.service` file includes:
```ini
[Service]
Restart=always
# or
Restart=on-failure
```

73
packages/ui/docs/posts.md Normal file
View File

@ -0,0 +1,73 @@
# Plan: Support Multiple Images/Videos per Post
## Overview
Currently, the application treats every image or video as an independent entity (a `picture` or linked `video`). We want to introduce a higher-level concept of a **Post** which can contain one or more media items (images or videos).
This allows:
- Grouping related photos (e.g., a photo dump, or variations).
- A unified description and comment section for the group.
- Preserving individual interactions (likes/comments) on specific images within the post if desired (as per requirements).
## Database Schema Changes
We will introduce a new `posts` table and link existing `pictures` to it.
### 1. New Table: `posts`
This table will hold the content for the "container".
| Column | Type | Notes |
|Ref|---|---|
| `id` | `uuid` | Primary Key, default `gen_random_uuid()` |
| `user_id` | `uuid` | FK to `auth.users` (or profiles) |
| `title` | `text` | Main title of the post |
| `description` | `text` | Description/Caption for the whole post |
| `created_at` | `timestamptz` | default `now()` |
| `updated_at` | `timestamptz` | |
| `metadata` | `jsonb` | Flexible field for extra data |
### 2. Update Table: `pictures`
We link media items to the post.
- Add column `post_id`: `uuid`, FK to `posts(id)`.
- Add column `position`: `integer`, default 0. To order images within a post.
> **Note**: Videos are stored in the `pictures` table with `type='mux-video'`, so this change covers both images and videos. The separate `videos` table in Supabase appears unused by the current frontend.
### 3. Update Table: `comments` and `likes`
Currently, `comments` and `likes` reference `picture_id`.
- **Requirement**: "we might have also comments, and descriptions for the parent 'post'".
- **Approach**:
- Add `post_id` to `comments` and `likes` tables (nullable).
- Or create `post_comments` / `post_likes` tables if cleaner.
- *Decision*: We will start with a simple structure where `posts` have their own `description` (already in table). For comments, we might need a unified comment system later or link comments to posts. For now, let's focus on `posts` containing `pictures`.
## Migration Strategy (SQL)
According to user feedback, **no backfill is required**. Old pictures will simply not be displayed in the new "Post" feed which will rely on the `posts` table.
1. **Create `posts` table.**
2. **Alter `pictures` table**: Add `post_id` column.
## UI/UX Updates
### Feed (`PhotoGrid.tsx`)
- Query `posts` instead of `pictures`.
- Fetch the first linked picture for the thumbnail.
### Post Detail (`Post.tsx`)
- Route `/post/:id` will now accept a **Post ID**.
- Fetch Post metadata.
- Fetch associated Media Items (`select * from pictures where post_id = :id order by position`).
### Creation Wizard
- Allow granular updates: "Select Multiple Files".
- Create Post -> Upload all files -> Create Picture records linked to Post.
## Step-by-Step Implementation
1. **Supabase Migration**: Create tables, run backfill script.
2. **Codebase - Types**: Update `types.ts` (re-run codegen).
3. **Codebase - API**: Update any fetch functions to use `posts`.
4. **UI - Feed**: Switch `PhotoGrid` to use `posts`.
5. **UI - Detail**: Rewrite `Post.tsx` to handle `Post` + `Media[]`.
6. **UI - Create**: Update upload logic.

View File

@ -0,0 +1,58 @@
# PWA Sharing & Mobile Integration Options
## Overview
We have implemented a **Progressive Web App (PWA)** with the **Web Share Target API** to allow sharing text, images, and videos from mobile apps directly to the Polymech Pics app.
## Implementation Details
### 1. Manifest
The manifest is manually managed in `public/manifest.webmanifest`.
It includes the `share_target` configuration:
```json
"share_target": {
"action": "/upload-share-target",
"method": "POST",
"enctype": "multipart/form-data",
"params": {
"title": "title",
"text": "text",
"url": "url",
"files": [
{
"name": "file",
"accept": ["image/*", "video/*"]
}
]
}
}
```
### 2. Service Worker (`src/sw.ts`)
The Service Worker intercepts `POST` requests to `/upload-share-target`:
1. Catches the request.
2. Parses `FormData` (files, title, text).
3. Stores data in **IndexedDB** (using `idb-keyval`, key: `share-target`).
4. Redirects the user to `/new?shared=true`.
### 3. Frontend (`src/pages/NewPost.tsx`)
On the "New Post" page:
1. Checks for `?shared=true`.
2. Reads from IndexedDB (`share-target`).
3. Pre-loads the images/text into the wizard.
4. Clears the IndexedDB entry.
### 4. Build Configuration (`vite.config.ts`)
- `vite-plugin-pwa` is configured with `manifest: false` to respect the manual file in `public/`.
- Strategy is `injectManifest` using `src/sw.ts`.
- Updates are handled via `workbox-core`'s `skipWaiting()` and `clientsClaim()` for immediate activation.
## Installation (Add to Home Screen)
The app meets the criteria for "Add to Home Screen" (A2HS):
- **Manifest**: Correctly linked with `name`, `icons` (192px/512px), `start_url`, and `display: standalone`.
- **Service Worker**: Registered and functioning offline (precaching cached assets).
- **HTTPS**: Required for PWA installation (ensure your deployment provider supports this).
### Testing A2HS
- **Desktop Chrome**: Look for the "Install" icon in the address bar.
- **Android**: Open Chrome -> Menu -> "Add to Home screen".
- **iOS**: Open Safari -> **Share** button -> "Add to Home Screen" (Note: iOS doesn't prompt automatically).

View File

@ -0,0 +1,630 @@
# Post Rendering Flow Documentation
This document traces the complete data flow and rendering pipeline for opening and displaying a post in the pm-pics application, from the PhotoGrid component through to the CompactMediaViewer.
---
## Overview
The post rendering system follows this component chain:
```
PhotoGrid → Post.tsx → CompactRenderer → CompactMediaViewer
```
Each layer transforms and enriches the data, handling navigation state, media types, and rendering options.
---
## 1. Entry Point: PhotoGrid Component
**File**: [PhotoGrid.tsx](../src/components/PhotoGrid.tsx)
### Data Source
PhotoGrid receives media items from two possible sources:
1. **Custom Pictures** (props): Pre-loaded media items passed directly
2. **Feed Data** (hook): Fetched via `useFeedData` hook with parameters:
- `source`: 'home' | 'collection' | 'tag' | 'user' | 'widget'
- `sourceId`: Identifier for the source
- `sortBy`: 'latest' | other sort options
- `categorySlugs`: Optional category filtering
### Data Structure: MediaItemType
```typescript
interface MediaItemType {
id: string;
picture_id?: string;
title: string;
description: string | null;
image_url: string;
thumbnail_url: string | null;
type: MediaType;
meta: any | null;
likes_count: number;
created_at: string;
user_id: string;
comments: { count: number }[];
// Enriched fields
author?: UserProfile;
job?: any;
responsive?: any;
versionCount?: number;
}
```
### Navigation Data Setup
When a user clicks on a media item (line 258-274):
```typescript
const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
// Special handling for internal pages
if (type === 'page-intern') {
navigate(`/user/${username}/pages/${slug}`);
return;
}
// Update navigation context with current index
setNavigationData({ ...navigationData, currentIndex: index });
// Navigate to post view
navigate(`/post/${mediaId}`);
}
```
**Navigation Context** includes:
- `posts`: Array of post metadata (id, title, image_url, user_id, type)
- `currentIndex`: Position in the feed
- `source`: Where the user came from
- `sourceId`: Source identifier
---
## 2. Post Container: Post.tsx
**File**: [Post.tsx](../src/pages/Post.tsx)
### Data Fetching Strategy
The `fetchMedia` function (lines 477-674) implements a **3-tier fallback strategy**:
#### Tier 1: Fetch as Post
```typescript
const postData = await db.fetchPostById(id);
```
Returns a `PostItem` with:
- Post metadata (title, description, settings)
- Array of `pictures` (sorted by position)
- User information
#### Tier 2: Fetch as Picture
```typescript
const pictureData = await db.fetchPictureById(id);
```
If found and has `post_id`, fetches the full post. Otherwise creates a **pseudo-post** with single picture.
#### Tier 3: Fetch as Page
```typescript
const pageData = await db.fetchPageById(id);
```
Creates a pseudo-post for internal page content with `type: 'page-intern'`.
### Version Resolution
After fetching, the system resolves image versions (lines 478-526):
```typescript
const resolveVersions = async (items: any[]) => {
// 1. Extract root IDs (parent_id || id)
const rootIds = items.map(i => i.parent_id || i.id);
// 2. Fetch all related versions
const allVersions = await db.fetchRelatedVersions(rootIds);
// 3. Preserve original positions
// 4. Sort by position, then created_at
// 5. Deduplicate by ID
}
```
This ensures that:
- AI-generated variations are grouped with originals
- User-selected versions are displayed
- Position order is maintained
### State Management
Key state variables:
```typescript
const [post, setPost] = useState<PostItem | null>(null);
const [mediaItems, setMediaItems] = useState<MediaItem[]>([]);
const [mediaItem, setMediaItem] = useState<MediaItem | null>(null); // Currently displayed
const [viewMode, setViewMode] = useState<'compact' | 'article' | 'thumbs'>('compact');
```
### View Mode Synchronization
View mode is synchronized with URL parameters (lines 82-124):
```typescript
// Initialize from URL
const viewParam = searchParams.get('view');
if (viewParam === 'article' || viewParam === 'compact' || viewParam === 'thumbs') {
return viewParam;
}
// Sync state to URL
useEffect(() => {
const newParams = new URLSearchParams(searchParams);
newParams.set('view', viewMode);
setSearchParams(newParams, { replace: true });
}, [viewMode]);
```
### Data Types
#### PostItem
```typescript
interface PostItem {
id: string;
title: string;
description: string | null;
user_id: string;
created_at: string;
updated_at: string;
pictures?: PostMediaItem[];
settings?: Json;
meta?: Json;
isPseudo?: boolean; // For single pictures without posts
type?: string; // 'page-intern' for internal pages
}
```
#### PostMediaItem
```typescript
interface PostMediaItem {
id: string;
title: string;
description: string | null;
image_url: string;
thumbnail_url: string | null;
user_id: string;
type: MediaType;
created_at: string;
position: number | null;
post_id: string | null;
parent_id: string | null; // For versions
meta: Json | null;
likes_count: number;
visible: boolean;
is_liked?: boolean; // Enriched by user context
renderKey: string; // Unique key for React rendering
}
```
---
## 3. Renderer Selection: CompactRenderer
**File**: [CompactRenderer.tsx](../src/pages/Post/renderers/CompactRenderer.tsx)
### Props Interface
```typescript
interface PostRendererProps {
post: PostItem;
authorProfile: UserProfile;
mediaItems: PostMediaItem[];
mediaItem: PostMediaItem; // Currently selected
// State
isOwner: boolean;
isLiked: boolean;
likesCount: number;
currentImageIndex: number;
// Media-specific
videoPlaybackUrl?: string;
videoPosterUrl?: string;
versionImages: ImageFile[];
// Callbacks
onMediaSelect: (item: PostMediaItem) => void;
onExpand: (item: PostMediaItem) => void;
onLike: () => void;
onDeletePicture: (id: string) => void;
// ... many more action handlers
// Navigation
navigationData: any;
handleNavigate: (direction: 'next' | 'prev') => void;
// Edit mode
isEditMode?: boolean;
localPost?: { title: string; description: string };
localMediaItems?: any[];
// ... edit-related handlers
}
```
### Layout Strategy
CompactRenderer implements a **responsive 2-column layout**:
#### Desktop (lg breakpoint):
- **Left Column**: Media viewer + filmstrip
- **Right Column**: Header + details (scrollable)
#### Mobile:
- **Stacked Layout**: Header → Media feed → Details
### Component Breakdown
```
CompactRenderer
├── CompactPostHeader (mobile top, desktop right)
│ ├── User info, title, description
│ ├── View mode switcher
│ └── Action menu (edit, delete, export)
├── Left Column (Desktop)
│ ├── CompactMediaViewer (main media display)
│ └── CompactFilmStrip (thumbnail navigation)
├── MobileGroupedFeed (Mobile only)
│ └── Vertical scrolling media cards
└── Right Column (Desktop)
├── CompactPostHeader (repeated)
└── CompactMediaDetails
├── Like/comment stats
├── Description
├── Comments section
└── Action buttons
```
---
## 4. Media Display: CompactMediaViewer
**File**: [CompactMediaViewer.tsx](../src/pages/Post/renderers/components/CompactMediaViewer.tsx)
### Media Type Handling
The viewer handles **6 distinct media types**:
#### 1. Internal Videos (`video`, `video-intern`)
```typescript
<MediaPlayer
src={videoPlaybackUrl}
poster={videoPosterUrl}
load="idle"
posterLoad="eager"
/>
```
#### 2. TikTok Videos (`tiktok`)
```typescript
<iframe
src={mediaItem.image_url} // Embed URL
className="h-full aspect-[9/16]"
allow="encrypted-media;"
/>
```
#### 3. YouTube Videos (detected from `page-external` meta)
```typescript
const ytId = getYouTubeVideoId(url);
<iframe
src={`https://www.youtube.com/embed/${ytId}`}
allow="accelerometer; autoplay; clipboard-write; encrypted-media;"
allowFullScreen
/>
```
#### 4. TikTok Links (detected from `page-external` meta)
```typescript
// Shows thumbnail with play button overlay
// On click, loads iframe embed
const tikTokId = getTikTokVideoId(url);
<iframe src={`https://www.tiktok.com/embed/v2/${tikTokId}`} />
```
#### 5. Images (default)
```typescript
<ResponsiveImage
src={`${mediaItem.image_url}${cacheBustKeys[mediaItem.id] ? `?v=${cacheBustKeys[mediaItem.id]}` : ''}`}
onClick={() => onExpand(mediaItem)}
sizes="(max-width: 1024px) 100vw, 1200px"
/>
```
#### 6. External Pages (`page-external`)
Falls through to image display with thumbnail.
### Version Control
CompactMediaViewer implements **version navigation** (lines 59-84):
```typescript
const getVersionsForCurrentItem = () => {
const rootId = mediaItem.parent_id || mediaItem.id;
// Filter versions in same family
const visibleVersions = mediaItems.filter(item => {
const isGroupMatch = (item.id === rootId) || (item.parent_id === rootId);
if (!isOwner && !item.visible) return false;
return isGroupMatch;
});
// Sort by creation time
return visibleVersions.sort((a, b) =>
new Date(a.created_at).getTime() - new Date(b.created_at).getTime()
);
};
```
**Keyboard Navigation**:
- `ArrowUp`: Previous version
- `ArrowDown`: Next version
- `ArrowLeft`: Previous media item or post
- `ArrowRight`: Next media item or post
### Navigation Controls
Two layers of navigation:
#### 1. Media Item Navigation (within post)
```typescript
onClick={() => {
if (currentImageIndex < mediaItems.length - 1) {
onMediaSelect(mediaItems[currentImageIndex + 1]);
} else {
handleNavigate('next'); // Move to next post
}
}}
```
#### 2. Post Navigation (between posts)
Uses `navigationData` context to move between posts in the feed.
---
## Data Flow Summary
```mermaid
graph TD
A[PhotoGrid] -->|Click mediaItem| B[navigate to /post/:id]
B --> C[Post.tsx fetchMedia]
C -->|Try 1| D[fetchPostById]
C -->|Try 2| E[fetchPictureById]
C -->|Try 3| F[fetchPageById]
D --> G[resolveVersions]
E --> G
F --> H[Create pseudo-post]
G --> I[setMediaItems]
H --> I
I --> J{viewMode?}
J -->|compact| K[CompactRenderer]
J -->|article| L[ArticleRenderer]
J -->|thumbs| M[ThumbsRenderer]
K --> N[CompactMediaViewer]
N --> O{mediaType?}
O -->|video| P[MediaPlayer]
O -->|tiktok| Q[TikTok iframe]
O -->|youtube| R[YouTube iframe]
O -->|image| S[ResponsiveImage]
```
---
## Refactoring Considerations
### Current Issues
1. **Data Duplication**: `MediaItemType` (PhotoGrid) vs `PostMediaItem` (Post.tsx) have overlapping fields
2. **Type Confusion**: `type` field is overloaded (media type vs page type)
3. **Meta Ambiguity**: `meta` field contains different structures for different types
4. **Navigation Coupling**: Navigation state tightly coupled to PhotoGrid
5. **Version Logic Scattered**: Version resolution happens in multiple places
### Proposed Improvements
#### 1. Unified Media Type System
```typescript
// Base media item
interface BaseMediaItem {
id: string;
title: string;
description: string | null;
user_id: string;
created_at: string;
likes_count: number;
visible: boolean;
}
// Discriminated union for media types
type MediaItem =
| ImageMediaItem
| VideoMediaItem
| YouTubeMediaItem
| TikTokMediaItem
| PageMediaItem;
interface ImageMediaItem extends BaseMediaItem {
mediaType: 'image';
image_url: string;
thumbnail_url: string | null;
parent_id?: string; // For versions
responsive?: ResponsiveImageData;
}
interface VideoMediaItem extends BaseMediaItem {
mediaType: 'video';
video_url: string;
thumbnail_url: string;
duration?: number;
resolution?: { width: number; height: number };
}
interface YouTubeMediaItem extends BaseMediaItem {
mediaType: 'youtube';
videoId: string;
thumbnail_url: string;
}
interface TikTokMediaItem extends BaseMediaItem {
mediaType: 'tiktok';
videoId: string;
thumbnail_url: string;
}
interface PageMediaItem extends BaseMediaItem {
mediaType: 'page';
pageType: 'internal' | 'external';
slug?: string; // For internal pages
url?: string; // For external pages
thumbnail_url?: string;
}
```
#### 2. Centralized Version Management
```typescript
class VersionManager {
// Group items by version family
groupByFamily(items: MediaItem[]): Map<string, MediaItem[]>
// Get selected version for each family
getSelectedVersions(items: MediaItem[], userId?: string): MediaItem[]
// Navigate versions within a family
getNextVersion(currentId: string, items: MediaItem[]): MediaItem | null
getPrevVersion(currentId: string, items: MediaItem[]): MediaItem | null
}
```
#### 3. Separate Navigation State
```typescript
interface NavigationContext {
// Feed-level navigation
feedState: {
posts: PostReference[];
currentIndex: number;
source: FeedSource;
sourceId?: string;
};
// Post-level navigation
postState: {
mediaItems: MediaItem[];
currentIndex: number;
versionFamilies: Map<string, MediaItem[]>;
};
}
```
#### 4. Renderer Configuration
```typescript
interface RenderOptions {
viewMode: 'compact' | 'article' | 'thumbs';
layout: {
showFilmstrip: boolean;
showComments: boolean;
showVersions: boolean;
autoplayVideos: boolean;
};
permissions: {
canEdit: boolean;
canDelete: boolean;
canComment: boolean;
};
}
```
### Migration Strategy
1. **Phase 1**: Create new type definitions alongside existing ones
2. **Phase 2**: Add adapters to convert between old and new types
3. **Phase 3**: Refactor CompactMediaViewer to use discriminated unions
4. **Phase 4**: Extract VersionManager as standalone service
5. **Phase 5**: Update PhotoGrid and Post.tsx to use new types
6. **Phase 6**: Remove old type definitions and adapters
---
## Key Files Reference
- [PhotoGrid.tsx](../src/components/PhotoGrid.tsx) - Feed display and navigation setup
- [Post.tsx](../src/pages/Post.tsx) - Post container and data fetching
- [CompactRenderer.tsx](../src/pages/Post/renderers/CompactRenderer.tsx) - Layout orchestration
- [CompactMediaViewer.tsx](../src/pages/Post/renderers/components/CompactMediaViewer.tsx) - Media type rendering
- [types.ts](../src/integrations/supabase/types.ts) - Database schema types
---
## Database Schema Reference
### Key Tables
#### `posts`
```sql
- id: string (PK)
- title: string
- description: string | null
- user_id: string
- settings: json | null
- meta: json | null
- created_at: timestamp
```
#### `pictures`
```sql
- id: string (PK)
- title: string
- description: string | null
- image_url: string
- thumbnail_url: string | null
- user_id: string
- post_id: string | null (FK)
- parent_id: string | null (FK) -- For versions
- position: integer | null
- type: string | null
- meta: json | null
- visible: boolean
- likes_count: integer
- created_at: timestamp
```
#### `pages`
```sql
- id: string (PK)
- title: string
- slug: string
- owner: string
- content: json | null
- meta: json | null
- type: string | null
- is_public: boolean
- visible: boolean
- created_at: timestamp
```
---
*Last updated: 2026-02-06*

526
packages/ui/docs/routes.md Normal file
View File

@ -0,0 +1,526 @@
# PM-Pics Routes Reference
> **Purpose**: Quick reference for LLM code assistants
> **Format**: Structured mappings of routes → components → files
---
## Route-to-Component Mapping
### Top-Level Routes
```yaml
/:
component: Index
file: src/pages/Index.tsx
renders: PhotoGrid
file: src/components/PhotoGrid.tsx
auth: false
/auth:
component: Auth
file: src/pages/Auth.tsx
auth: false
/profile:
component: Profile
file: src/pages/Profile.tsx
auth: required
/post/:id:
component: Post
file: src/pages/Post.tsx
auth: false
child_components:
- ImageLightbox: src/components/ImageLightbox.tsx
- Comments: src/components/Comments.tsx
- EditImageModal: src/components/EditImageModal.tsx
- MagicWizardButton: src/components/MagicWizardButton.tsx
/user/:userId:
component: UserProfile
file: src/pages/UserProfile.tsx
auth: false
/user/:userId/collections:
component: UserCollections
file: src/pages/UserCollections.tsx
auth: false
/collections/new:
component: NewCollection
file: src/pages/NewCollection.tsx
auth: required
/collections/:userId/:slug:
component: Collections
file: src/pages/Collections.tsx
auth: conditional # private collections require ownership
renders: PhotoGrid
/tags/:tag:
component: TagPage
file: src/pages/TagPage.tsx
auth: false
renders: PhotoGrid
/search:
component: SearchResults
file: src/pages/SearchResults.tsx
auth: false
query_params: [q]
renders: PhotoGrid
/wizard:
component: Wizard
file: src/pages/Wizard.tsx
renders: ImageWizard
file: src/components/ImageWizard.tsx
auth: required
child_components:
- ImageLightbox: src/components/ImageLightbox.tsx
- ModelSelector: src/components/ImageWizard/components/ModelSelector.tsx
- QuickActionsToolbar: src/components/ImageWizard/components/QuickActionsToolbar.tsx
- ImageActionButtons: src/components/ImageWizard/components/ImageActionButtons.tsx
/new:
component: NewPost
file: src/pages/NewPost.tsx
auth: required
/version-map/:id:
component: VersionMap
file: src/pages/VersionMap.tsx
auth: false
/organizations:
component: Organizations
file: src/pages/Organizations.tsx
auth: false
```
### Organization-Scoped Routes
All routes above have org-scoped equivalents:
```yaml
/org/:orgSlug/*:
pattern: /org/:orgSlug + [any top-level route]
examples:
- /org/acme-corp
- /org/acme-corp/post/:id
- /org/acme-corp/wizard
context: OrganizationContext
file: src/contexts/OrganizationContext.tsx
filtering: Content filtered by organization_id
```
---
## Core Navigation Components
```yaml
TopNavigation:
file: src/components/TopNavigation.tsx
provides_links:
- Home: / or /org/:orgSlug
- Organizations: /organizations
- Wizard: /wizard
- Profile: /profile
- Auth: /auth
uses_contexts:
- OrganizationContext
- AuthContext
PhotoGrid:
file: src/components/PhotoGrid.tsx
renders: PhotoCard[]
sets_context: PostNavigationContext
navigation_target: /post/:id
PhotoCard:
file: src/components/PhotoCard.tsx
navigation: onClick -> /post/:id
ImageLightbox:
file: src/components/ImageLightbox.tsx
navigation: prev/next via PostNavigationContext
keyboard: [ArrowLeft, ArrowRight, Escape, Space]
features: [Wizard mode, Templates, Voice, History]
MagicWizardButton:
file: src/components/MagicWizardButton.tsx
navigation: Current post -> /wizard
persistence: sessionStorage[wizardInitialImage, postNavigationContext]
```
---
## Navigation Contexts
```yaml
PostNavigationContext:
file: src/contexts/PostNavigationContext.tsx
provider: PostNavigationProvider
state:
posts: Array<{id, title, image_url, user_id}>
currentIndex: number
source: home | collection | tag | search | user
sourceId?: string
methods:
- setNavigationData(data)
- preloadImage(url)
persistence: sessionStorage (when navigating to wizard)
OrganizationContext:
file: src/contexts/OrganizationContext.tsx
provider: OrganizationProvider
state:
orgSlug: string | null
isOrgContext: boolean
usage: Filters content, prefixes routes with /org/:orgSlug
AuthContext:
file: src/hooks/useAuth.tsx
provider: AuthProvider
state:
user: User | null
guards: Protects /profile, /wizard, /new, /collections/new
LogContext:
file: src/contexts/LogContext.tsx
provider: LogProvider
state:
logs: LogEntry[]
isLoggerVisible: boolean
usage: Debug logging in wizard and image generation
```
---
## Navigation Mechanisms
### 1. React Router Navigation
```typescript
// File: src/pages/*.tsx
import { useNavigate } from "react-router-dom";
const navigate = useNavigate();
navigate('/post/:id'); // Click navigation
navigate(-1); // Back navigation
```
### 2. PostNavigationContext (Prev/Next)
```typescript
// File: src/pages/Post.tsx, src/components/ImageLightbox.tsx
const { navigationData, setNavigationData } = usePostNavigation();
// Navigate to next post
const newPost = navigationData.posts[navigationData.currentIndex + 1];
navigate(`/post/${newPost.id}`);
```
### 3. Session Storage Persistence
```typescript
// File: src/components/MagicWizardButton.tsx
// Store before navigating to wizard
sessionStorage.setItem('wizardInitialImage', JSON.stringify(imageData));
sessionStorage.setItem('postNavigationContext', JSON.stringify(navigationData));
// File: src/pages/Post.tsx
// Restore on return from wizard
const storedData = sessionStorage.getItem('postNavigationContext');
setNavigationData(JSON.parse(storedData));
sessionStorage.removeItem('postNavigationContext');
```
### 4. Keyboard Navigation
```typescript
// File: src/pages/Post.tsx
// Global keyboard listeners
ArrowLeft -> handleNavigate('prev') // Previous post
ArrowRight -> handleNavigate('next') // Next post
Escape -> navigate back // Return to feed
// File: src/components/ImageLightbox.tsx
// Lightbox keyboard listeners
ArrowLeft -> onNavigate('prev') // Previous image
ArrowRight -> onNavigate('next') // Next image
Space -> toggle prompt field // Show/hide prompt
Escape -> onClose() // Close lightbox
Ctrl+ArrowUp -> navigate history up // Previous prompt
Ctrl+ArrowDown -> navigate history down // Next prompt
```
### 5. Mouse Wheel Navigation
```typescript
// File: src/pages/Post.tsx
// Scroll-based navigation at page boundaries
wheelDelta > THRESHOLD && isAtBottom -> handleNavigate('next')
wheelDelta < THRESHOLD && isAtTop -> handleNavigate('prev')
```
### 6. Touch Gestures
```typescript
// File: src/components/ImageLightbox.tsx
// Swipe detection
swipe_left -> onNavigate('next') // Next image
swipe_right -> onNavigate('prev') // Previous image
double_tap -> open lightbox // On Post page
```
---
## Key Navigation Flows
### 1. Browse → View → Edit
```
Index (PhotoGrid)
↓ click photo
Post (/post/:id)
↓ click MagicWizardButton
↓ store context → sessionStorage
Wizard (/wizard)
↓ load from sessionStorage
↓ generate variations
↓ back button
Post (/post/:id)
↓ restore context ← sessionStorage
↓ resume at exact position
```
**Files involved**:
- `src/components/PhotoGrid.tsx` - Sets navigation data
- `src/pages/Post.tsx` - Stores/restores context
- `src/components/MagicWizardButton.tsx` - Handles storage
- `src/pages/Wizard.tsx` - Loads initial image
- `src/components/ImageWizard.tsx` - Main wizard logic
### 2. Lightbox Prev/Next Navigation
```
PhotoGrid
↓ setNavigationData(posts[], currentIndex)
PostNavigationContext
↓ posts[0...N]
Post page opens
↓ opens ImageLightbox
ImageLightbox
↓ user presses ← or →
↓ onNavigate('prev' | 'next')
Post page
↓ navigate(/post/[newId])
↓ updates currentIndex
```
**Files involved**:
- `src/contexts/PostNavigationContext.tsx` - Context provider
- `src/components/PhotoGrid.tsx` - Sets initial data
- `src/pages/Post.tsx` - Handles navigation
- `src/components/ImageLightbox.tsx` - UI controls
### 3. Organization Context Navigation
```
Any page
↓ URL contains /org/:orgSlug
OrganizationProvider
↓ extracts orgSlug from useParams()
↓ provides {orgSlug, isOrgContext}
All components
↓ read context
↓ filter content by organization_id
↓ prefix all routes with /org/:orgSlug
```
**Files involved**:
- `src/contexts/OrganizationContext.tsx` - Context provider
- `src/components/TopNavigation.tsx` - Adjusts links
- `src/components/PhotoGrid.tsx` - Filters content
- `src/pages/Post.tsx` - Scoped navigation
---
## Router Configuration
```typescript
// File: src/App.tsx
<BrowserRouter>
<AuthProvider>
<LogProvider>
<PostNavigationProvider>
<OrganizationProvider>
<Routes>
{/* Top-level routes */}
<Route path="/" element={<Index />} />
<Route path="/post/:id" element={<Post />} />
<Route path="/wizard" element={<Wizard />} />
{/* ... all other routes */}
{/* Organization-scoped routes */}
<Route path="/org/:orgSlug" element={<Index />} />
<Route path="/org/:orgSlug/post/:id" element={<Post />} />
<Route path="/org/:orgSlug/wizard" element={<Wizard />} />
{/* ... all other org routes */}
<Route path="*" element={<NotFound />} />
</Routes>
</OrganizationProvider>
</PostNavigationProvider>
</LogProvider>
</AuthProvider>
</BrowserRouter>
```
**Provider Nesting Order** (outer to inner):
1. `QueryClientProvider` - React Query
2. `AuthProvider` - Authentication state
3. `LogProvider` - Debug logging
4. `PostNavigationProvider` - Post navigation
5. `BrowserRouter` - Routing
6. `OrganizationProvider` - Organization context
---
## Image-Related Files (Wizard Features)
```yaml
Image Generation/Editing:
router: src/lib/image-router.ts
apis:
- OpenAI: src/lib/openai.ts
- Replicate: src/lib/replicate.ts
- AIMLAPI: src/lib/aimlapi.ts
- Bria: src/lib/bria.ts
entry: src/image-api.ts
Wizard Handlers:
base: src/components/ImageWizard/handlers/
files:
- imageHandlers.ts: Upload, selection, download
- generationHandlers.ts: Image generation, optimization
- publishHandlers.ts: Publishing to gallery
- dataHandlers.ts: Loading versions, images
- settingsHandlers.ts: Templates, presets, history
- voiceHandlers.ts: Voice recording, transcription
- agentHandlers.ts: AI agent generation
Wizard Components:
base: src/components/ImageWizard/components/
files:
- ModelSelector.tsx: Model selection UI
- QuickActionsToolbar.tsx: Quick action buttons
- ImageActionButtons.tsx: Per-image actions
```
---
## Authentication Guards
```typescript
// Pattern used in protected routes
// Files: src/pages/Profile.tsx, src/pages/Wizard.tsx, src/pages/NewPost.tsx
import { Navigate } from "react-router-dom";
import { useAuth } from "@/hooks/useAuth";
if (!user) {
return <Navigate to="/auth" replace />;
}
```
**Protected Routes**:
- `/profile``/auth`
- `/wizard``/auth`
- `/new``/auth`
- `/collections/new``/auth`
---
## Common Patterns
### Navigate to Post from Grid
```typescript
// File: src/components/PhotoGrid.tsx
const handleImageClick = (pictureId: string) => {
navigate(`/post/${pictureId}`);
};
```
### Navigate with Org Context
```typescript
// File: src/components/TopNavigation.tsx
const { orgSlug, isOrgContext } = useOrganization();
const basePath = isOrgContext ? `/org/${orgSlug}` : '';
navigate(`${basePath}/wizard`);
```
### Preserve Navigation on Wizard Visit
```typescript
// File: src/components/MagicWizardButton.tsx
// Before navigate
sessionStorage.setItem('postNavigationContext', JSON.stringify(navigationData));
// File: src/pages/Post.tsx
// On mount
const stored = sessionStorage.getItem('postNavigationContext');
if (stored) {
setNavigationData(JSON.parse(stored));
sessionStorage.removeItem('postNavigationContext');
}
```
### Lightbox Navigation Handler
```typescript
// File: src/pages/Post.tsx
const handleNavigate = (direction: 'prev' | 'next') => {
if (!navigationData) return;
const newIndex = direction === 'next'
? navigationData.currentIndex + 1
: navigationData.currentIndex - 1;
if (newIndex >= 0 && newIndex < navigationData.posts.length) {
const newPost = navigationData.posts[newIndex];
setNavigationData({ ...navigationData, currentIndex: newIndex });
navigate(`/post/${newPost.id}`);
}
};
```
---
## Quick Reference
### Find Route Handler
- Route definition: `src/App.tsx`
- Page component: `src/pages/[PageName].tsx`
- Navigation setup: `src/components/TopNavigation.tsx`
### Find Context Usage
- Auth: `useAuth()` from `src/hooks/useAuth.tsx`
- Navigation: `usePostNavigation()` from `src/contexts/PostNavigationContext.tsx`
- Organization: `useOrganization()` from `src/contexts/OrganizationContext.tsx`
### Find Navigation Logic
- Click navigation: `src/components/PhotoGrid.tsx`, `src/components/TopNavigation.tsx`
- Keyboard navigation: `src/pages/Post.tsx` (global), `src/components/ImageLightbox.tsx` (lightbox)
- Context persistence: `src/components/MagicWizardButton.tsx`, `src/pages/Post.tsx`
---
**Version**: 2.0
**Last Updated**: 2025-10-03
**Format**: LLM-optimized reference

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 933 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.8 MiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.7 MiB

View File

@ -0,0 +1,41 @@
# Security Improvement Plan
## 1. Secrets Management (Critical)
**Issue**: [`Profile.tsx`](../src/pages/Profile.tsx) currently fetches and exposes API keys (OpenAI, Google, etc.) to the client browser.
**Goal**: Never send raw API keys to the client unless explicitly requested for a "Settings" view context, and even then, mask them.
### A. Client Side (`Profile.tsx`)
- [ ] **Remove** all code that fetches `_api_key` columns from `profiles` in [`Profile.tsx`](../src/pages/Profile.tsx).
- [ ] **Remove** Input fields for API keys in the main Profile view in [`Profile.tsx`](../src/pages/Profile.tsx).
- [ ] **Create** a new "Provider Settings" page (or modal) protected by re-auth or strict checks (Target: `src/pages/ProviderSettings.tsx`).
- [ ] Use `/api/me/secrets` (Server Proxy) to manage keys, never direct DB Updates for secrets.
### B. Server Side
- [ ] Ensure `user_secrets` table has RLS `auth.uid() = user_id`.
- [ ] Ensure no public endpoints (like `/api/profile/:id`) return columns from `user_secrets` in [`ServingProduct`](../server/src/products/serving/index.ts).
---
## 2. Authorization & ACL
**Goal**: Secure multi-user collaboration and Organization access.
### A. Shared Pages
- [ ] Implement `page_collaborators` RLS.
- [ ] **Verify**: A user cannot edit a page they are only a 'viewer' on.
- [ ] **Verify**: Listing pages returns both owned and shared pages in [`PageManager.tsx`](../src/components/PageManager.tsx).
### B. Organization Impersonation
- [ ] **Middleware**: Implement `OrganizationMiddleware` in `server/src`.
- [ ] **Logic**: If `X-Org-Slug` header is present:
1. Check if `auth.uid()` is an Admin/Member of that Org.
2. If yes, scope all queries to that Organization's `collection_id` or Context.
3. (Advanced) Allow "Impersonation" where an Admin acts as a specific user. This requires a signed Token exchange or a Server-Side "Sudo" mode. **Decision**: For now, stick to Role-Based Access (Admin reads all Org data) rather than direct User Impersonation to avoid audit confusion.
---
## 3. General Hardening
- [ ] **404 Handling**: In [`Post.tsx`](../src/pages/Post.tsx), ensure 404s do not redirect blindly.
- [ ] If permission denied (Private post), show "Unauthorized" (403).
- [ ] If missing, show "Not Found" (404).
- [ ] **Rate Limiting**: Ensure `/api/feed` and `/api/search` have basic rate limiting (using `hono-rate-limiter` or Redis) to prevent scraping.

View File

@ -0,0 +1,619 @@
# Supabase Development Guide
This guide covers common Supabase CLI operations for database migrations and TypeScript type generation.
**Note**: This project uses a **remote-only workflow** - we work directly with the remote Supabase database without running a local Supabase instance. This means all migrations are applied directly to the remote database.
## Two Workflow Options: Remote-Only vs Local Development
### Current Setup: Remote-Only (No Docker) ✅
This project currently uses a **remote-only workflow** without Docker:
**What we use:**
- ✅ `npx supabase migration new` - create migration files
- ✅ `npx supabase db push` - apply migrations to remote
- ✅ `npx supabase gen types` - generate TypeScript types from remote
- ✅ `npx supabase migration repair` - fix migration history
**What we DON'T use:**
- ❌ Docker Desktop
- ❌ Local Supabase instance
- ❌ `npx supabase start`
- ❌ `npx supabase db pull`
**Pros:**
- ✅ No Docker installation needed
- ✅ Simpler setup (just link and go)
- ✅ No Docker resource usage (RAM, CPU, disk)
- ✅ Faster onboarding for new developers
- ✅ Works on machines where Docker can't be installed
**Cons:**
- ❌ Test migrations directly on remote (more risky)
- ❌ Can't use `db pull` to sync from dashboard
- ❌ Need internet connection to work
- ❌ Slower migration testing cycle
- ❌ No local testing environment
### Alternative: Local Development with Docker (Optional)
If you install Docker Desktop, you can run a **local mirror** of your Supabase instance:
**How it works:**
1. Install Docker Desktop
2. Start local Supabase:
```bash
npx supabase start
```
This creates a complete local Supabase instance with:
- PostgreSQL database
- Auth server
- Storage server
- Realtime server
- All accessible at `http://localhost:54321`
3. Develop locally:
```bash
# Apply migrations to LOCAL database
npx supabase db push
# Test your changes locally
# (your app connects to localhost:54321)
# Pull schema changes
npx supabase db pull
# When satisfied, push to remote
npx supabase db push --linked
```
**Pros:**
- ✅ Test migrations safely before applying to production
- ✅ Can use `db pull` to sync from dashboard
- ✅ Work offline
- ✅ Faster migration testing
- ✅ Complete local development environment
- ✅ Seed data locally for testing
**Cons:**
- ❌ Requires Docker Desktop (4-6 GB disk space)
- ❌ Docker uses significant RAM (2-4 GB)
- ❌ More complex setup
- ❌ Need to manage two databases (local + remote)
- ❌ Can get out of sync between local and remote
### Which Should You Choose?
**Stick with Remote-Only if:**
- You're working on a small project
- You trust your SQL and test carefully
- You want minimal setup complexity
- Your machine can't run Docker well
- You're new to database migrations
**Switch to Local Development if:**
- You're working on a production app with users
- You need to test complex migrations safely
- You want to experiment without affecting production
- You need to work offline
- Multiple developers need isolated test environments
- You use the Supabase dashboard and need to sync changes
### How to Switch to Local Development
If you decide you want local development later:
```bash
# 1. Install Docker Desktop
# Download from: https://docs.docker.com/desktop/
# 2. Start Docker Desktop
# 3. Initialize Supabase locally
npx supabase init
# 4. Start local Supabase
npx supabase start
# 5. Apply existing migrations to local
npx supabase db push
# 6. Link to remote (if not already linked)
npx supabase link --project-ref <your-project-ref>
# 7. Now you have both local and remote!
```
Then you can use both:
- `npx supabase db push` → applies to LOCAL database
- `npx supabase db push --linked` → applies to REMOTE database
- `npx supabase db pull` → pulls from remote to local migration files
### Our Current Choice: Remote-Only
For this project, we're sticking with **remote-only** to keep things simple. But the option to add local development is always there if needs change!
## Prerequisites
- Supabase CLI installed (`npm install -g supabase` or use `npx supabase`)
- A Supabase account and project at [supabase.com](https://supabase.com)
- Your project linked via CLI (see [Linking to Remote Project](#linking-to-remote-project) below)
## Linking to Remote Project
### First-Time Setup
If you haven't linked your project yet, connect to your remote Supabase project:
```bash
npx supabase link --project-ref <your-project-ref>
```
You can find your project reference in your Supabase dashboard URL:
- Format: `https://app.supabase.com/project/<your-project-ref>`
- Example: If URL is `https://app.supabase.com/project/abc123xyz`, then `abc123xyz` is your project ref
You'll be prompted to enter your database password (the one you set when creating the project).
### Checking Link Status
Verify your project is linked:
```bash
npx supabase projects list
```
This shows all your Supabase projects and indicates which one is currently linked.
### Switching Between Projects
If you work with multiple Supabase projects:
```bash
# Link to a different project
npx supabase link --project-ref <different-project-ref>
# Or unlink current project
npx supabase unlink
```
## Database Migrations
### Creating a New Migration
Create a new migration file with a descriptive name:
```bash
npx supabase migration new <migration_name>
```
Example:
```bash
npx supabase migration new add_user_preferences_table
```
This creates a new file in `supabase/migrations/` with timestamp prefix. Edit this file to add your SQL changes.
### Running Migrations on Remote (Remote-Only Workflow)
**For remote-only development** (no local Supabase instance), apply migrations directly to your linked project:
```bash
npx supabase db push
```
When linked to a remote project, this command pushes migrations to your remote database.
⚠️ **Important**: Test migrations carefully as you're working directly with your remote database!
### Checking Migration Status
See which migrations have been applied:
```bash
npx supabase migration list
```
### Pulling Schema from Remote
**⚠️ Important Note**: `npx supabase db pull` requires Docker Desktop because it creates a local "shadow database" to compare schemas. This is a limitation of the Supabase CLI and conflicts with a true remote-only workflow.
**Alternatives for Remote-Only Development:**
**Option 1: Don't Pull Schema** (Recommended for Remote-Only)
Instead of pulling schema:
1. Make all changes through migration files (never in the dashboard)
2. Always create migrations for schema changes: `npx supabase migration new <name>`
3. Apply migrations with: `npx supabase db push`
4. This ensures your migration files are the source of truth
**Option 2: Export Schema Manually**
If you must capture dashboard changes, use direct SQL export:
```bash
# Get your database connection string from Supabase dashboard
# Settings > Database > Connection string (use "Direct connection")
# Then manually export schema (requires psql or pg_dump installed)
pg_dump "postgresql://postgres:[password]@db.[project-ref].supabase.co:5432/postgres" \
--schema-only --schema=public > schema_export.sql
```
Then create a migration file from the export manually.
**Option 3: Install Docker Desktop** (If you really need db pull)
If you absolutely need `db pull` functionality:
1. Install [Docker Desktop](https://docs.docker.com/desktop/)
2. Start Docker Desktop
3. Then `npx supabase db pull` will work
But this defeats the purpose of remote-only development.
**Our Recommendation for This Project:**
Since we're doing remote-only development:
- ✅ Create all schema changes via migration files
- ✅ Never make manual changes in the Supabase dashboard
- ✅ Use `npx supabase db push` to apply migrations
- ❌ Don't use `npx supabase db pull`
## TypeScript Type Generation
### Generate Types from Remote Database
After creating or modifying database tables, regenerate TypeScript types from your remote database:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
This command:
1. Connects to your linked remote Supabase project
2. Reads the current database schema
3. Generates TypeScript types
4. Saves them to `src/integrations/supabase/types.ts`
**Pro tip**: Run this command after every migration to keep your types in sync!
### Alternative: Using Project ID Directly
If you're not in a linked project directory:
```bash
npx supabase gen types typescript --project-id <your-project-ref> > src/integrations/supabase/types.ts
```
## Common Workflows
### Workflow 1: Adding a New Table (Remote-Only)
1. Create a migration file:
```bash
npx supabase migration new add_my_table
```
2. Edit the migration file in `supabase/migrations/` with your SQL:
```sql
CREATE TABLE public.my_table (
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
user_id uuid REFERENCES auth.users(id) ON DELETE CASCADE,
name text NOT NULL,
created_at timestamp with time zone DEFAULT now()
);
-- Enable RLS
ALTER TABLE public.my_table ENABLE ROW LEVEL SECURITY;
-- Add policies
CREATE POLICY "Users can read own data"
ON public.my_table FOR SELECT
USING (auth.uid() = user_id);
```
3. Review your migration file carefully (you're going straight to remote!)
4. Apply the migration to remote:
```bash
npx supabase db push
```
5. Generate TypeScript types from remote:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
6. Commit both the migration file and updated types to git
### Workflow 2: Modifying Existing Schema (Remote-Only)
1. Create a migration:
```bash
npx supabase migration new modify_users_table
```
2. Add your ALTER statements in the migration file:
```sql
-- Example: Adding a column
ALTER TABLE public.users ADD COLUMN bio text;
-- Example: Adding an index
CREATE INDEX idx_users_email ON public.users(email);
```
3. Review carefully (no local testing with remote-only workflow!)
4. Apply to remote:
```bash
npx supabase db push
```
5. Regenerate types:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
6. Commit migration file and updated types
### Workflow 3: Handling Dashboard Changes (Remote-Only)
**⚠️ Best Practice**: Avoid making changes in the Supabase dashboard. Always use migration files.
If you or a teammate accidentally made changes directly in the dashboard:
**Manual Migration Creation:**
1. Document what was changed in the dashboard
2. Create a new migration file:
```bash
npx supabase migration new sync_dashboard_changes
```
3. Manually write the SQL that represents those changes in the migration file
4. Apply the migration (this will fail if changes already exist, which is expected):
```bash
npx supabase db push
```
5. Mark the migration as applied even though it failed:
```bash
npx supabase migration repair --status applied <migration-timestamp>
```
6. Regenerate types:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
7. Commit the migration file and updated types
**Better Approach**: Establish a team rule to never make manual changes in the dashboard.
### Workflow 4: Quick Deploy Script
Use the existing deploy script for a complete deployment:
```bash
bash scripts/supabase-deploy.sh
```
This script (from `scripts/supabase-deploy.sh`):
1. Deploys Mux proxy edge function
2. Pushes all pending database migrations
3. Regenerates TypeScript types from remote schema
## Troubleshooting
### Types Not Updating
If types don't reflect your changes:
1. Ensure migration was applied:
```bash
npx supabase migration list
```
2. Try regenerating with explicit project ref:
```bash
npx supabase gen types typescript --project-id <your-project-ref> > src/integrations/supabase/types.ts
```
### Migration Conflicts
If you have migration conflicts:
1. Check the migration order in `supabase/migrations/`
2. Ensure timestamps are correct
3. Consider creating a new migration to fix issues rather than editing old ones
### Database Out of Sync
If your migration files are out of sync with the remote database:
**Remote-Only Approach:**
1. Review what's in your `supabase/migrations/` directory
2. Check what's actually in the remote database (via Supabase dashboard > Table Editor)
3. Create a new migration to fix the differences:
```bash
npx supabase migration new fix_sync_issue
```
4. Write SQL in the migration file to align remote with what you want
5. Apply the migration:
```bash
npx supabase db push
```
**Note**: Without `db pull` (which requires Docker), you need to manually track what's in your database vs. your migration files.
### Migration History Mismatch
If you get an error like "The remote database's migration history does not match local files":
This happens when:
- Migration files were deleted/modified locally but the remote database still has records of them
- Team members have different migration files
- The migration history table is out of sync
**Solution 1: Repair Migration History (Recommended)**
The CLI will suggest repair commands. You can run them all at once using the provided script:
```bash
# Linux/Mac:
bash scripts/repair-migrations.sh
# Windows PowerShell:
.\scripts\repair-migrations.ps1
```
This script runs all the repair commands to sync your migration history table.
Or run them manually one by one:
```bash
# Copy all the suggested repair commands from the error output
# Example:
npx supabase migration repair --status reverted 20250927012218
npx supabase migration repair --status applied 20250119000001
# ... etc
```
**Solution 2: Clean Slate Approach**
If you have many migrations to repair and want to start fresh:
1. **Backup first!** Make sure you have your migration files in git
2. Decide on your source of truth:
- **Option A**: Your migration files are correct → just repair them all
- **Option B**: Remote database is correct → delete old migration files and create new ones
3. For Option B (remote is correct):
- Delete old migration files that don't match remote
- Manually inspect the remote schema in Supabase dashboard
- Create new migration files that represent the current state
- Mark them as applied:
```bash
npx supabase migration repair --status applied <migration-timestamp>
```
4. Regenerate types:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
**Solution 3: Manual Cleanup**
If you know which migrations should be kept:
1. Check your `supabase/migrations/` directory
2. Delete any migration files that shouldn't be there
3. Run the repair commands for the remaining ones
4. Regenerate types:
```bash
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
```
**Understanding the Output:**
- `--status reverted` = Migration file exists locally but not applied in remote (or was rolled back)
- `--status applied` = Migration was applied in remote
After running the repair commands, your migration history should be in sync.
### Authentication Issues
If you get authentication errors:
1. Re-link your project:
```bash
npx supabase link --project-ref <your-project-ref>
```
2. Verify you're using the correct database password
3. Check your internet connection
## Best Practices (Remote-Only Workflow)
1. **Review migrations carefully before pushing** - you're working directly on remote, so double-check your SQL!
2. **Regenerate types after every schema change** to keep TypeScript in sync
3. **Use descriptive migration names** (e.g., `add_video_comments_table` not `update`)
4. **Always commit migration files AND updated types.ts together** in the same commit
5. **Use `npx supabase db pull`** after any manual dashboard changes to capture them as migrations
6. **Enable RLS on new tables** unless you have a specific reason not to
7. **Add appropriate indexes** for foreign keys and frequently queried columns
8. **Use transactions** in migrations when making multiple related changes
9. **Test in a staging environment** if possible before applying to production
10. **Keep your project linked** - verify with `npx supabase projects list`
## Quick Reference Commands (Remote-Only)
```bash
# Link to your Supabase project (first time setup)
npx supabase link --project-ref <your-project-ref>
# Check which project is linked
npx supabase projects list
# Create new migration file
npx supabase migration new <descriptive_name>
# Apply migrations to remote database
npx supabase db push
# Check migration status
npx supabase migration list
# Generate TypeScript types from remote
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
# Deploy everything (migrations + types + edge functions)
bash scripts/supabase-deploy.sh
# Repair migration history (if needed)
bash scripts/repair-migrations.sh # Linux/Mac
.\scripts\repair-migrations.ps1 # Windows PowerShell
```
**Note**: We don't use `npx supabase db pull` because it requires Docker Desktop, which defeats the remote-only workflow.
## Example: Complete Workflow from Start
```bash
# 1. Link your project (one-time setup)
npx supabase link --project-ref abc123xyz
# 2. Create a migration
npx supabase migration new add_comments_table
# 3. Edit the migration file in supabase/migrations/
# (Use your code editor to add SQL)
# 4. Push migration to remote
npx supabase db push
# 5. Regenerate types
npx supabase gen types typescript --linked > src/integrations/supabase/types.ts
# 6. Commit your changes
git add supabase/migrations/* src/integrations/supabase/types.ts
git commit -m "Add comments table"
git push
```
## References
- [Supabase Database Import Guide](https://supabase.com/docs/guides/database/import-data)
- [Migrating Within Supabase](https://supabase.com/docs/guides/platform/migrating-within-supabase)
- [Dashboard Restore Guide](https://supabase.com/docs/guides/platform/migrating-within-supabase/dashboard-restore)
- [Supabase CLI Documentation](https://supabase.com/docs/guides/cli)
- [Database Migrations](https://supabase.com/docs/guides/cli/local-development#database-migrations)
- [TypeScript Type Generation](https://supabase.com/docs/guides/api/rest/generating-types)

View File

@ -0,0 +1,172 @@
# Templates System Database Schema
This document describes the database schema for the LLM templates and filters system.
## Database Schema (Mermaid ERD)
```mermaid
erDiagram
auth_users {
uuid id PK
}
user_filter_configs {
uuid id PK
uuid user_id FK
text context
text provider
text model
text_array default_templates
jsonb custom_filters
jsonb variables
boolean is_default
timestamptz created_at
timestamptz updated_at
}
user_templates {
uuid id PK
uuid user_id FK
text name
text description
text context
text provider
text model
text prompt
text_array filters
text format
boolean is_public
integer usage_count
timestamptz created_at
timestamptz updated_at
}
filter_usage_logs {
uuid id PK
uuid user_id FK
uuid template_id FK
text context
text provider
text model
integer input_length
integer output_length
integer processing_time_ms
boolean success
text error_message
text_array filters_applied
timestamptz created_at
}
provider_configs {
uuid id PK
text name
text display_name
text base_url
jsonb models
jsonb rate_limits
boolean is_active
timestamptz created_at
timestamptz updated_at
}
context_definitions {
uuid id PK
text name
text display_name
text description
jsonb default_templates
jsonb default_filters
text icon
boolean is_active
timestamptz created_at
timestamptz updated_at
}
%% Relationships
auth_users ||--o{ user_filter_configs : "owns"
auth_users ||--o{ user_templates : "creates"
auth_users ||--o{ filter_usage_logs : "generates"
user_templates ||--o{ filter_usage_logs : "tracks_usage"
```
## Table Descriptions
### `user_filter_configs`
Stores user-specific filter configurations and preferences for different contexts.
**Key Fields:**
- `context`: One of 'maker-tutorials', 'marketplace', 'directory', 'commons'
- `provider`: AI service provider (default: 'openai')
- `model`: AI model to use (default: 'gpt-4o-mini')
- `default_templates`: Array of default template names
- `custom_filters`: JSONB object containing custom filter configurations
- `variables`: JSONB object for template variables
### `user_templates`
User-defined custom templates for various contexts.
**Key Fields:**
- `context`: The context this template applies to
- `prompt`: The actual LLM prompt text
- `filters`: Array of filter names to apply
- `format`: Output format ('text', 'json', 'markdown')
- `is_public`: Whether template is shared publicly
- `usage_count`: Tracking popularity
**Constraints:**
- Unique constraint on `(user_id, name, context)`
### `filter_usage_logs`
Analytics and performance tracking for filter usage.
**Key Fields:**
- `processing_time_ms`: Performance tracking
- `success`: Whether the operation succeeded
- `input_length`/`output_length`: Token usage tracking
- `filters_applied`: Which filters were used
### `provider_configs`
Configuration for different AI service providers.
**Key Fields:**
- `base_url`: API endpoint for the provider
- `models`: JSONB array of available models
- `rate_limits`: JSONB object with rate limiting info
**Initial Providers:**
- OpenAI (gpt-4o, gpt-4o-mini, gpt-4-turbo, gpt-3.5-turbo)
- Anthropic (claude-3-5-sonnet, claude-3-haiku)
- Google (gemini-pro, gemini-pro-vision)
- OpenRouter (various models)
### `context_definitions`
Defines available contexts and their default configurations.
**Key Fields:**
- `default_templates`: JSONB array of default template names
- `default_filters`: JSONB object with default filter configurations
- `icon`: UI icon identifier
**Initial Contexts:**
- **maker-tutorials**: DIY and maker content
- **marketplace**: Product and commerce content
- **directory**: Listing and categorization content
- **commons**: General-purpose templates
## Security
All tables use Row Level Security (RLS) with policies ensuring:
- Users can only access their own data
- Public templates are visible to all users
- Provider configs and context definitions are read-only for authenticated users
## Indexes
Performance indexes are created on:
- User foreign keys
- Context fields
- Public template flag
- Usage log timestamps
- Active provider/context flags

View File

@ -0,0 +1,98 @@
# Proposal: Tetris AI "Hive Mind" Cloud Architecture (Key-Value)
## Problem
The current Tetris AI is limited by:
1. **Local Memory**: `localStorage` restricts history to ~100 games.
2. **Isolation**: Strategies learned on one device are not shared.
## Solution: "Hive Mind" via Simple Key-Value Storage
We propose using Supabase as a simple, flexible NoSQL-style store. This avoids managing complex relational schemas while allowing the AI to dump large amounts of training data ("Long-Term Memory") into the cloud.
### 1. Database Schema
A single, generic table to store all AI data.
#### `tetris_data_store`
```sql
create table tetris_data_store (
id uuid primary key default gen_random_uuid(),
user_id uuid references auth.users(id),
-- Partitioning
bucket text not null, -- e.g., 'replay', 'model_snapshot', 'experiment'
key text, -- Optional human-readable ID (e.g., 'latest_v5')
-- The Payload
value jsonb not null, -- The actual game data / neural weights
-- Indexing (For "High Score" queries)
score int, -- Extracted from value for fast sorting
created_at timestamptz default now()
);
-- Index for retrieving the best games (The "Memory")
create index idx_tetris_store_bucket_score on tetris_data_store(bucket, score desc);
```
### 2. Usage Patterns
#### A. Storing Memories (Game Replays)
Instead of a rigid schema, the client simply dumps the game result JSON.
```typescript
await supabase.from('tetris_data_store').insert({
bucket: 'replay',
score: gameResult.score, // Hoisted for indexing
value: gameResult // { boardFeatures, weights, version... }
});
```
#### B. Retrieving "Collective Memory"
The AI can now fetch the top 1,000 global games to train on.
```typescript
const { data } = await supabase
.from('tetris_data_store')
.select('value')
.eq('bucket', 'replay')
.order('score', { ascending: false })
.limit(1000);
// Result: A massive array of high-quality training examples from all users.
```
#### C. syncing the "Global Brain"
We can store the canonical "Best Model" under a known key.
```typescript
// Fetching the Hive Mind
const { data } = await supabase
.from('tetris_data_store')
.select('value')
.eq('bucket', 'model_snapshot')
.eq('key', 'production_v1')
.single();
// Updating the Hive Mind (Admin / Edge Function)
await supabase.from('tetris_data_store').upsert({
bucket: 'model_snapshot',
key: 'production_v1',
value: newNetworkWeights
});
```
### 3. Implementation Plan
1. **Phase 1: Validation**
* Create the `tetris_data_store` table via SQL Editor.
* Add RLS policies (Insert: Authenticated, Select: Public).
2. **Phase 2: Client Integration**
* Update `aiStrategies.ts` to push high-score games (>100k) to the `replay` bucket.
* Add a "Load Hive Mind" button in `WeightsTuner.tsx`.
3. **Phase 3: Training**
* Create a simple script (or Edge Function) that pulls the top 1,000 `replay` items and runs the `train()` loop, then updates the `production_v1` model.
---
## Benefits of Key-Value Approach
* **Flexibility**: We can add new fields to the `value` JSON (e.g., "max_combo", "avg_speed") without migration.
* **Simplicity**: Only one table to manage.
* **Scale**: Partitioning by `bucket` allows us to store millions of replays easily.

View File

@ -0,0 +1,89 @@
# Tetris LLM Strategy: The "5-Game Coach"
The user has proposed a "Batch Tuning" model where an LLM analyzes gameplay every 5 games and adjusts the heuristic weights. This avoids real-time latency issues while leveraging the LLM's high-level reasoning capabilities.
---
## Architecture: "The Coach Loop"
### 1. The Cycle (5 Games)
The AI plays 5 consecutive games using the *current* weight set. During these games, we collect **performance metrics** rather than just raw moves.
**Metrics Collected per Game:**
* **Survival Time**: How long the game lasted.
* **Death Cause**: Did it top out? Was it a specific hole pattern?
* **Average Height**: Was the stack too high?
* **Holes Created**: A measure of "messiness".
* **Tetris Rate**: Percentage of lines cleared via 4-line clears (efficiency).
### 2. The Analysis Phase (Async)
After Game #5 ends, the `LLMCoachStrategy` triggers.
It constructs a prompt for the LLM (e.g., GPT-4o or Claude 3.5 Sonnet):
> **System Prompt:** You are a Tetris Grandmaster Coach. You tune heuristic weights for a bot.
>
> **Input:**
> "Over the last 5 games, the bot averaged 45,000 points.
> It died consistently due to **high stack height (avg 14)** and **inability to clear garbage lines**.
> Current Weights: { heightPenalty: 200, holesPenalty: 500, ... }"
>
> **Task:**
> Output a JSON object with NEW weights to fix these specific flaws.
> (e.g., "Increase heightPenalty to 350 to force lower play.")
### 3. The Adjustment
The LLM returns a JSON:
```json
{
"reasoning": "The bot is playing too dangerously high. Increasing height penalty.",
"weights": {
"heightAdded": 350,
"holesCreated": 550,
...
}
}
```
The game engine applies these weights immediately for the **next 5 games**.
---
## Implementation Details
### New Strategy: `LLMCoachStrategy`
Located in `src/apps/tetris/aiStrategies.ts`.
```typescript
class LLMCoachStrategy implements AIStrategy {
id = 'llm-coach';
buffer: GameResult[] = [];
onGameEnd(result: GameResult) {
this.buffer.push(result);
if (this.buffer.length >= 5) {
this.triggerCoachingSession();
this.buffer = []; // Clear buffer
}
}
async triggerCoachingSession() {
const stats = this.analyzeBuffer(this.buffer);
const newWeights = await fetchLLMAdvice(stats);
// Apply weights
setManualOverride(newWeights);
}
}
```
### Advantages
1. **Zero Latency Impact**: The LLM call happens *between* games (or in background), so it never lags the active gameplay.
2. **Semantic Tuning**: The LLM understands *concepts* ("Playing too risky") rather than just gradients, potentially escaping local minima that the Neural Network might get stuck in.
3. **Explainability**: The LLM provides a *reason* for every change, which we can display in the UI ("Coach says: 'Play safer!'").
---
## Integration Plan
1. **Backend**: Add an Edge Function `analyze-gameplay` that wraps the OpenAI/Anthropic API.
2. **Frontend**: Add the `LLMCoachStrategy` to `aiStrategies.ts`.
3. **UI**: Add a "Coach's Corner" panel to show the LLM's last advice and current "Focus".

View File

@ -0,0 +1,69 @@
# Tetris Neural AI Analysis Report
**Date:** 2026-02-07
**Target File:** `src/apps/tetris/neuralAI.ts`
**Context:** `neuralNetwork.ts`, `aiStrategies.ts`, `ai_heuristics.md` (KI)
## Executive Summary
The `neuralAI.ts` file acts as the bridge between the raw Neural Network engine (`neuralNetwork.ts`) and the game logic. It implements a **"Self-Reinforcing Imitation"** learning strategy, where the AI learns to predict the heuristic weights that led to successful games.
The implementation is **Architecturally Sound** and strictly follows the design documented in the Knowledge Items. The versioning system is robust for the network itself.
---
## Detailed Analysis
### 1. Learning Paradigm: "Success Imitation"
**Status: ✅ Valid Strategy**
* **Mechanism**: The `train` method in `neuralNetwork.ts` scales the error gradients by the `reward` (`delta.scale(example.reward)`).
* **Implication**:
* **High Reward (Good Games)**: The network strongly updates to match the weights used in that game.
* **Zero Reward (Bad Games)**: The network effectively *ignores* these examples. It does not "learn from mistakes" (negative reinforcement); it only "learns to repeat success" (positive reinforcement).
* **Assessment**: This is a safe and stable approach for this specific problem domain. It prevents the network from learning "what NOT to do" (which is efficient but unstable) and focuses on "what TO do".
### 2. Feature Normalization & Inputs
**Status: ✅ Correct (Tetris-Specific)**
The normalization constants in `extractBoardFeatures` are well-calibrated for standard Tetris:
* `linesCleared / 4`: Perfectly matches the max lines (Tetris).
* `holesCreated / 5`: Reasonable upper bound for a single placement. (A generic "I" piece placement rarely creates >4 holes).
* `rowTransitions / 50`: A generic board has 10 columns * 20 rows. Max transitions per row is ~10. Summing transitions across relevant surface rows... 50 is a safe normalization ceiling.
### 3. Output Scaling (Weights Configuration)
**Status: Intentional Bias**
As noted in the Strategies report, the `outputToWeights` function applies specific scalars that bias the AI towards survival:
* The `lineCleared` weight is capped at `1000` (vs 10,000 in manual defaults).
* The `holesCreated` penalty is capped at `3000` (vs 800 in manual defaults).
* **Correction**: This confirms the Neural AI is designed to be a "Survivalist" agent, prioritizing board cleanliness over aggressive scoring. This is optimal for **Consistency**, though it might achieve lower "high scores" than a risky greedy algorithm.
### 4. Versioning & Data Safety
**Status: ✅ Robust**
* **Versioning**: The `NETWORK_VERSION_KEY` assertion ensures that if the code structure changes (e.g., v5 -> v6), old incompatible `localStorage` data is wiped.
* **Manual Overrides**: The system correctly prioritizes `MANUAL_OVERRIDE_KEY`, allowing developers to forcibly guide the AI during debugging without corrupting the trained network.
### 5. Code Quality & Performance
**Status: ✅ Efficient**
* **Batching**: Training is performed in batches of 10 (`neuralAI.ts`: `batchSize = 10`), preventing UI thread freeze during backpropagation.
* **Memory**: The `Matrix` class implementation in `neuralNetwork.ts` is a lightweight wrapper around standard arrays, suitable for the small network size (`10 -> 16 -> 12 -> 10` = ~450 connections).
* **Reward Function**: `Math.min(score / 100000, 1) ** 0.5`. The square root dampening prevents improved performance from causing exploding gradients in later training stages.
---
## Potential Edge Cases
### 1. The "Cold Start" Initial Loop
If the initialized random network outputs weights that cause *immediate game over* (Reward ≈ 0), the network will **never learn** because `delta.scale(0)` kills the gradient.
* **Current Mitigation**: The `StrategyManager`'s `HallOfFame` likely won't have data yet.
* **Risk**: The first few generations rely purely on random initialization luck.
* **Recommendation**: Ensure `NeuralNetwork` initialization (`randomize`) produces weights in a range that allows *some* play (e.g., non-zero `heightAdded` penalty), or seed the initial network with "Decent" manual weights pre-trained.
### 2. Reward Saturation
The reward function is capped at `1.0`.
* `score / 100000`.
* If the AI becomes *too good* (Score > 100,000), the reward flattens to `1.0`.
* The AI will stop distinguishing between "Great" (150k) and "Godlike" (500k) games.
* **Recommendation**: Monitor if the AI plateaus around 100k points. If so, increase the normalization divisor.
## Conclusion
The Neural AI implementation is solid. The "Imitation of Success" paradigm is well-implemented, and the rigorous version checking in `neuralAI.ts` protects against data corruption. The only minor long-term risk is the reward saturation cap at 100k score.

View File

@ -0,0 +1,332 @@
# Tetris Neural Network Learning System
## Overview
This document explains how the Tetris AI uses a **feedforward neural network** to learn optimal play strategies through **self-play and weight adaptation**. Despite having 9 different weights to balance, the system successfully improves over time through a combination of neural network learning and adaptive weight scaling.
---
## Architecture
### Neural Network Structure
```
Input Layer (9 neurons)
Hidden Layer 1 (15 neurons)
Hidden Layer 2 (10 neurons)
Output Layer (9 neurons)
```
**Total Parameters:** ~300 trainable weights and biases
### Input Features (9)
The network receives normalized board state features (0-1 range):
1. **Lines Cleared** - Number of lines cleared this move (0-4)
2. **Contacts** - How many sides touch existing blocks (0-10)
3. **Holes Created** - New holes created by this move (0-5)
4. **Overhangs Created** - New overhangs created (0-5)
5. **Overhangs Filled** - Overhangs fixed by this move (0-5)
6. **Height Added** - Rows added to the stack (0-10)
7. **Well Depth** - Depth of vertical gaps (0-10)
8. **Bumpiness** - Surface unevenness (0-20)
9. **Average Height** - Mean column height (0-10)
### Output Weights (9)
The network outputs optimal weight values for the heuristic evaluation function:
1. **lineCleared** (0-20,000) - Bonus for clearing lines
2. **contact** (0-500) - Bonus for piece connectivity
3. **holesCreated** (0-2,000) - Penalty for creating holes
4. **overhangsCreated** (0-3,000) - Penalty for creating overhangs
5. **overhangsFilled** (0-1,000) - Bonus for filling overhangs
6. **heightAdded** (0-2,000) - Penalty for increasing height
7. **wellDepthSquared** (0-500) - Penalty for deep wells
8. **bumpiness** (0-200) - Penalty for uneven surface
9. **avgHeight** (0-100) - Penalty for overall height
---
## How Learning Works
### 1. **Self-Play Loop**
```
Game Start
Neural Network predicts optimal weights
AI plays using those weights
Game ends with score/lines/level
Network learns from performance
Repeat
```
### 2. **Weight Prediction Process**
For each game:
1. **Extract board features** from current state
2. **Feed through neural network** (forward propagation)
3. **Get weight predictions** (9 output values)
4. **Scale to appropriate ranges** (e.g., lineCleared: 0-20k)
5. **Use weights for move evaluation** (brute-force search)
### 3. **Learning from Results**
After each game:
```typescript
// Calculate reward based on performance
reward = scoreReward * 0.6 + linesReward * 0.3 + levelReward * 0.1
// Train network with backpropagation
network.train({
input: boardFeatures,
expectedOutput: weightsUsed,
reward: reward
});
```
**Key Insight:** The network learns which weight combinations lead to better scores, not just individual weight values.
---
## Why Multiple Weights Improve Learning
### Problem: Single-Objective Optimization
If we only had one weight (e.g., "maximize score"), the AI would:
- ❌ Get stuck in local optima
- ❌ Fail to learn nuanced strategies
- ❌ Struggle with different game phases
### Solution: Multi-Objective Optimization
With 9 weights, the AI can:
- ✅ **Balance competing goals** (score vs survival)
- ✅ **Adapt to game state** (early vs late game)
- ✅ **Learn complex strategies** (setup moves, defensive play)
- ✅ **Explore solution space** more effectively
### Example: Height Management
Instead of just "avoid height," the AI learns:
- `heightAdded` - Immediate penalty for this move
- `avgHeight` - Overall board state penalty
- `holesCreated` - Future consequences of height
- `bumpiness` - Surface quality at that height
This creates a **rich feedback signal** that guides learning toward sophisticated play.
---
## Adaptive Weight Scaling
### Height-Based Adaptation
The system uses **dynamic weight adjustment** based on board state:
```typescript
function getAdaptiveWeights(baseWeights: AIWeights, avgHeight: number): AIWeights {
const heightRatio = Math.min(avgHeight / 15, 1.0);
const crisisMode = heightRatio > 0.6;
const crisisMultiplier = crisisMode ? 1.5 : 1.0;
return {
// Bonuses decrease in crisis
lineCleared: baseWeights.lineCleared * (1 - heightRatio * 0.3),
contact: baseWeights.contact * (1 - heightRatio * 0.7),
// Critical penalties increase dramatically
holesCreated: baseWeights.holesCreated * (1 + heightRatio * 2.0 * crisisMultiplier),
heightAdded: baseWeights.heightAdded * (1 + heightRatio * 3.0 * crisisMultiplier),
avgHeight: baseWeights.avgHeight * (1 + heightRatio * 2.5 * crisisMultiplier),
// ... other weights
};
}
```
### Game Phases
| Phase | Height | Strategy | Weight Adjustments |
|-------|--------|----------|-------------------|
| **Early** | 0-6 | Aggressive scoring | +Score bonuses, -Penalties |
| **Mid** | 6-9 | Balanced play | Standard weights |
| **Late** | 9-12 | Defensive | +Height penalties |
| **Crisis** | 12+ | Survival mode | ++Height penalties, --Bonuses |
This allows the **same neural network** to play optimally across all game phases.
---
## Training Process
### 1. **Initialization**
```typescript
// Start with reasonable defaults
const DEFAULT_WEIGHTS = {
lineCleared: 10000,
contact: 100,
holesCreated: 800,
// ... etc
};
```
### 2. **Exploration**
Early games use neural network predictions to explore different weight combinations:
- Some games prioritize line clearing
- Some focus on height management
- Some balance multiple objectives
### 3. **Exploitation**
As training progresses:
- Network learns which combinations work best
- Predictions converge toward optimal weights
- Performance becomes more consistent
### 4. **Continuous Improvement**
The network keeps learning because:
- **Stochastic piece sequence** (TGM3 randomizer)
- **Different board states** require different strategies
- **Reward function** encourages both score and survival
---
## Why This Works
### 1. **Dimensionality Reduction**
9 weights might seem like a lot, but the neural network:
- Learns **correlations** between weights
- Discovers **weight combinations** that work together
- Reduces effective search space through hidden layers
### 2. **Hierarchical Learning**
The two hidden layers create a hierarchy:
- **Layer 1** learns basic patterns (e.g., "holes are bad")
- **Layer 2** learns combinations (e.g., "holes + height = very bad")
- **Output** produces coherent weight sets
### 3. **Reward Shaping**
The reward function balances multiple objectives:
```typescript
reward = scoreReward * 0.6 + linesReward * 0.3 + levelReward * 0.1
```
This prevents the AI from:
- ❌ Only maximizing score (ignoring survival)
- ❌ Only surviving (ignoring score)
- ✅ Finding the optimal balance
### 4. **Implicit Curriculum**
The adaptive weight system creates an **implicit curriculum**:
1. Learn to play at low heights (easier)
2. Gradually handle higher heights (harder)
3. Master crisis management (hardest)
---
## Performance Metrics
### Tracking Improvement
The system tracks:
- **Score trend** - Moving average over last 5 games
- **Best score** - Peak performance achieved
- **Average level** - Consistency indicator
- **Weight changes** - Learning activity
### Expected Learning Curve
```
Games 1-10: Exploration (high variance)
Games 10-50: Rapid improvement (finding good strategies)
Games 50-100: Refinement (optimizing details)
Games 100+: Mastery (consistent high performance)
```
---
## Key Insights
### Why 9 Weights Instead of 1?
**Single weight:**
```
Score = f(board_state)
```
- Simple but inflexible
- Can't adapt to different situations
- Limited learning capacity
**Multiple weights:**
```
Score = w₁·f₁(state) + w₂·f₂(state) + ... + w₉·f₉(state)
```
- Rich representation of strategy
- Adaptable to different game phases
- Higher learning capacity
### The Neural Network's Role
The network doesn't learn to play Tetris directly. Instead, it learns to:
1. **Recognize board patterns** (input features)
2. **Predict optimal weight combinations** (output)
3. **Adapt strategy** to current situation
This is more efficient than learning move-by-move because:
- ✅ Faster convergence (fewer parameters to learn)
- ✅ Better generalization (works on unseen boards)
- ✅ Interpretable (can see what weights it's using)
---
## Future Improvements
### Potential Enhancements
1. **Store board states** during gameplay for better training data
2. **Implement experience replay** to learn from past games
3. **Add piece preview features** (next piece, hold piece)
4. **Tune hyperparameters** (learning rate, network size)
5. **Implement TD-learning** for better credit assignment
### Advanced Techniques
- **Genetic algorithms** for weight evolution
- **Monte Carlo Tree Search** for move planning
- **Ensemble methods** combining multiple networks
- **Transfer learning** from human expert games
---
## Conclusion
The multi-weight neural network system works because it:
1. **Decomposes the problem** into manageable sub-objectives
2. **Learns correlations** between different aspects of play
3. **Adapts dynamically** to changing game conditions
4. **Balances exploration and exploitation** effectively
While 9 weights might seem complex, they provide the **expressiveness** needed for the neural network to discover sophisticated Tetris strategies through self-play.
The key is not the number of weights, but how they work together to create a rich, learnable representation of optimal play.

View File

@ -0,0 +1,84 @@
# Tetris AI Strategies Analysis Report
**Date:** 2026-02-07
**Target File:** `src/apps/tetris/aiStrategies.ts`
**Context:** `neuralAI.ts`, `gameLogic.ts`, `ai_heuristics.md` (KI)
## Executive Summary
The `aiStrategies.ts` file implements a robust **Strategy Pattern** for managing AI behavior, specifically focusing on "Meta-Learning" features like Experience Replay (`HallOfFameStrategy`) and Performance Safe-Guarding (`AutoRevertStrategy`). The implementation is logically sound, type-safe, and well-integrated with the core Neural Network engine (`neuralAI.ts`).
However, two significant findings require attention:
1. **Critical Versioning Risk**: The `AutoRevertStrategy` lacks version compatibility checks for the stored "Best Network".
2. **Weight Scaling Divergence**: The Neural Network's weight output ranges differ significantly from the documented "Default Baseline" heuristics.
---
## Detailed Analysis
### 1. Strategy Manager Architecture
**Status: ✅ Correct**
* **Singleton Implementation**: The `StrategyManager` correctly implements the Singleton pattern, ensuring a centralized point of control for AI behaviors across the application lifecycle.
* **Hooks**: The lifecycle hooks (`onGameEnd`, `onTrainingStart`, `onWeightsRequested`) provide the necessary injection points to modify training data and runtime behavior without coupling the core logic to specific strategies.
* **Persistence**: Configuration state (enabled/disabled strategies) is correctly persisted to `localStorage`.
### 2. Hall of Fame Strategy (Experience Replay)
**Status: ✅ Correct**
* **Logic**: The strategy correctly maintains a rolling buffer of the top 10 best games.
* **Training Injection**: The `50/50` mixing logic in `onTrainingStart` is implemented correctly:
* It takes the most recent 5 games from the current batch.
* It fills the remaining slots with random samples from the Hall of Fame.
* **Benefit**: This effectively mitigates "Catastrophic Forgetting" by ensuring the network constantly rehearses known successful patterns even while exploring new (potentially poor) strategies.
### 3. Auto-Revert Strategy (Safety Net)
**Status: ⚠️ Risk Identified**
* **Logic**: The performance monitoring logic (detecting a 50% drop from peak moving average) is sound.
* **Risk**: **Versioning Incompatibility**.
* **Scenario**: If the developer updates `NETWORK_CONFIG` (e.g., changing layer sizes from `[16, 12]` to `[20, 20]`) and increments `CURRENT_VERSION` in `neuralAI.ts`:
1. `neuralAI.ts` correctly clears the *current* network to start fresh.
2. However, `AutoRevertStrategy` **retains the old "Best Network" snapshot** in a separate localStorage key (`tetris-neural-network-best`).
3. If the new network performs poorly (which is expected during initial training), the `AutoRevertStrategy` may trigger.
4. It will attempt to load the **Old Version** network into the **New Version** codebase.
* **Consequence**: `NeuralNetwork.fromJSON()` might fail if the topology doesn't match, or worse, it might succeed but load incompatible weight matrices, causing runtime errors or undefined behavior.
* **Recommendation**: The `AutoRevertStrategy` snapshot should include the network version. Before reverting, verify that `snapshot.version === CURRENT_VERSION`.
### 4. Neural Network Integration & Heuristics
**Status: Note on Divergence**
* **Feature Alignment**: The 10 input features in `neuralAI.ts` (`extractBoardFeatures`) perfectly match the documentation in the Knowledge Item.
* **Weight Discrepancy**: There is a notable difference between the **Default Manual Weights** (documented in KI) and the **Neural Network Output Caps**:
| Weight | Default (Manual) | Neural Max Output | Assessment |
| :--- | :--- | :--- | :--- |
| `lineCleared` | **10,000** | **1,000** | **Drastic Reduction**. The Neural AI is forced to undervalue line clears compared to default. |
| `holesCreated` | 800 | 3,000 | **Increased Priority**. The Neural AI strictly penalizes holes. |
| `heightAdded` | 800 | 3,000 | **Increased Priority**. Stronger aversion to height growth. |
* **Interpretation**: The Neural AI implementation prioritizes **Survival** (avoiding holes/height) significantly more than the Manual Baseline, which prioritizes **Scoring** (lines). This matches the comment in `neuralAI.ts`: *"drastically reduced to prevent suicide for lines"*. This is likely a correct design decision for a self-learning AI (which needs to survive to learn), but it means the AI's playstyle will differ fundamentally from the "Standard" heuristic.
---
## Recommendations
1. **Fix Auto-Revert Versioning**:
Modify `AutoRevertStrategy` to store the version alongside the network.
```typescript
// In checkAndSaveBest
const payload = {
version: CURRENT_VERSION, // Import this
network: network.toJSON()
};
localStorage.setItem(this.bestNetworkKey, JSON.stringify(payload));
// In checkAndRevert
const data = JSON.parse(stored);
if (data.version !== CURRENT_VERSION) {
console.warn('Cannot revert: Best snapshot is from an older version.');
return;
}
```
2. **Documentation Update**:
Update `ai_heuristics.md` to explicitly mention that the Neural AI uses a "Survival-First" weight scaling profile that differs from the manual defaults.
3. **Refine Hall of Fame**:
Consider storing the *version* in Hall of Fame entries as well. Training on games played with a different neural architecture might be fine (since we only use Input/Output pairs), but if the *Features* (`boardFeatures`) implementation changes, the old examples becomes invalid.

141
packages/ui/docs/types.md Normal file
View File

@ -0,0 +1,141 @@
# Unified Type System
The Type System provides a flexible, schema-driven way to define data structures, enums, flags, and relationships within the Polymech platform. It is built on top of Supabase (PostgreSQL) and supports inheritance, validation (JSON Schema), and strict typing.
## Database Schema
The system consists of the following core tables in the `public` schema.
### Core Tables
#### `types`
The main table storing type definitions.
- `id`: UUID (Primary Key)
- `name`: Text (Unique constraint usually desired but not strictly enforced at DB level yet)
- `kind`: Enum (`primitive`, `enum`, `flags`, `structure`, `alias`)
- `parent_type_id`: UUID (Foreign Key -> `types.id`). Supports inheritance.
- `description`: Text (Optional)
- `json_schema`: JSONB (JSON Schema fragment validation)
- `owner_id`: UUID (Foreign Key -> `auth.users.id`)
- `visibility`: Enum (`public`, `private`, `custom`)
- `meta`: JSONB (Arbitrary metadata)
- `settings`: JSONB (UI/Editor settings)
- `created_at`, `updated_at`: Timestamps
#### `type_enum_values`
Values for `enum` types.
- `id`: UUID
- `type_id`: UUID (FK -> `types.id`)
- `value`: Text (The raw value)
- `label`: Text (Display label)
- `order`: Integer (Sort order)
#### `type_flag_values`
Bit definitions for `flags` types.
- `id`: UUID
- `type_id`: UUID (FK -> `types.id`)
- `name`: Text (Flag name)
- `bit`: Integer (Power of 2 or bit index)
#### `type_structure_fields`
Field definitions for `structure` types.
- `id`: UUID
- `structure_type_id`: UUID (FK -> `types.id`)
- `field_name`: Text
- `field_type_id`: UUID (FK -> `types.id`)
- `required`: Boolean
- `default_value`: JSONB
- `order`: Integer
#### `type_casts`
Transformation rules between types.
- `from_type_id`: UUID
- `to_type_id`: UUID
- `cast_kind`: Enum (`implicit`, `explicit`, `lossy`)
- `description`: Text
## Access Control (RLS)
Row Level Security is enabled on all tables.
### Policies
- **Read**:
- `public` types are visible to **all users** (authenticated and anonymous).
- `private` and `custom` types are visible only to their **owner** (creator) and **admins**.
- **Write** (Create, Update, Delete):
- **Owners** can modify their own types.
- **Admins** can modify any type.
- Ordinary users cannot modify system types (types with no owner or owned by system).
## API Endpoints
The type system is exposed via a RESTful API under `/api/types`.
### List Types
`GET /api/types`
**Query Parameters:**
- `kind`: Filter by type kind (e.g., `structure`).
- `parentTypeId`: Filter by parent type.
- `visibility`: Filter by visibility.
### Get Type Details
`GET /api/types/:id`
Returns the full type definition, including:
- Enum values (if enum)
- Flag values (if flags)
- Structure fields (if structure)
- Cast definitions
### Create Type
`POST /api/types`
Creates a new type. Supports atomic creation of children (enums/flags/fields).
**Body Payload:**
```json
{
"name": "MyType",
"kind": "enum",
"description": "A custom enum",
"visibility": "public",
"enumValues": [
{ "value": "A", "label": "Option A" },
{ "value": "B", "label": "Option B" }
]
}
```
### Update Type
`PATCH /api/types/:id`
Updates metadata (name, description, visibility, etc.).
*Note: Child modifications (adding fields/enums) should be handled via separate specific updates or by implementing deep update logic.*
### Delete Type
`DELETE /api/types/:id`
Deletes a type and cascades to its children (values, fields).
## Primitive Types
The system is seeded with the following primitive types (immutable system types):
- `bool`
- `int`
- `float`
- `string`
- `array`
- `object`
- `enum`
- `flags`
- `reference`
- `alias`
---
*Generated by Antigravity*

View File

@ -0,0 +1,35 @@
import React, { useState } from 'react';
import { ChevronUp, ChevronDown } from 'lucide-react';
import { AIStrategyPanel } from './AIStrategyPanel';
export const AIStrategyControl: React.FC = () => {
const [isCollapsed, setIsCollapsed] = useState(() => {
return localStorage.getItem('tetris-strategies-collapsed') === 'true';
});
const toggleCollapse = () => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('tetris-strategies-collapsed', String(newState));
};
return (
<div className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 transition-all duration-200 mt-4">
<div
className="flex items-center justify-between cursor-pointer"
onClick={toggleCollapse}
>
<h2 className="text-xl font-bold text-pink-500">AI Strategies</h2>
<button className="text-purple-400 hover:text-pink-500 transition-colors p-1">
{isCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</button>
</div>
{!isCollapsed && (
<div className="mt-4 animate-in slide-in-from-top-2 duration-200">
<AIStrategyPanel />
</div>
)}
</div>
);
};

View File

@ -0,0 +1,109 @@
import React, { useState, useEffect } from 'react';
import { StrategyManager, AIStrategy } from './aiStrategies';
import { Switch } from '@/components/ui/switch';
import { Brain, Save, ShieldAlert, FileText } from 'lucide-react';
export const AIStrategyPanel: React.FC = () => {
const [strategies, setStrategies] = useState<AIStrategy[]>([]);
const manager = StrategyManager.getInstance();
useEffect(() => {
setStrategies(manager.getStrategies());
}, []);
const toggleStrategy = (id: string, enabled: boolean) => {
manager.toggleStrategy(id, enabled);
// Force update local state to reflect change immediately
setStrategies(manager.getStrategies().map(s =>
s.id === id ? { ...s, isEnabled: enabled } : s
));
};
const toggleLogs = (id: string, enabled: boolean) => {
manager.toggleLogs(id, enabled);
setStrategies(manager.getStrategies().map(s =>
s.id === id ? { ...s, logsEnabled: enabled } : s
));
};
const getIcon = (id: string) => {
switch (id) {
case 'hall-of-fame': return <Save className="w-4 h-4 text-yellow-400" />;
case 'auto-revert': return <ShieldAlert className="w-4 h-4 text-green-400" />;
default: return <Brain className="w-4 h-4 text-purple-400" />;
}
};
return (
<div className="space-y-4">
<h3 className="text-sm font-semibold text-purple-400 flex items-center gap-2">
<Brain className="w-4 h-4" />
Active Learning Strategies
</h3>
<div className="grid grid-cols-1 gap-2">
{strategies.map((strategy) => (
<div
key={strategy.id}
className={`p-3 rounded-lg border flex items-start gap-3 transition-colors ${strategy.isEnabled
? 'bg-purple-500/10 border-purple-500/40'
: 'bg-gray-900/40 border-gray-800'
}`}
>
<div className="mt-1">
{getIcon(strategy.id)}
</div>
<div className="flex-1">
<div className="flex items-center justify-between mb-1">
<span className={`text-sm font-medium ${strategy.isEnabled ? 'text-white' : 'text-gray-400'}`}>
{strategy.name}
</span>
<div className="flex items-center gap-2">
<button
onClick={() => toggleLogs(strategy.id, !strategy.logsEnabled)}
className={`p-1.5 rounded-md transition-colors ${strategy.logsEnabled
? 'bg-blue-500/20 text-blue-400 hover:bg-blue-500/30'
: 'bg-gray-800 text-gray-500 hover:text-gray-400'
}`}
title={strategy.logsEnabled ? "Disable Logs" : "Enable Logs"}
>
<FileText className="w-3.5 h-3.5" />
</button>
<Switch
checked={strategy.isEnabled}
onCheckedChange={(checked) => toggleStrategy(strategy.id, checked)}
/>
</div>
</div>
<p className="text-xs text-gray-400 leading-relaxed">
{strategy.description}
</p>
</div>
</div>
))}
</div>
<div className="p-3 rounded-lg border bg-gray-900/40 border-gray-800">
<div className="flex items-center justify-between mb-2">
<span className="text-sm font-medium text-gray-300">Training Sample Size</span>
<input
type="number"
min="1"
max="100"
className="w-16 bg-gray-800 border border-gray-700 rounded px-2 py-1 text-sm text-white"
defaultValue={localStorage.getItem('tetris-training-sample-size') || '3'}
onChange={(e) => {
const val = parseInt(e.target.value);
if (val > 0) {
localStorage.setItem('tetris-training-sample-size', val.toString());
}
}}
/>
</div>
<p className="text-xs text-gray-500">
AI will train only after every N games to build a batch. Default: 3.
</p>
</div>
</div>
);
};

View File

@ -0,0 +1,477 @@
import React, { useRef, useState, useEffect } from 'react';
import { AIWeights, DEFAULT_WEIGHTS, AdaptiveScaling, AdaptiveScalingMode, DEFAULT_ADAPTIVE_SCALING } from './aiPlayer';
import { HelpCircle, Lock, Unlock, ChevronDown, ChevronUp } from 'lucide-react';
import { setManualOverride, clearManualOverride, hasManualOverride, trainOnManualAdjustment, getNeuralNetwork, extractBoardFeatures } from './neuralAI';
interface AIWeightsPanelProps {
debugInfo: any;
aiWeights: AIWeights;
gameHistory: any[];
weightChanges: any[];
gameCounter: number;
onWeightsChange: (weights: AIWeights) => void;
onHelpOpen: () => void;
onResetAI: () => void;
adaptiveScaling?: AdaptiveScaling;
onAdaptiveScalingChange?: (scaling: AdaptiveScaling) => void;
}
export const AIWeightsPanel: React.FC<AIWeightsPanelProps> = ({
debugInfo,
aiWeights,
gameHistory,
weightChanges,
gameCounter,
onWeightsChange,
onHelpOpen,
onResetAI,
adaptiveScaling = DEFAULT_ADAPTIVE_SCALING,
onAdaptiveScalingChange,
}) => {
const aiMoveRef = useRef<any>(null);
const lastProcessedPieceRef = useRef<string | null>(null);
const [isManualOverride, setIsManualOverride] = useState(() => hasManualOverride());
const [showAdaptiveScaling, setShowAdaptiveScaling] = useState(false);
const [activeMode, setActiveMode] = useState<'normal' | 'defense' | 'crisis'>('normal');
const [isCollapsed, setIsCollapsed] = useState(() => {
return localStorage.getItem('tetris-weights-panel-collapsed') === 'true';
});
const toggleCollapse = () => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('tetris-weights-panel-collapsed', String(newState));
};
useEffect(() => {
setIsManualOverride(hasManualOverride());
}, [aiWeights]);
const handleWeightChange = (key: keyof AIWeights, value: number) => {
const newWeights = { ...aiWeights, [key]: value };
onWeightsChange(newWeights);
// If manual override is enabled, save to override
if (isManualOverride) {
setManualOverride(newWeights);
} else {
localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights));
}
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
onResetAI();
};
// ... (rest of the functions remain the same) ...
const toggleManualOverride = () => {
if (isManualOverride) {
// Disable override
clearManualOverride();
setIsManualOverride(false);
console.log('[UI] Manual override disabled');
} else {
// Enable override with current weights
setManualOverride(aiWeights);
setIsManualOverride(true);
console.log('[UI] Manual override enabled');
}
onResetAI();
};
const trainOnCurrentWeights = () => {
if (!debugInfo) return;
const boardFeatures = extractBoardFeatures(debugInfo);
const network = getNeuralNetwork();
trainOnManualAdjustment(network, boardFeatures, aiWeights, 1.0);
alert('✅ Neural network trained on current weights!');
};
const handleExport = () => {
const exportData = {
version: '1.0',
timestamp: new Date().toISOString(),
aiWeights,
gameHistory,
weightChanges,
gameCounter,
};
const dataStr = JSON.stringify(exportData, null, 2);
const dataBlob = new Blob([dataStr], { type: 'application/json' });
const url = URL.createObjectURL(dataBlob);
const link = document.createElement('a');
link.href = url;
link.download = `tetris-ai-data-${Date.now()}.json`;
link.click();
URL.revokeObjectURL(url);
};
const handleImport = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = (e.target as HTMLInputElement).files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importData = JSON.parse(event.target?.result as string);
if (importData.aiWeights) {
onWeightsChange(importData.aiWeights);
localStorage.setItem('tetris-ai-weights', JSON.stringify(importData.aiWeights));
}
if (importData.gameHistory) {
localStorage.setItem('tetris-game-history', JSON.stringify(importData.gameHistory));
}
if (importData.weightChanges) {
localStorage.setItem('tetris-weight-changes', JSON.stringify(importData.weightChanges));
}
if (importData.gameCounter !== undefined) {
localStorage.setItem('tetris-game-counter', importData.gameCounter.toString());
}
onResetAI();
alert('Data imported successfully!');
} catch (error) {
alert('Failed to import data. Please check the file format.');
}
};
reader.readAsText(file);
};
input.click();
};
const handleReset = () => {
onWeightsChange(DEFAULT_WEIGHTS);
localStorage.setItem('tetris-ai-weights', JSON.stringify(DEFAULT_WEIGHTS));
onResetAI();
};
const weightFields = [
{ key: 'lineCleared', label: 'Lines Cleared', color: 'text-green-400', sign: '+' },
{ key: 'contact', label: 'Contact', color: 'text-blue-400', sign: '+' },
{ key: 'holesCreated', label: 'Holes Created', color: 'text-red-600', sign: '-' },
{ key: 'overhangsCreated', label: 'Overhangs Created', color: 'text-pink-600', sign: '-' },
{ key: 'overhangsFilled', label: 'Overhangs Filled', color: 'text-emerald-400', sign: '+' },
{ key: 'heightAdded', label: 'Height Added', color: 'text-amber-400', sign: '-' },
{ key: 'wellDepthSquared', label: 'Well Depth²', color: 'text-rose-500', sign: '-' },
{ key: 'bumpiness', label: 'Bumpiness', color: 'text-violet-400', sign: '-' },
{ key: 'avgHeight', label: 'Avg Height', color: 'text-yellow-400', sign: '-' },
{ key: 'rowTransitions', label: 'Row Transitions', color: 'text-cyan-400', sign: '-' },
] as const;
// Calculate distributions
const rewards = [
{ key: 'lineCleared', value: aiWeights.lineCleared, label: 'Lines', color: '#10b981' },
{ key: 'contact', value: aiWeights.contact, label: 'Contact', color: '#34d399' },
{ key: 'overhangsFilled', value: aiWeights.overhangsFilled, label: 'Fill', color: '#6ee7b7' },
];
const penalties = [
{ key: 'holesCreated', value: aiWeights.holesCreated, label: 'Holes', color: '#ef4444' },
{ key: 'overhangsCreated', value: aiWeights.overhangsCreated, label: 'Overhangs', color: '#f87171' },
{ key: 'heightAdded', value: aiWeights.heightAdded, label: 'Height', color: '#fca5a5' },
{ key: 'wellDepthSquared', value: aiWeights.wellDepthSquared, label: 'Wells', color: '#fb923c' },
{ key: 'bumpiness', value: aiWeights.bumpiness, label: 'Bumpiness', color: '#fdba74' },
{ key: 'avgHeight', value: aiWeights.avgHeight, label: 'Avg Height', color: '#fed7aa' },
{ key: 'rowTransitions', value: aiWeights.rowTransitions, label: 'Row Trans', color: '#fef3c7' },
];
const totalRewards = rewards.reduce((sum, r) => sum + r.value, 0);
const totalPenalties = penalties.reduce((sum, p) => sum + p.value, 0);
return (
<div className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 transition-all duration-200">
<div
className="flex items-center justify-between cursor-pointer mb-3"
onClick={toggleCollapse}
>
<div className="flex items-center gap-2">
<h5 className="text-lg font-bold text-cyan-400">Weights</h5>
<button
onClick={(e) => {
e.stopPropagation();
onHelpOpen();
}}
className="text-gray-400 hover:text-cyan-400 transition-colors"
title="Help"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<button className="text-purple-400 hover:text-cyan-400 transition-colors p-1">
{isCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</button>
</div>
{!isCollapsed && (
<div className="animate-in slide-in-from-top-2 duration-200">
<div className="flex items-center gap-1.5 mb-3 flex-wrap">
<button
onClick={handleExport}
className="px-2.5 py-1 text-xs font-medium bg-gradient-to-br from-green-500/20 to-emerald-500/20 hover:from-green-500/30 hover:to-emerald-500/30 backdrop-blur-sm border border-green-400/40 hover:border-green-400/60 rounded-lg text-green-300 hover:text-green-200 transition-all duration-200 shadow-lg hover:shadow-green-500/20"
title="Export weights and history"
>
Export
</button>
<button
onClick={handleImport}
className="px-2.5 py-1 text-xs font-medium bg-gradient-to-br from-blue-500/20 to-cyan-500/20 hover:from-blue-500/30 hover:to-cyan-500/30 backdrop-blur-sm border border-blue-400/40 hover:border-blue-400/60 rounded-lg text-blue-300 hover:text-blue-200 transition-all duration-200 shadow-lg hover:shadow-blue-500/20"
title="Import weights and history"
>
Import
</button>
<button
onClick={handleReset}
className="px-2.5 py-1 text-xs font-medium bg-gradient-to-br from-purple-500/20 to-pink-500/20 hover:from-purple-500/30 hover:to-pink-500/30 backdrop-blur-sm border border-purple-400/40 hover:border-purple-400/60 rounded-lg text-purple-300 hover:text-purple-200 transition-all duration-200 shadow-lg hover:shadow-purple-500/20"
title="Reset to default weights"
>
Reset
</button>
<button
onClick={toggleManualOverride}
className={`px-2.5 py-1 text-xs font-medium backdrop-blur-sm rounded-lg transition-all duration-200 shadow-lg flex items-center gap-1 ${isManualOverride
? 'bg-gradient-to-br from-amber-500/20 to-orange-500/20 hover:from-amber-500/30 hover:to-orange-500/30 border border-amber-400/40 hover:border-amber-400/60 text-amber-300 hover:text-amber-200 hover:shadow-amber-500/20'
: 'bg-gradient-to-br from-gray-500/20 to-gray-600/20 hover:from-gray-500/30 hover:to-gray-600/30 border border-gray-400/40 hover:border-gray-400/60 text-gray-300 hover:text-gray-200 hover:shadow-gray-500/20'
}`}
title={isManualOverride ? 'Disable manual override (use neural network)' : 'Enable manual override (lock current weights)'}
>
{isManualOverride ? <Lock className="w-3 h-3" /> : <Unlock className="w-3 h-3" />}
{isManualOverride ? 'Locked' : 'Auto'}
</button>
{isManualOverride && (
<button
onClick={trainOnCurrentWeights}
className="px-2.5 py-1 text-xs font-medium bg-gradient-to-br from-cyan-500/20 to-blue-500/20 hover:from-cyan-500/30 hover:to-blue-500/30 backdrop-blur-sm border border-cyan-400/40 hover:border-cyan-400/60 rounded-lg text-cyan-300 hover:text-cyan-200 transition-all duration-200 shadow-lg hover:shadow-cyan-500/20"
title="Train neural network to learn these weights"
>
Train NN
</button>
)}
</div>
{/* Weight Distribution Visualization */}
<div className="mb-3">
<h3 className="text-xs font-semibold text-purple-400 mb-2">Weight Distribution</h3>
<div className="space-y-1.5">
{/* Rewards Bar */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-green-400 font-semibold w-20">Rewards</span>
<span className="text-xs text-gray-400">{totalRewards.toFixed(0)}</span>
</div>
<div className="flex h-6 rounded overflow-hidden bg-gray-800/50">
{rewards.map((r) => {
const percentage = totalRewards > 0 ? (r.value / totalRewards) * 100 : 0;
return percentage > 0 ? (
<div
key={r.key}
className="flex items-center justify-center text-xs font-semibold text-white/90 transition-all duration-300"
style={{
width: `${percentage}%`,
backgroundColor: r.color,
}}
title={`${r.label}: ${r.value} (${percentage.toFixed(1)}%)`}
>
{percentage > 15 && r.label}
</div>
) : null;
})}
</div>
</div>
{/* Penalties Bar */}
<div>
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-red-400 font-semibold w-20">Penalties</span>
<span className="text-xs text-gray-400">{totalPenalties.toFixed(0)}</span>
</div>
<div className="flex h-6 rounded overflow-hidden bg-gray-800/50">
{penalties.map((p) => {
const percentage = totalPenalties > 0 ? (p.value / totalPenalties) * 100 : 0;
return percentage > 0 ? (
<div
key={p.key}
className="flex items-center justify-center text-xs font-semibold text-white/90 transition-all duration-300"
style={{
width: `${percentage}%`,
backgroundColor: p.color,
}}
title={`${p.label}: ${p.value} (${percentage.toFixed(1)}%)`}
>
{percentage > 8 && (percentage > 15 ? p.label : p.label.slice(0, 3))}
</div>
) : null;
})}
</div>
</div>
{/* Overall Balance */}
<div className="pt-0.5">
<div className="flex items-center gap-2 mb-1">
<span className="text-xs text-purple-400 font-semibold w-20">Balance</span>
<span className="text-xs text-gray-400">
{totalRewards > 0 && totalPenalties > 0
? `${((totalRewards / (totalRewards + totalPenalties)) * 100).toFixed(0)}% / ${((totalPenalties / (totalRewards + totalPenalties)) * 100).toFixed(0)}%`
: 'N/A'}
</span>
</div>
<div className="flex h-4 rounded overflow-hidden bg-gray-800/50">
<div
className="bg-gradient-to-r from-green-500 to-green-400 transition-all duration-300"
style={{
width: `${totalRewards > 0 && totalPenalties > 0 ? (totalRewards / (totalRewards + totalPenalties)) * 100 : 50}%`,
}}
/>
<div
className="bg-gradient-to-r from-red-400 to-red-500 transition-all duration-300"
style={{
width: `${totalRewards > 0 && totalPenalties > 0 ? (totalPenalties / (totalRewards + totalPenalties)) * 100 : 50}%`,
}}
/>
</div>
</div>
</div>
</div>
{/* Adaptive Scaling Section */}
<div className="mb-3">
<button
onClick={() => setShowAdaptiveScaling(!showAdaptiveScaling)}
className="w-full flex items-center justify-between text-xs font-semibold text-purple-400 hover:text-purple-300 transition-colors mb-2"
>
<span> Adaptive Scaling</span>
{showAdaptiveScaling ? <ChevronUp className="w-3 h-3" /> : <ChevronDown className="w-3 h-3" />}
</button>
{showAdaptiveScaling && onAdaptiveScalingChange && (
<div className="space-y-2 text-xs bg-purple-500/5 border border-purple-500/20 rounded-lg p-2">
{/* Slim Tab Toggles */}
<div className="flex gap-1 bg-black/40 rounded p-0.5">
{[
{ key: 'normal' as const, label: 'Normal', desc: '0-9', color: 'text-green-400' },
{ key: 'defense' as const, label: 'Defense', desc: '9-12', color: 'text-yellow-400' },
{ key: 'crisis' as const, label: 'Crisis', desc: '12+', color: 'text-red-400' },
].map(({ key, label, desc, color }) => (
<button
key={key}
onClick={() => setActiveMode(key)}
className={`flex-1 px-2 py-1 rounded text-xs font-semibold transition-all ${activeMode === key
? `bg-purple-500/30 ${color} border border-purple-400/50`
: 'text-gray-400 hover:text-gray-300'
}`}
>
<div>{label}</div>
<div className="text-[10px] opacity-70">h:{desc}</div>
</button>
))}
</div>
{/* Mode Description */}
<p className="text-gray-400 text-[10px] italic">
{activeMode === 'normal' && '📊 Base strategy - focus on score'}
{activeMode === 'defense' && '⚠️ Moderate caution - balance score & safety'}
{activeMode === 'crisis' && '🚨 Survival mode - avoid death at all costs'}
</p>
{/* Multiplier Controls */}
<div className="space-y-1">
{[
{ key: 'lineCleared', label: 'Lines ↓', type: 'decrease' },
{ key: 'contact', label: 'Contact ↓', type: 'decrease' },
{ key: 'overhangsFilled', label: 'Fill ↓', type: 'decrease' },
{ key: 'holesCreated', label: 'Holes ↑', type: 'increase' },
{ key: 'overhangsCreated', label: 'Overhangs ↑', type: 'increase' },
{ key: 'heightAdded', label: 'Height ↑', type: 'increase' },
{ key: 'avgHeight', label: 'Avg Height ↑', type: 'increase' },
{ key: 'wellDepthSquared', label: 'Wells ↓', type: 'decrease' },
{ key: 'bumpiness', label: 'Bumpiness ↑', type: 'increase' },
{ key: 'rowTransitions', label: 'Row Trans ↑', type: 'increase' },
].map(({ key, label, type }) => {
const currentMode = adaptiveScaling[activeMode];
return (
<div key={key} className="flex justify-between gap-2 items-center">
<span className={`flex-shrink-0 text-xs ${type === 'decrease' ? 'text-blue-400' : 'text-orange-400'}`}>
{label}:
</span>
<input
type="number"
step="0.1"
value={currentMode[key as keyof AdaptiveScalingMode].toFixed(1)}
onChange={(e) => {
const newMode = { ...currentMode, [key]: parseFloat(e.target.value) || 0 };
const newScaling = { ...adaptiveScaling, [activeMode]: newMode };
onAdaptiveScalingChange(newScaling);
}}
className="text-purple-300 text-right bg-black/40 border border-purple-500/30 rounded px-2 py-0.5 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/50 w-16 text-xs"
/>
<span className="text-gray-500 text-xs w-12">
{type === 'decrease'
? `-${(currentMode[key as keyof AdaptiveScalingMode] * 100).toFixed(0)}%`
: `+${(currentMode[key as keyof AdaptiveScalingMode] * 100).toFixed(0)}%`
}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
<div className="space-y-1.5 text-xs text-gray-300 font-mono">
{weightFields.map(({ key, label, color, sign }) => (
<div key={key} className="flex justify-between gap-2 items-center">
<span className="flex-shrink-0 text-xs">
<span className={sign === '+' ? 'text-green-400' : 'text-red-400'}>{sign}</span>
{' '}{label}:
</span>
<input
key={`${key}-${aiWeights[key as keyof AIWeights]}`}
type="number"
defaultValue={Math.round(aiWeights[key as keyof AIWeights] ?? 0)}
onBlur={(e) => handleWeightChange(key as keyof AIWeights, parseFloat(e.target.value) || 0)}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
const value = parseFloat((e.target as HTMLInputElement).value) || 0;
handleWeightChange(key as keyof AIWeights, value);
(e.target as HTMLInputElement).blur();
} else if (e.key === 'ArrowUp') {
e.preventDefault();
const currentValue = parseFloat((e.target as HTMLInputElement).value) || 0;
const step = key === 'lineCleared' ? 100 : key.includes('Height') ? 5 : 10;
const newValue = Math.round(currentValue + step);
(e.target as HTMLInputElement).value = newValue.toString();
handleWeightChange(key as keyof AIWeights, newValue);
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const currentValue = parseFloat((e.target as HTMLInputElement).value) || 0;
const step = key === 'lineCleared' ? 100 : key.includes('Height') ? 5 : 10;
const newValue = Math.max(0, Math.round(currentValue - step));
(e.target as HTMLInputElement).value = newValue.toString();
handleWeightChange(key as keyof AIWeights, newValue);
}
}}
className={`${color} text-right bg-black/40 border border-purple-500/30 rounded px-2 py-0.5 focus:border-purple-500 focus:outline-none focus:ring-1 focus:ring-purple-500/50 w-28 text-sm`}
/>
</div>
))}
<div className="border-t border-gray-700 pt-2 mt-2 flex justify-between gap-4 font-bold">
<span className="flex-shrink-0">Total Score:</span>
<span className="text-purple-400 text-right">{debugInfo.totalScore}</span>
</div>
</div>
</div>
)}
</div>
);
};

View File

@ -0,0 +1,251 @@
import React, { useState, useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { ChevronUp, ChevronDown } from 'lucide-react';
interface ControlsPanelProps {
gameStarted: boolean;
gameOver: boolean;
isAutoPlay: boolean;
isMaxSpeed: boolean;
aiMode: 'standard' | 'neural';
startLevel: number;
onAutoPlayChange: (checked: boolean) => void;
onMaxSpeedChange: (checked: boolean) => void;
onAiModeChange: (mode: 'standard' | 'neural') => void;
onStartLevelChange: (level: number) => void;
onNewGame: () => void;
randomizerMode: 'tgm3' | 'classic';
onRandomizerModeChange: (mode: 'tgm3' | 'classic') => void;
}
export const ControlsPanel: React.FC<ControlsPanelProps> = ({
gameStarted,
gameOver,
isAutoPlay,
isMaxSpeed,
aiMode,
startLevel,
onAutoPlayChange,
onMaxSpeedChange,
onAiModeChange,
onStartLevelChange,
onNewGame,
randomizerMode,
onRandomizerModeChange,
}) => {
const [isCollapsed, setIsCollapsed] = useState(() => {
return localStorage.getItem('tetris-controls-collapsed') === 'true';
});
const toggleCollapse = () => {
const newState = !isCollapsed;
setIsCollapsed(newState);
localStorage.setItem('tetris-controls-collapsed', String(newState));
};
const [localStartLevel, setLocalStartLevel] = useState(startLevel.toString());
useEffect(() => {
setLocalStartLevel(startLevel.toString());
}, [startLevel]);
const handleLevelSubmit = () => {
const val = parseInt(localStartLevel);
if (!isNaN(val)) {
onStartLevelChange(Math.max(0, val));
} else {
setLocalStartLevel(startLevel.toString());
}
};
return (
<div className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 transition-all duration-200">
<div
className="flex items-center justify-between cursor-pointer"
onClick={toggleCollapse}
>
<h2 className="text-xl font-bold text-cyan-400">Controls</h2>
<div className="flex items-center gap-4">
<div
className="flex items-center gap-2"
onClick={(e) => e.stopPropagation()}
>
<Switch
id="max-speed-header"
checked={isMaxSpeed}
onCheckedChange={onMaxSpeedChange}
/>
<Label htmlFor="max-speed-header" className="text-sm text-yellow-400 font-bold cursor-pointer mr-2">
Max
</Label>
<Switch
id="autoplay-header"
checked={isAutoPlay}
onCheckedChange={onAutoPlayChange}
disabled={!gameStarted || gameOver}
/>
<Label htmlFor="autoplay-header" className="text-sm text-gray-300 cursor-pointer">
Auto Play
</Label>
</div>
<button className="text-purple-400 hover:text-cyan-400 transition-colors p-1">
{isCollapsed ? <ChevronDown size={20} /> : <ChevronUp size={20} />}
</button>
</div>
</div>
{!isCollapsed && (
<div className="space-y-4 mt-4 animate-in slide-in-from-top-2 duration-200">
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2 text-gray-200">RNG Mode:</p>
<div className="flex bg-gray-900/50 rounded-lg p-1 border border-gray-700">
<button
className={`flex-1 py-1.5 px-3 rounded-md text-sm font-medium transition-all ${randomizerMode === 'tgm3'
? 'bg-gradient-to-r from-blue-600 to-cyan-600 text-white shadow-lg'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
onClick={() => onRandomizerModeChange('tgm3')}
>
Balanced (TGM3)
</button>
<button
className={`flex-1 py-1.5 px-3 rounded-md text-sm font-medium transition-all ${randomizerMode === 'classic'
? 'bg-gradient-to-r from-purple-600 to-pink-600 text-white shadow-lg'
: 'text-gray-400 hover:text-gray-200 hover:bg-white/5'
}`}
onClick={() => onRandomizerModeChange('classic')}
>
Classic (History 3)
</button>
</div>
</div>
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2 text-gray-200">AI Mode:</p>
<div className="flex gap-2">
<Button
onClick={() => onAiModeChange('standard')}
variant={activeStyle(aiMode === 'standard')}
size="sm"
className={activeClass(aiMode === 'standard', 'purple')}
>
Standard
</Button>
<Button
onClick={() => onAiModeChange('neural')}
variant={activeStyle(aiMode === 'neural')}
size="sm"
className={activeClass(aiMode === 'neural', 'cyan')}
>
Neural Net
</Button>
</div>
<p className="text-xs text-gray-400 mt-2">
{aiMode === 'neural' ? '🧠 Learning from past games' : '⚙️ Using manual weights'}
</p>
</div>
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2 text-gray-200">Start Level:</p>
<div className="flex items-center gap-3 mb-2">
<input
type="range"
min="0"
max="100"
value={startLevel}
onChange={(e) => onStartLevelChange(parseInt(e.target.value))}
className="flex-1 h-2 bg-gray-700 rounded-lg appearance-none cursor-pointer accent-purple-500"
/>
<input
type="number"
min="0"
max="10000"
value={localStartLevel}
onChange={(e) => setLocalStartLevel(e.target.value)}
onBlur={handleLevelSubmit}
onKeyDown={(e) => {
if (e.key === 'Enter') {
handleLevelSubmit();
(e.target as HTMLInputElement).blur();
}
}}
className="w-12 bg-gray-800 text-center font-bold text-purple-400 border border-gray-600 rounded text-sm focus:border-purple-500 focus:outline-none"
/>
</div>
<div className="flex gap-2">
{[20, 50, 100].map((lvl, idx) => (
<Button
key={lvl}
onClick={() => onStartLevelChange(lvl)}
variant="outline"
size="sm"
className="flex-1 text-xs border-gray-600 hover:bg-gray-700"
>
{['Slow', 'Med', 'Fast'][idx]}
</Button>
))}
</div>
</div>
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2 text-gray-200">Keyboard:</p>
<ul className="space-y-1 text-xs text-gray-300">
<li> Move (stops auto)</li>
<li> Rotate (stops auto)</li>
<li>Space: Hard Drop (stops auto)</li>
<li>P: Pause</li>
</ul>
</div>
{gameStarted && (
<Button
onClick={onNewGame}
variant="outline"
className="border-purple-500/50 hover:bg-purple-500/20 mt-4 w-full"
>
New Game
</Button>
)}
<Button
onClick={() => {
if (confirm('⚠️ This will clear ALL saved data:\n\n• AI weights\n• Neural network\n• Game history\n• Weight changes\n• Start level\n\nAre you sure?')) {
// Clear all Tetris-related localStorage
localStorage.removeItem('tetris-ai-weights');
localStorage.removeItem('tetris-neural-network');
localStorage.removeItem('tetris-neural-network-version');
localStorage.removeItem('tetris-game-history');
localStorage.removeItem('tetris-weight-changes');
localStorage.removeItem('tetris-game-counter');
localStorage.removeItem('tetris-start-level');
localStorage.removeItem('tetris-hall-of-fame');
localStorage.removeItem('tetris-neural-network-best');
localStorage.removeItem('tetris-best-performance');
localStorage.removeItem('tetris-ai-strategies-config');
// Reload page to reinitialize
window.location.reload();
}
}}
variant="outline"
className="border-red-500/50 hover:bg-red-500/20 text-red-400 hover:text-red-300 mt-2 w-full"
>
🗑 Reset All Data
</Button>
</div>
)}
</div>
);
};
// Helper for button styles to keep JSX clean
const activeStyle = (isActive: boolean) => isActive ? 'default' : 'outline';
const activeClass = (isActive: boolean, color: 'purple' | 'cyan') => {
if (color === 'purple') {
return isActive ? 'bg-purple-600 hover:bg-purple-700' : 'border-purple-500/50 hover:bg-purple-500/20';
}
return isActive ? 'bg-cyan-600 hover:bg-cyan-700' : 'border-cyan-500/50 hover:bg-cyan-500/20';
};

View File

@ -24,20 +24,24 @@ export const LearningLog: React.FC<LearningLogProps> = ({ changes }) => {
);
}
const calculateWeightDelta = (oldVal: number, newVal: number): { delta: number; percent: number } => {
const calculateWeightDelta = (oldVal: number | undefined, newVal: number | undefined): { delta: number; percent: number } | null => {
if (oldVal === undefined || newVal === undefined) return null;
const delta = newVal - oldVal;
const percent = oldVal !== 0 ? (delta / oldVal) * 100 : 0;
return { delta, percent };
};
const getSignificantChanges = (change: WeightChange) => {
const keys = Object.keys(change.oldWeights) as (keyof AIWeights)[];
const keys = Object.keys(change.newWeights) as (keyof AIWeights)[];
const significant = keys
.map(key => {
const { delta, percent } = calculateWeightDelta(change.oldWeights[key], change.newWeights[key]);
return { key, delta, percent, newValue: change.newWeights[key] };
const result = calculateWeightDelta(change.oldWeights[key], change.newWeights[key]);
if (!result) return null;
const newValue = change.newWeights[key];
if (newValue === undefined || newValue === null) return null; // Skip if new value is missing
return { key, delta: result.delta, percent: result.percent, newValue };
})
.filter(item => Math.abs(item.percent) > 5) // Only show changes > 5%
.filter((item): item is NonNullable<typeof item> => item !== null) // Filter out null values
.sort((a, b) => Math.abs(b.percent) - Math.abs(a.percent))
.slice(0, 3); // Top 3 changes
@ -75,7 +79,7 @@ export const LearningLog: React.FC<LearningLogProps> = ({ changes }) => {
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300">{item.key}:</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">{item.newValue.toFixed(0)}</span>
<span className="text-gray-400">{(item.newValue ?? 0).toFixed(0)}</span>
<span className={`font-semibold ${item.percent > 0 ? 'text-green-400' : 'text-red-400'}`}>
{item.percent > 0 ? '+' : ''}{item.percent.toFixed(1)}%
</span>
@ -84,7 +88,7 @@ export const LearningLog: React.FC<LearningLogProps> = ({ changes }) => {
))}
</div>
) : (
<p className="text-xs text-gray-500">Minor adjustments (&lt;5% change)</p>
<p className="text-xs text-gray-500">No significant changes</p>
)}
</div>
);

View File

@ -23,19 +23,31 @@ export const NeuralNetworkVisualizer: React.FC<NeuralNetworkVisualizerProps> = (
value: number;
} | null>(null);
// Neuron names for input and output layers
// Neuron names for input and output layers (10 weights)
const inputNames = [
'Lines Cleared', 'Contact', 'Holes', 'Holes Created',
'Overhangs', 'Overhangs Created', 'Overhangs Filled',
'Height Added', 'Wells', 'Well Depth²',
'Bumpiness', 'Max Height', 'Avg Height'
'Lines Cleared',
'Contact',
'Holes Created',
'Overhangs Created',
'Overhangs Filled',
'Height Added',
'Well Depth²',
'Bumpiness',
'Avg Height',
'Row Transitions'
];
const outputNames = [
'lineCleared', 'contact', 'holes', 'holesCreated',
'overhangs', 'overhangsCreated', 'overhangsFilled',
'heightAdded', 'wells', 'wellDepthSquared',
'bumpiness', 'maxHeight', 'avgHeight'
'lineCleared',
'contact',
'holesCreated',
'overhangsCreated',
'overhangsFilled',
'heightAdded',
'wellDepthSquared',
'bumpiness',
'avgHeight',
'rowTransitions'
];
useEffect(() => {

View File

@ -0,0 +1,50 @@
import React from 'react';
import { Tetromino } from './types';
import { getColorForCell } from './gameLogic';
interface NextPieceDisplayProps {
nextPiece: Tetromino | null;
}
export const NextPieceDisplay: React.FC<NextPieceDisplayProps> = ({ nextPiece }) => {
if (!nextPiece) return null;
// Create a 4x4 grid and center the piece
const grid: number[][] = Array(4).fill(0).map(() => Array(4).fill(0));
const piece = nextPiece;
// Calculate offset to center the piece in the 4x4 grid
const offsetY = Math.floor((4 - piece.shape.length) / 2);
const offsetX = Math.floor((4 - piece.shape[0].length) / 2);
// Place piece in the center of the grid
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
grid[y + offsetY][x + offsetX] = 1;
}
}
}
return (
<div className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 flex-1">
<h2 className="text-xl font-bold text-cyan-400 mb-4">Next</h2>
<div className="flex flex-col items-center gap-0">
{grid.map((row, y) => (
<div key={y} className="flex">
{row.map((cell, x) => (
<div
key={`${y}-${x}`}
className="w-6 h-6 border border-gray-700/20"
style={{
backgroundColor: cell ? nextPiece.color : 'transparent',
boxShadow: cell ? 'inset 0 0 0 1px rgba(255,255,255,0.1)' : 'none',
}}
/>
))}
</div>
))}
</div>
</div>
);
};

View File

@ -23,7 +23,10 @@ export const PerformanceChart: React.FC<PerformanceChartProps> = ({ history }) =
// Take last 20 games for visualization
const recentGames = history.slice(-20);
const maxScore = Math.max(...recentGames.map(g => g.score), 100);
// Calculate adaptive max with 10% padding so bars don't hit the top
const dataMaxScore = Math.max(...recentGames.map(g => g.score), 100);
const maxScore = Math.ceil(dataMaxScore * 1.1);
const maxLevel = Math.max(...recentGames.map(g => g.level), 5);
// Calculate statistics
@ -43,6 +46,13 @@ export const PerformanceChart: React.FC<PerformanceChartProps> = ({ history }) =
const isImproving = trends.length > 1 && trends[trends.length - 1] > trends[0];
// Format number for y-axis labels
const formatYAxis = (value: number): string => {
if (value >= 1000000) return `${(value / 1000000).toFixed(1)}M`;
if (value >= 1000) return `${(value / 1000).toFixed(0)}k`;
return value.toString();
};
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-4">Performance Trend</h3>
@ -75,8 +85,8 @@ export const PerformanceChart: React.FC<PerformanceChartProps> = ({ history }) =
<div className="relative h-48 bg-black/30 rounded-lg p-4 border border-gray-700">
{/* Y-axis labels */}
<div className="absolute left-0 top-0 bottom-0 w-12 flex flex-col justify-between text-xs text-gray-500">
<span>{maxScore >= 1000 ? `${(maxScore / 1000).toFixed(0)}k` : maxScore}</span>
<span>{maxScore >= 2000 ? `${(maxScore / 2000).toFixed(0)}k` : Math.round(maxScore / 2)}</span>
<span>{formatYAxis(maxScore)}</span>
<span>{formatYAxis(maxScore / 2)}</span>
<span>0</span>
</div>
@ -89,39 +99,162 @@ export const PerformanceChart: React.FC<PerformanceChartProps> = ({ history }) =
))}
</div>
{/* Score bars */}
<div className="absolute inset-0 flex items-end justify-around gap-1">
{recentGames.map((game, idx) => {
const heightPercent = Math.max((game.score / maxScore) * 100, 2); // Minimum 2% height
const trendHeight = (trends[idx] / maxScore) * 100;
{/* SVG Line Chart */}
<svg className="absolute inset-0 w-full h-full" preserveAspectRatio="none">
<defs>
{/* Gradient for score line */}
<linearGradient id="scoreGradient" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="rgb(34, 211, 238)" stopOpacity="0.3" />
<stop offset="100%" stopColor="rgb(168, 85, 247)" stopOpacity="0.1" />
</linearGradient>
</defs>
{/* Calculate SVG path for score line */}
{(() => {
const width = 100; // percentage
const height = 100; // percentage
const points = recentGames.map((game, idx) => {
// Prevent division by zero when only 1 game
const x = (idx / Math.max(1, recentGames.length - 1)) * width;
const y = height - (game.score / maxScore) * height;
return { x, y, game, idx };
});
// Create path for score line
const scorePath = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`
).join(' ');
// Create filled area path
const areaPath = `${scorePath} L ${points[points.length - 1].x} ${height} L 0 ${height} Z`;
// Create path for trend line
const trendPath = points.map((p, i) => {
const trendY = height - (trends[i] / maxScore) * height;
return `${i === 0 ? 'M' : 'L'} ${p.x} ${trendY}`;
}).join(' ');
return (
<div key={idx} className="flex-1 flex flex-col items-center justify-end group relative min-w-0">
{/* Trend line marker */}
{trendHeight > 0 && (
<div
className="absolute w-full border-t-2 border-yellow-400/50"
style={{ bottom: `${trendHeight}%` }}
<>
{/* Horizontal reference line for best score */}
{(() => {
const bestY = height - (bestGame.score / maxScore) * height;
return (
<line
x1="0"
y1={`${bestY}%`}
x2="100"
y2={`${bestY}%`}
stroke="rgb(34, 197, 94)"
strokeWidth="0.2"
strokeDasharray="4,4"
opacity="0.3"
vectorEffect="non-scaling-stroke"
/>
);
})()}
{/* Filled area under score line */}
<path
d={areaPath}
fill="url(#scoreGradient)"
vectorEffect="non-scaling-stroke"
/>
{/* Trend line (moving average) */}
<path
d={trendPath}
fill="none"
stroke="rgb(250, 204, 21)"
strokeWidth="0.4"
strokeDasharray="3,2"
opacity="0.7"
vectorEffect="non-scaling-stroke"
/>
{/* Score line (actual scores) */}
<path
d={scorePath}
fill="none"
stroke="rgb(139, 92, 246)"
strokeWidth="0.6"
vectorEffect="non-scaling-stroke"
/>
{/* Data points */}
{points.map((p) => (
<g key={p.idx}>
{/* Highlight best score with larger, glowing marker */}
{p.game.score === bestGame.score && (
<>
{/* Glow effect */}
<circle
cx={`${p.x}%`}
cy={`${p.y}%`}
r="2.5"
fill="rgb(34, 197, 94)"
opacity="0.2"
/>
{/* Main marker */}
<circle
cx={`${p.x}%`}
cy={`${p.y}%`}
r="1.8"
fill="rgb(34, 197, 94)"
stroke="rgb(134, 239, 172)"
strokeWidth="0.4"
opacity="0.95"
/>
</>
)}
{/* Regular point (smaller for non-best scores) */}
{p.game.score !== bestGame.score && (
<circle
cx={`${p.x}%`}
cy={`${p.y}%`}
r="0.7"
fill="rgb(139, 92, 246)"
stroke="rgb(168, 85, 247)"
strokeWidth="0.2"
opacity="0.8"
className="hover:r-2 transition-all cursor-pointer"
/>
)}
</g>
))}
</>
);
})()}
</svg>
{/* Score bar */}
{/* Interactive hover points (HTML overlay) */}
<div className="absolute inset-0">
{recentGames.map((game, idx) => {
// Use the same calculation as SVG points for perfect alignment
const xPercent = (idx / Math.max(1, recentGames.length - 1)) * 100;
const yPercent = 100 - (game.score / maxScore) * 100;
return (
<div
className={`w-full rounded-t transition-all ${game.score === bestGame.score
? 'bg-gradient-to-t from-green-500 to-green-400'
: 'bg-gradient-to-t from-cyan-500 to-purple-500'
} hover:opacity-80 cursor-pointer`}
style={{ height: `${heightPercent}%`, minHeight: '4px' }}
key={idx}
className="absolute group"
style={{
left: `${xPercent}%`,
top: `${yPercent}%`,
transform: 'translate(-50%, -50%)',
}}
>
{/* Invisible hover target - larger circle for easier hovering */}
<div className="w-6 h-6 -m-3 cursor-pointer" />
{/* Tooltip on hover */}
<div className="opacity-0 group-hover:opacity-100 absolute bottom-full mb-2 left-1/2 -translate-x-1/2 bg-black/90 border border-cyan-400/50 rounded px-2 py-1 whitespace-nowrap text-xs pointer-events-none z-10">
<div className="opacity-0 group-hover:opacity-100 absolute top-full left-1/2 -translate-x-1/2 mt-2 bg-black/90 border border-cyan-400/50 rounded px-2 py-1 whitespace-nowrap text-xs pointer-events-none z-10 transition-opacity">
<div className="font-bold text-cyan-400">Game #{history.length - recentGames.length + idx + 1}</div>
<div className="text-gray-300">Score: <span className="text-purple-400 font-bold">{game.score}</span></div>
<div className="text-gray-300">Level: <span className="text-green-400">{game.level}</span></div>
<div className="text-gray-300">Lines: <span className="text-cyan-400">{game.lines}</span></div>
</div>
</div>
</div>
);
})}
</div>

View File

@ -0,0 +1,150 @@
import React, { useMemo } from 'react';
interface GameResult {
score: number;
lines: number;
level: number;
weights: any;
timestamp: number;
}
interface StatsPanelProps {
score: number;
lines: number;
level: number;
debugInfo: any;
gameHistory?: GameResult[];
gameCounter?: number;
}
export const StatsPanel: React.FC<StatsPanelProps> = ({
score,
lines,
level,
debugInfo,
gameHistory = [],
gameCounter = 0,
}) => {
const avgHeight = parseFloat(debugInfo?.avgHeight) || 0;
const heightRatio = avgHeight / 15;
let mode = 'Normal';
let modeColor = 'text-green-400';
let modeIcon = '🎯';
if (heightRatio > 0.8) {
mode = 'Crisis';
modeColor = 'text-red-400';
modeIcon = '🚨';
} else if (heightRatio > 0.6) {
mode = 'Defensive';
modeColor = 'text-yellow-400';
modeIcon = '⚠️';
}
// Calculate performance trends
const trends = useMemo(() => {
if (gameHistory.length < 2) {
return { scoreChange: 0, linesChange: 0, hasData: false, avgScore: 0, maxScore: 0 };
}
// Get last 10 games (excluding current)
const recentGames = gameHistory.slice(-10);
const avgScore = recentGames.reduce((sum, g) => sum + g.score, 0) / recentGames.length;
const avgLines = recentGames.reduce((sum, g) => sum + g.lines, 0) / recentGames.length;
// Get last 5 games for comparison
const olderGames = gameHistory.slice(-15, -5);
const olderAvgScore = olderGames.length > 0
? olderGames.reduce((sum, g) => sum + g.score, 0) / olderGames.length
: avgScore;
const olderAvgLines = olderGames.length > 0
? olderGames.reduce((sum, g) => sum + g.lines, 0) / olderGames.length
: avgLines;
const scoreChange = ((avgScore - olderAvgScore) / (olderAvgScore || 1)) * 100;
const linesChange = ((avgLines - olderAvgLines) / (olderAvgLines || 1)) * 100;
// Calculate min/max scores
const allScores = gameHistory.map(g => g.score);
const minScore = Math.min(...allScores);
const maxScore = Math.max(...allScores);
return {
scoreChange: Math.round(scoreChange),
linesChange: Math.round(linesChange),
avgScore: Math.round(avgScore),
avgLines: Math.round(avgLines),
minScore,
maxScore,
hasData: gameHistory.length >= 5,
};
}, [gameHistory]);
const getTrendIcon = (change: number) => {
if (change > 5) return '📈';
if (change < -5) return '📉';
return '➡️';
};
const getTrendColor = (change: number) => {
if (change > 5) return 'text-green-400';
if (change < -5) return 'text-red-400';
return 'text-gray-400';
};
return (
<div className="bg-black/40 backdrop-blur-sm p-4 rounded-2xl shadow-2xl border border-purple-500/20 w-full h-full flex flex-col justify-center gap-4">
<h2 className="text-xl font-bold text-cyan-400 mb-2 md:hidden">Stats</h2>
{/* Primary Stats */}
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 gap-x-8 gap-y-2 text-gray-200 items-center">
<div className="flex flex-col">
<span className="text-xs text-gray-400 uppercase tracking-wider">Score</span>
<span className="font-bold text-2xl text-purple-400 font-mono">{score.toLocaleString()}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-gray-400 uppercase tracking-wider">Lines</span>
<span className="font-bold text-2xl text-cyan-400 font-mono">{lines}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-gray-400 uppercase tracking-wider">Level</span>
<span className="font-bold text-2xl text-green-400 font-mono">{level}</span>
</div>
<div className="flex flex-col">
<span className="text-xs text-gray-400 uppercase tracking-wider">AI Mode</span>
<span className={`font-bold text-lg ${modeColor} flex items-center gap-2`}>
{modeIcon} {mode}
</span>
</div>
</div>
{/* Secondary Stats Row */}
<div className="grid grid-cols-2 md:grid-cols-4 gap-4 border-t border-purple-500/10 pt-3">
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">Games Played</span>
<span className="font-mono text-sm text-gray-300 font-semibold">{gameCounter.toLocaleString()}</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">Best Score</span>
<span className="font-mono text-sm text-yellow-500 font-semibold">
{trends.hasData ? trends.maxScore.toLocaleString() : '-'}
</span>
</div>
<div className="flex flex-col">
<span className="text-[10px] text-gray-500 uppercase tracking-wider">L10 Avg</span>
<div className="flex items-center gap-2">
<span className={`font-mono text-sm font-semibold ${trends.hasData ? getTrendColor(trends.scoreChange) : 'text-gray-500'}`}>
{trends.hasData ? trends.avgScore.toLocaleString() : '-'}
</span>
{trends.hasData && <span className="text-xs">{getTrendIcon(trends.scoreChange)}</span>}
</div>
</div>
</div>
</div>
);
};

View File

@ -3,6 +3,7 @@ import { GameState, Position, BOARD_WIDTH, BOARD_HEIGHT } from './types';
import {
createEmptyBoard,
getRandomTetromino,
resetRandomizer,
rotateTetromino,
checkCollision,
mergePieceToBoard,
@ -11,16 +12,24 @@ import {
getColorForCell,
getStartPosition,
getDropSpeed,
RandomizerMode,
} from './gameLogic';
import { findBestMove, AIWeights, DEFAULT_WEIGHTS } from './aiPlayer';
import { findBestMove, AIWeights, DEFAULT_WEIGHTS, AdaptiveScaling, DEFAULT_ADAPTIVE_SCALING } from './aiPlayer';
import { Button } from '@/components/ui/button';
import { Switch } from '@/components/ui/switch';
import { Label } from '@/components/ui/label';
import { HelpCircle, X } from 'lucide-react';
import { getNeuralNetwork, saveNeuralNetwork, trainOnHistory, getNeuralWeights, calculateReward, extractBoardFeatures, weightsToOutput } from './neuralAI';
import { X } from 'lucide-react';
import { getNeuralNetwork, saveNeuralNetwork, trainOnHistory, getNeuralWeights, calculateReward, extractBoardFeatures, weightsToOutput, NETWORK_CONFIG, notifyGameEnd } from './neuralAI';
import { NeuralNetworkVisualizer } from './NeuralNetworkVisualizer';
import { LearningLog } from './LearningLog';
import { WeightsHistoryChart } from './WeightsHistoryChart';
import { AIStrategyControl } from './AIStrategyControl';
import { PerformanceChart } from './PerformanceChart';
import { AIWeightsPanel } from './AIWeightsPanel';
import { ControlsPanel } from './ControlsPanel';
import { StatsPanel } from './StatsPanel';
import { NextPieceDisplay } from './NextPieceDisplay';
import { LearningLog } from './LearningLog';
import { TrainingDataModal } from './TrainingDataModal';
import { Database } from 'lucide-react';
import MarkdownRenderer from '@/components/MarkdownRenderer';
// Game history tracking
interface GameResult {
@ -28,6 +37,7 @@ interface GameResult {
lines: number;
level: number;
weights: AIWeights;
boardFeatures: number[]; // Average board state during the game
timestamp: number;
}
@ -40,24 +50,41 @@ interface WeightChange {
newWeights: AIWeights;
}
const saveGameResult = (score: number, lines: number, level: number, weights: AIWeights) => {
const saveGameResult = (score: number, lines: number, level: number, weights: AIWeights, boardFeatures: number[], isAutoTraining: boolean = true) => {
try {
const history: GameResult[] = JSON.parse(localStorage.getItem('tetris-game-history') || '[]');
history.push({
score,
lines,
level,
weights,
boardFeatures,
timestamp: Date.now(),
});
// Keep only last 100 games for training
if (history.length > 100) {
// Keep only last 1000 games for training
if (history.length > 1000) {
history.shift();
}
localStorage.setItem('tetris-game-history', JSON.stringify(history));
// Train neural network on new data
// Notify AI strategies (Hall of Fame, Auto-Revert)
notifyGameEnd({
score,
lines,
level,
weights,
boardFeatures,
timestamp: Date.now(),
}, history);
// Train neural network on new data ONLY if auto-training is enabled
if (isAutoTraining) {
const network = getNeuralNetwork();
trainOnHistory(network);
}
} catch (error) {
console.error("Failed to save game result:", error);
}
};
@ -83,11 +110,9 @@ const Tetris: React.FC = () => {
contacts: 0,
contactScore: 0,
holes: 0,
holesPenalty: 0,
holesCreated: 0,
holesCreatedPenalty: 0,
overhangs: 0,
overhangPenalty: 0,
overhangsCreated: 0,
overhangsCreatedPenalty: 0,
overhangsFilled: 0,
@ -100,17 +125,34 @@ const Tetris: React.FC = () => {
bumpiness: 0,
bumpinessPenalty: 0,
maxHeight: 0,
maxHeightPenalty: 0,
avgHeight: '0.0',
avgHeightPenalty: '0',
rowTransitions: 0,
rowTransitionsPenalty: 0,
totalScore: 0,
});
const [clearedLines, setClearedLines] = useState<number[]>([]);
const [aiWeights, setAiWeights] = useState<AIWeights>(() => {
const stored = localStorage.getItem('tetris-ai-weights');
return stored ? JSON.parse(stored) : DEFAULT_WEIGHTS;
if (stored) {
try {
const parsed = JSON.parse(stored);
// Filter out undefined/null values and merge with defaults
const cleanParsed = Object.fromEntries(
Object.entries(parsed).filter(([_, v]) => v !== undefined && v !== null && !isNaN(v as number))
);
return { ...DEFAULT_WEIGHTS, ...cleanParsed };
} catch {
return DEFAULT_WEIGHTS;
}
}
return DEFAULT_WEIGHTS;
});
const [aiMode, setAiMode] = useState<'standard' | 'neural'>('neural');
const [startLevel, setStartLevel] = useState(() => {
const stored = localStorage.getItem('tetris-start-level');
return stored ? parseInt(stored) : 40;
});
const [neuralNetwork] = useState(() => getNeuralNetwork());
const [currentNNInput, setCurrentNNInput] = useState<number[]>();
const [currentNNOutput, setCurrentNNOutput] = useState<number[]>();
@ -119,6 +161,9 @@ const Tetris: React.FC = () => {
const stored = localStorage.getItem('tetris-weight-changes');
return stored ? JSON.parse(stored) : [];
});
const [isMaxSpeed, setIsMaxSpeed] = useState(() => {
return localStorage.getItem('tetris-max-speed') === 'true';
});
const [gameCounter, setGameCounter] = useState(() => {
const stored = localStorage.getItem('tetris-game-counter');
return stored ? parseInt(stored) : 0;
@ -127,6 +172,16 @@ const Tetris: React.FC = () => {
const stored = localStorage.getItem('tetris-game-history');
return stored ? JSON.parse(stored) : [];
});
const [adaptiveScaling, setAdaptiveScaling] = useState<AdaptiveScaling>(() => {
const stored = localStorage.getItem('tetris-adaptive-scaling');
return stored ? JSON.parse(stored) : DEFAULT_ADAPTIVE_SCALING;
});
const [randomizerMode, setRandomizerMode] = useState<RandomizerMode>(() => {
return (localStorage.getItem('tetris-randomizer-mode') as RandomizerMode) || 'tgm3';
});
const [activeTab, setActiveTab] = useState<'game' | 'manual' | 'log'>('game');
const [manualContent, setManualContent] = useState<string>('');
const gameLoopRef = useRef<number | null>(null);
const lastDropTimeRef = useRef<number>(0);
const aiMoveRef = useRef<any>(null);
@ -134,12 +189,40 @@ const Tetris: React.FC = () => {
const aiStateRef = useRef<'idle' | 'calculating' | 'executing' | 'dropping'>('idle');
const aiStateStartTimeRef = useRef<number>(0);
const inactivityTimerRef = useRef<number | null>(null);
const boardFeaturesAccumulator = useRef<number[][]>([]); // Accumulate board features during game
// Load manual content
useEffect(() => {
fetch('/tetris.md')
.then(res => res.text())
.then(text => setManualContent(text))
.catch(err => console.error('Failed to load manual:', err));
}, []);
const handleMaxSpeedChange = (checked: boolean) => {
setIsMaxSpeed(checked);
localStorage.setItem('tetris-max-speed', String(checked));
};
const handleRandomizerModeChange = (mode: RandomizerMode) => {
setRandomizerMode(mode);
localStorage.setItem('tetris-randomizer-mode', mode);
// Reset randomizers to clear history/generators when switching modes
resetRandomizer();
};
const startGame = useCallback(() => {
// Save previous game result if it was a completed game
if (gameStarted && gameState.gameOver) {
saveGameResult(gameState.score, gameState.lines, gameState.level, aiWeights);
// Calculate average board features from accumulator
const avgBoardFeatures = boardFeaturesAccumulator.current.length > 0
? boardFeaturesAccumulator.current[0].map((_, i) =>
boardFeaturesAccumulator.current.reduce((sum, features) => sum + features[i], 0) /
boardFeaturesAccumulator.current.length
)
: Array(10).fill(0.5); // Fallback if no features collected
saveGameResult(gameState.score, gameState.lines, gameState.level, aiWeights, avgBoardFeatures);
// Update game history state
const newResult: GameResult = {
@ -147,15 +230,19 @@ const Tetris: React.FC = () => {
lines: gameState.lines,
level: gameState.level,
weights: aiWeights,
boardFeatures: avgBoardFeatures,
timestamp: Date.now(),
};
setGameHistory(prev => {
const updated = [...prev, newResult];
// Keep only last 100 games
return updated.length > 100 ? updated.slice(-100) : updated;
// Keep only last 1000 games
return updated.length > 1000 ? updated.slice(-1000) : updated;
});
}
// Reset board features accumulator for new game
boardFeaturesAccumulator.current = [];
// Load weights based on AI mode
const oldWeights = aiWeights;
const newWeights = aiMode === 'neural' ? getNeuralWeights() : aiWeights;
@ -166,7 +253,15 @@ const Tetris: React.FC = () => {
setGameCounter(newGameNumber);
localStorage.setItem('tetris-game-counter', newGameNumber.toString());
// Log the weight change
// Log the weight change ONLY if weights have actually changed
const areWeightsDifferent = (w1: AIWeights, w2: AIWeights) => {
return Object.keys(w1).some(key => {
const k = key as keyof AIWeights;
return Math.abs(w1[k] - w2[k]) > 0.001;
});
};
if (areWeightsDifferent(oldWeights, newWeights)) {
const change: WeightChange = {
timestamp: Date.now(),
gameNumber: newGameNumber,
@ -176,12 +271,19 @@ const Tetris: React.FC = () => {
newWeights,
};
const updatedChanges = [...weightChanges, change];
let updatedChanges = [...weightChanges, change];
if (updatedChanges.length > 1000) {
updatedChanges = updatedChanges.slice(-1000);
}
setWeightChanges(updatedChanges);
try {
localStorage.setItem('tetris-weight-changes', JSON.stringify(updatedChanges));
} catch (e) {
console.error("Failed to save weight changes", e);
}
}
setAiWeights(newWeights);
console.log(`🧠 Neural Network: Game #${newGameNumber} - Weights updated`);
}
aiMoveRef.current = null;
@ -194,11 +296,9 @@ const Tetris: React.FC = () => {
contacts: 0,
contactScore: 0,
holes: 0,
holesPenalty: 0,
holesCreated: 0,
holesCreatedPenalty: 0,
overhangs: 0,
overhangPenalty: 0,
overhangsCreated: 0,
overhangsCreatedPenalty: 0,
overhangsFilled: 0,
@ -211,25 +311,30 @@ const Tetris: React.FC = () => {
bumpiness: 0,
bumpinessPenalty: 0,
maxHeight: 0,
maxHeightPenalty: 0,
avgHeight: '0.0',
avgHeightPenalty: '0',
rowTransitions: 0,
rowTransitionsPenalty: 0,
totalScore: 0,
});
// Reset the TGM3 randomizer for a fresh start
resetRandomizer();
setGameState({
board: createEmptyBoard(),
currentPiece: getRandomTetromino(),
currentPiece: getRandomTetromino(randomizerMode),
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(),
nextPiece: getRandomTetromino(randomizerMode),
score: 0,
level: 10, // Start at level 10 for autoplay challenge
level: startLevel,
lines: 0,
gameOver: false,
isPaused: false,
isAutoPlay: true,
});
setGameStarted(true);
}, [gameStarted, gameState.gameOver, gameState.score, gameState.lines, gameState.level, aiWeights, aiMode]);
}, [gameStarted, gameState.gameOver, gameState.score, gameState.lines, gameState.level, aiWeights, aiMode, weightChanges, gameCounter, startLevel, randomizerMode]);
const movePiece = useCallback((dx: number, dy: number) => {
setGameState((prev) => {
@ -292,12 +397,12 @@ const Tetris: React.FC = () => {
// Show animation if lines were cleared
if (linesToClear.length > 0) {
setClearedLines(linesToClear);
setTimeout(() => setClearedLines([]), 300);
setTimeout(() => setClearedLines([]), isMaxSpeed ? 0 : 300);
}
const newScore = prev.score + calculateScore(linesCleared, prev.level);
const newLines = prev.lines + linesCleared;
const newLevel = Math.floor(newLines / 10);
const newLevel = startLevel + Math.floor(newLines / 10);
if (clearedBoard[0].some(cell => cell !== 0)) {
return { ...prev, gameOver: true };
@ -314,7 +419,7 @@ const Tetris: React.FC = () => {
lines: newLines,
};
});
}, []);
}, [startLevel, isMaxSpeed]);
const drop = useCallback(() => {
setGameState((prev) => {
@ -357,12 +462,12 @@ const Tetris: React.FC = () => {
// Show animation if lines were cleared
if (linesToClear.length > 0) {
setClearedLines(linesToClear);
setTimeout(() => setClearedLines([]), 300);
setTimeout(() => setClearedLines([]), isMaxSpeed ? 0 : 300);
}
const newScore = prev.score + calculateScore(linesCleared, prev.level);
const newLines = prev.lines + linesCleared;
const newLevel = Math.floor(newLines / 10);
const newLevel = startLevel + Math.floor(newLines / 10);
if (clearedBoard[0].some(cell => cell !== 0)) {
return { ...prev, gameOver: true };
@ -373,7 +478,7 @@ const Tetris: React.FC = () => {
board: clearedBoard,
currentPiece: prev.nextPiece,
currentPosition: getStartPosition(),
nextPiece: getRandomTetromino(),
nextPiece: getRandomTetromino(randomizerMode),
score: newScore,
level: newLevel,
lines: newLines,
@ -382,7 +487,7 @@ const Tetris: React.FC = () => {
return prev;
});
}, []);
}, [startLevel, isMaxSpeed, randomizerMode]);
// Auto-start game on mount
useEffect(() => {
@ -397,7 +502,7 @@ const Tetris: React.FC = () => {
// Reset AI state when lines are cleared (board changes significantly)
useEffect(() => {
if (gameState.isAutoPlay && clearedLines.length > 0) {
console.log('Lines cleared, resetting AI state');
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
@ -425,7 +530,7 @@ const Tetris: React.FC = () => {
const pieceId = `${gameState.currentPiece.type}-${gameState.currentPosition.x}`;
if (lastProcessedPieceRef.current !== pieceId) {
console.log('AI STATE: idle -> calculating', pieceId);
aiStateRef.current = 'calculating';
aiStateStartTimeRef.current = Date.now();
lastProcessedPieceRef.current = pieceId;
@ -442,15 +547,23 @@ const Tetris: React.FC = () => {
movesDone: 0,
};
setDebugInfo(bestMove.breakdown);
console.log('AI calculated move:', bestMove, 'transitioning to executing');
// Collect board features for neural network training
const boardFeatures = extractBoardFeatures(bestMove.breakdown);
boardFeaturesAccumulator.current.push(boardFeatures);
if (boardFeaturesAccumulator.current.length % 50 === 0) {
// console.log(`[Neural AI] Collected ${boardFeaturesAccumulator.current.length} board feature samples`);
}
aiStateRef.current = 'executing';
} else {
console.warn('AI could not find a move! Forcing drop');
aiStateRef.current = 'dropping';
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiStateRef.current = 'idle';
}, 50);
}, isMaxSpeed ? 0 : 50);
}
// Don't return here - let it fall through to executing state
}
@ -467,7 +580,7 @@ const Tetris: React.FC = () => {
if (aiMoveRef.current) {
aiMoveRef.current.rotationsDone++;
}
}, 20);
}, isMaxSpeed ? 0 : 20);
return;
}
@ -478,63 +591,70 @@ const Tetris: React.FC = () => {
if (currentX < targetX) {
aiMove.movesDone = (aiMove.movesDone || 0) + 1;
if (aiMove.movesDone > 20) {
console.warn('AI stuck moving right, forcing drop');
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
}, 50);
}, isMaxSpeed ? 0 : 50);
return;
}
setTimeout(() => movePiece(1, 0), 5);
setTimeout(() => movePiece(1, 0), isMaxSpeed ? 0 : 5);
return;
} else if (currentX > targetX) {
aiMove.movesDone = (aiMove.movesDone || 0) + 1;
if (aiMove.movesDone > 20) {
console.warn('AI stuck moving left, forcing drop');
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
aiStateRef.current = 'idle';
}, 50);
}, isMaxSpeed ? 0 : 50);
return;
}
setTimeout(() => movePiece(-1, 0), 5);
setTimeout(() => movePiece(-1, 0), isMaxSpeed ? 0 : 5);
return;
} else {
// At target position, transition to dropping
console.log('AI STATE: executing -> dropping');
aiStateRef.current = 'dropping';
setTimeout(() => {
hardDrop();
aiMoveRef.current = null;
lastProcessedPieceRef.current = null; // Clear so AI can process next piece
aiStateRef.current = 'idle';
console.log('AI STATE: dropping -> idle');
}, 50);
}, isMaxSpeed ? 0 : 50);
}
}
}, [gameState.isAutoPlay, gameState.currentPiece, gameState.currentPosition, gameState.gameOver, gameState.isPaused, gameState.board, aiWeights, movePiece, rotate, hardDrop]);
}, [gameState.isAutoPlay, gameState.currentPiece, gameState.currentPosition, gameState.gameOver, gameState.isPaused, gameState.board, aiWeights, movePiece, rotate, hardDrop, isMaxSpeed]);
// Store startGame in a ref to avoid dependency issues
const startGameRef = useRef(startGame);
useEffect(() => {
startGameRef.current = startGame;
}, [startGame]);
// Auto-restart after game over
useEffect(() => {
if (gameState.gameOver && gameState.isAutoPlay) {
const timer = setTimeout(() => {
startGame();
startGameRef.current();
}, 2000);
return () => clearTimeout(timer);
}
}, [gameState.gameOver, gameState.isAutoPlay, startGame]);
}, [gameState.gameOver, gameState.isAutoPlay]);
// Game loop
useEffect(() => {
if (!gameStarted || gameState.gameOver || gameState.isPaused) return;
const speed = getDropSpeed(gameState.level);
const speed = isMaxSpeed ? 0 : getDropSpeed(gameState.level);
const interval = setInterval(() => {
// Don't drop if AI is still rotating (horizontal movement is fast enough)
if (gameState.isAutoPlay && aiMoveRef.current) {
@ -550,7 +670,7 @@ const Tetris: React.FC = () => {
}, speed);
return () => clearInterval(interval);
}, [gameStarted, gameState.gameOver, gameState.isPaused, gameState.level, gameState.isAutoPlay, drop]);
}, [gameStarted, gameState.gameOver, gameState.isPaused, gameState.level, gameState.isAutoPlay, drop, isMaxSpeed]);
// Keyboard controls
useEffect(() => {
@ -680,55 +800,65 @@ const Tetris: React.FC = () => {
));
};
const renderNextPiece = () => {
if (!gameState.nextPiece) return null;
// Create a 4x4 grid and center the piece
const grid: number[][] = Array(4).fill(0).map(() => Array(4).fill(0));
const piece = gameState.nextPiece;
// Calculate offset to center the piece in the 4x4 grid
const offsetY = Math.floor((4 - piece.shape.length) / 2);
const offsetX = Math.floor((4 - piece.shape[0].length) / 2);
// Place piece in the center of the grid
for (let y = 0; y < piece.shape.length; y++) {
for (let x = 0; x < piece.shape[y].length; x++) {
if (piece.shape[y][x]) {
grid[y + offsetY][x + offsetX] = 1;
}
}
}
return (
<div className="flex flex-col items-center gap-0">
{grid.map((row, y) => (
<div key={y} className="flex">
{row.map((cell, x) => (
<div
key={`${y} -${x} `}
className="w-6 h-6 border border-gray-700/20"
style={{
backgroundColor: cell ? gameState.nextPiece!.color : 'transparent',
boxShadow: cell ? 'inset 0 0 0 1px rgba(255,255,255,0.1)' : 'none',
}}
/>
))}
</div>
))}
</div>
);
};
return (
<div className="min-h-screen bg-gradient-to-br from-slate-900 via-purple-900 to-slate-900 p-4">
<div className="max-w-7xl mx-auto">
{/* Top Row: Game + Controls */}
<div className="flex flex-col lg:flex-row gap-8 items-start justify-center mb-8">
{/* Game Board - Left Side */}
<div className="flex-shrink-0">
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
{/* Tab Navigation */}
<div className="flex gap-2 mb-6 w-full max-w-5xl mx-auto">
<button
onClick={() => setActiveTab('game')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'game'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
🎮 Game
</button>
<button
onClick={() => setActiveTab('manual')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'manual'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
📚 Manual
</button>
<button
onClick={() => setActiveTab('log')}
className={`px-6 py-2 rounded-lg font-semibold transition-all ${activeTab === 'log'
? 'bg-gradient-to-r from-cyan-500 to-purple-500 text-white shadow-lg'
: 'bg-black/40 text-gray-400 hover:text-gray-200 border border-purple-500/20'
}`}
>
📝 Log
</button>
</div>
{/* Game Tab Content */}
{activeTab === 'game' && (
<div className="flex flex-col items-center w-full max-w-5xl mx-auto">
{/* Top Dashboard: Next Piece + Stats */}
<div className="flex flex-col md:flex-row gap-4 mb-6 w-full items-stretch justify-center">
<div className="flex-shrink-0 flex w-full md:w-auto">
<NextPieceDisplay nextPiece={gameState.nextPiece} />
</div>
<div className="flex-grow min-w-0 flex">
<StatsPanel
score={gameState.score}
lines={gameState.lines}
level={gameState.level}
debugInfo={debugInfo}
gameHistory={gameHistory}
gameCounter={gameCounter}
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4 md:gap-8 w-full mb-8 items-start">
{/* Left Column: Game Board + Controls */}
<div className="flex flex-col gap-4 w-full">
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20 flex flex-col w-full">
<div className="mb-4">
<h1 className="text-4xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400 mb-2">
Tetris
@ -738,7 +868,7 @@ const Tetris: React.FC = () => {
)}
</div>
<div className="bg-black/60 p-2 rounded-lg">
<div className="bg-black/60 p-2 rounded-lg self-center shadow-lg">
{renderBoard()}
</div>
@ -752,214 +882,72 @@ const Tetris: React.FC = () => {
)}
</div>
{/* Stats and Next Piece - Below Game */}
<div className="flex gap-4 mt-4">
{/* Stats */}
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20 flex-1">
<h2 className="text-xl font-bold text-cyan-400 mb-4">Stats</h2>
<div className="space-y-2 text-gray-200">
<div className="flex justify-between">
<span>Score:</span>
<span className="font-bold text-purple-400">{gameState.score}</span>
</div>
<div className="flex justify-between">
<span>Lines:</span>
<span className="font-bold text-cyan-400">{gameState.lines}</span>
</div>
<div className="flex justify-between">
<span>Level:</span>
<span className="font-bold text-green-400">{gameState.level}</span>
</div>
</div>
</div>
{/* Next Piece */}
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20 flex-1">
<h2 className="text-xl font-bold text-cyan-400 mb-4">Next</h2>
{renderNextPiece()}
</div>
</div>
{gameStarted && (
<Button
onClick={startGame}
variant="outline"
className="border-purple-500/50 hover:bg-purple-500/20 mt-4 w-full"
>
New Game
</Button>
)}
</div>
{/* Right Column: Controls + Weights */}
<div className="flex flex-col gap-4 w-full lg:w-96">
{/* AI Weights Panel with Inline Editing */}
{debugInfo && (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<div className="flex items-center justify-between mb-4">
<div className="flex items-center gap-2">
<h2 className="text-xl font-bold text-cyan-400">AI Weights</h2>
<button
onClick={() => setIsHelpOpen(true)}
className="text-gray-400 hover:text-cyan-400 transition-colors"
title="Help"
>
<HelpCircle className="w-4 h-4" />
</button>
</div>
<Button
onClick={() => {
setAiWeights(DEFAULT_WEIGHTS);
localStorage.setItem('tetris-ai-weights', JSON.stringify(DEFAULT_WEIGHTS));
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
}}
variant="outline"
size="sm"
className="border-purple-500/50 hover:bg-purple-500/20 text-xs"
>
Reset
</Button>
</div>
<div className="space-y-2 text-xs text-gray-300 font-mono">
{/* Editable Weight Rows */}
{[
{ key: 'lineCleared', label: 'Lines Cleared', color: 'text-green-400', sign: '+' },
{ key: 'contact', label: 'Contact', color: 'text-blue-400', sign: '+' },
{ key: 'holes', label: 'Holes', color: 'text-red-400', sign: '-' },
{ key: 'holesCreated', label: 'Holes Created', color: 'text-red-600', sign: '-' },
{ key: 'overhangs', label: 'Overhangs', color: 'text-pink-400', sign: '-' },
{ key: 'overhangsCreated', label: 'Overhangs Created', color: 'text-pink-600', sign: '-' },
{ key: 'overhangsFilled', label: 'Overhangs Filled', color: 'text-emerald-400', sign: '+' },
{ key: 'heightAdded', label: 'Height Added', color: 'text-amber-400', sign: '-' },
{ key: 'wells', label: 'Wells', color: 'text-rose-400', sign: '-' },
{ key: 'wellDepthSquared', label: 'Well Depth²', color: 'text-rose-500', sign: '-' },
{ key: 'bumpiness', label: 'Bumpiness', color: 'text-violet-400', sign: '-' },
{ key: 'maxHeight', label: 'Max Height', color: 'text-orange-400', sign: '-' },
{ key: 'avgHeight', label: 'Avg Height', color: 'text-yellow-400', sign: '-' },
].map(({ key, label, color, sign }) => (
<div key={key} className="flex justify-between gap-2 items-center">
<span className="flex-shrink-0">
<span className={sign === '+' ? 'text-green-400' : 'text-red-400'}>{sign}</span>
{' '}{label}:
</span>
<input
type="number"
value={aiWeights[key as keyof AIWeights]}
onChange={(e) => {
const newWeights = { ...aiWeights, [key]: parseFloat(e.target.value) || 0 };
setAiWeights(newWeights);
localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights));
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
}}
onKeyDown={(e) => {
const step = key === 'lineCleared' ? 100 : key.includes('Height') ? 5 : 10;
if (e.key === 'ArrowUp') {
e.preventDefault();
const newWeights = { ...aiWeights, [key]: aiWeights[key as keyof AIWeights] + step };
setAiWeights(newWeights);
localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights));
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
} else if (e.key === 'ArrowDown') {
e.preventDefault();
const newWeights = { ...aiWeights, [key]: Math.max(0, aiWeights[key as keyof AIWeights] - step) };
setAiWeights(newWeights);
localStorage.setItem('tetris-ai-weights', JSON.stringify(newWeights));
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
}
}}
className={`${color} text - right bg - transparent border - b border - purple - 500 / 30 focus: border - purple - 500 focus: outline - none w - 16 px - 1`}
/>
</div>
))}
<div className="border-t border-gray-700 pt-2 mt-2 flex justify-between gap-4 font-bold">
<span className="flex-shrink-0">Total Score:</span>
<span className="text-purple-400 text-right">{debugInfo.totalScore}</span>
</div>
</div>
</div>
)}
{/* Controls */}
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h2 className="text-xl font-bold text-cyan-400 mb-4">Controls</h2>
<div className="space-y-3 text-sm text-gray-300">
<div className="flex items-center gap-3">
<Switch
id="autoplay"
checked={gameState.isAutoPlay}
onCheckedChange={(checked) => {
{/* Controls - Below Game Board */}
<div className="w-full">
<ControlsPanel
gameStarted={gameStarted}
gameOver={gameState.gameOver}
isAutoPlay={gameState.isAutoPlay}
isMaxSpeed={isMaxSpeed}
aiMode={aiMode}
startLevel={startLevel}
onAutoPlayChange={(checked) => {
setGameState((prev) => ({ ...prev, isAutoPlay: checked }));
aiMoveRef.current = null;
}}
disabled={!gameStarted || gameState.gameOver}
onMaxSpeedChange={handleMaxSpeedChange}
onAiModeChange={setAiMode}
onStartLevelChange={(level) => {
setStartLevel(level);
localStorage.setItem('tetris-start-level', level.toString());
}}
onNewGame={startGame}
randomizerMode={randomizerMode}
onRandomizerModeChange={handleRandomizerModeChange}
/>
<Label htmlFor="autoplay" className="cursor-pointer">
Auto Play
</Label>
</div>
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2">AI Mode:</p>
<div className="flex gap-2">
<Button
onClick={() => setAiMode('standard')}
variant={aiMode === 'standard' ? 'default' : 'outline'}
size="sm"
className={aiMode === 'standard' ? 'bg-purple-600 hover:bg-purple-700' : 'border-purple-500/50 hover:bg-purple-500/20'}
>
Standard
</Button>
<Button
onClick={() => setAiMode('neural')}
variant={aiMode === 'neural' ? 'default' : 'outline'}
size="sm"
className={aiMode === 'neural' ? 'bg-cyan-600 hover:bg-cyan-700' : 'border-cyan-500/50 hover:bg-cyan-500/20'}
>
Neural Net
</Button>
</div>
<p className="text-xs text-gray-400 mt-2">
{aiMode === 'neural' ? '🧠 Learning from past games' : '⚙️ Using manual weights'}
</p>
</div>
<div className="pt-3 border-t border-gray-700">
<p className="font-semibold mb-2">Keyboard:</p>
<ul className="space-y-1 text-xs">
<li> Move (stops auto)</li>
<li> Rotate (stops auto)</li>
<li>Space: Hard Drop (stops auto)</li>
<li>P: Pause</li>
</ul>
</div>
</div>
</div>
{gameStarted && (
<Button
onClick={startGame}
variant="outline"
className="border-purple-500/50 hover:bg-purple-500/20"
>
New Game
</Button>
{/* Right Column: Weights (and other future panels) */}
<div className="flex flex-col gap-4 w-full">
{/* AI Weights Panel */}
{debugInfo && (
<AIWeightsPanel
debugInfo={debugInfo}
aiWeights={aiWeights}
gameHistory={gameHistory}
weightChanges={weightChanges}
gameCounter={gameCounter}
onWeightsChange={setAiWeights}
onHelpOpen={() => setIsHelpOpen(true)}
onResetAI={() => {
aiMoveRef.current = null;
lastProcessedPieceRef.current = null;
}}
adaptiveScaling={adaptiveScaling}
onAdaptiveScalingChange={(scaling) => {
setAdaptiveScaling(scaling);
localStorage.setItem('tetris-adaptive-scaling', JSON.stringify(scaling));
}}
/>
)}
{aiMode === 'neural' && <AIStrategyControl />}
</div>
</div>
{/* Learning Log - Neural Mode Only */}
{/* Weights History Chart - Full Width Row */}
{aiMode === 'neural' && (
<LearningLog changes={weightChanges} />
<div className="mb-8 w-full">
<WeightsHistoryChart changes={weightChanges} />
</div>
)}
</div>
</div>
)}
{/* Help Popup */}
{isHelpOpen && (
{
isHelpOpen && (
<div className="fixed inset-0 bg-black/80 backdrop-blur-sm flex items-center justify-center p-4 z-50">
<div className="bg-gradient-to-br from-slate-900 to-purple-900 border border-purple-500/30 rounded-2xl shadow-2xl max-w-2xl w-full max-h-[90vh] overflow-y-auto">
<div className="sticky top-0 bg-gradient-to-br from-slate-900 to-purple-900 border-b border-purple-500/30 p-6 flex items-center justify-between">
@ -987,19 +975,9 @@ const Tetris: React.FC = () => {
<p className="text-sm">Rewards placing pieces adjacent to existing blocks. Encourages compact, connected structures rather than isolated pieces.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-red-500/20">
<h3 className="font-bold text-red-400 mb-2">- Holes (Penalty)</h3>
<p className="text-sm">Penalizes empty cells with blocks above them. Holes are hard to fill and lead to game over. Higher values make the AI avoid creating holes.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-red-600/20">
<h3 className="font-bold text-red-600 mb-2">- Holes Created (Penalty)</h3>
<p className="text-sm">Extra penalty for moves that create NEW holes. This is worse than existing holes because it represents a mistake the AI is about to make.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-pink-500/20">
<h3 className="font-bold text-pink-400 mb-2">- Overhangs (Penalty)</h3>
<p className="text-sm">Penalizes blocks that stick out horizontally with empty space beneath. Overhangs create awkward shapes and future holes.</p>
<p className="text-sm">Penalty for moves that create NEW holes. Holes are empty cells with blocks above them, making them hard to fill and leading to game over.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-pink-600/20">
@ -1017,14 +995,9 @@ const Tetris: React.FC = () => {
<p className="text-sm">Penalizes moves that increase the stack height. Keeps the board low to avoid game over and maintain flexibility.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-rose-500/20">
<h3 className="font-bold text-rose-400 mb-2">- Wells (Penalty)</h3>
<p className="text-sm">Penalizes deep vertical gaps (wells). While useful for I-pieces, deep wells limit placement options and can trap pieces.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-rose-600/20">
<h3 className="font-bold text-rose-500 mb-2">- Well Depth² (Penalty)</h3>
<p className="text-sm">Squared penalty for well depth. Makes very deep wells exponentially worse. A 4-deep well is penalized 16x more than a 1-deep well.</p>
<p className="text-sm">Squared penalty for well depth. Deep vertical gaps (wells) limit placement options. A 4-deep well is penalized 16x more than a 1-deep well.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-violet-500/20">
@ -1032,10 +1005,7 @@ const Tetris: React.FC = () => {
<p className="text-sm">Penalizes uneven column heights. Smooth, flat surfaces are easier to work with and reduce the chance of creating holes.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-orange-500/20">
<h3 className="font-bold text-orange-400 mb-2">- Max Height (Penalty)</h3>
<p className="text-sm">Penalizes the tallest column. Prevents any single column from getting too high, which would trigger game over.</p>
</div>
<div className="bg-black/30 p-4 rounded-lg border border-yellow-500/20">
<h3 className="font-bold text-yellow-400 mb-2">- Avg Height (Penalty)</h3>
@ -1055,24 +1025,47 @@ const Tetris: React.FC = () => {
</div>
</div>
</div>
)}
)
}
{/* Neural Network Visualization - Full Width Below */}
{aiMode === 'neural' && (
<div className="mt-8 max-w-7xl mx-auto">
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{
aiMode === 'neural' && (
<div className="mt-8 max-w-5xl mx-auto w-full">
<div className="flex flex-col gap-8 w-full">
<div className="w-full">
<NeuralNetworkVisualizer
network={neuralNetwork}
currentInput={currentNNInput}
currentOutput={currentNNOutput}
/>
</div>
<div className="w-full">
<PerformanceChart history={gameHistory} />
</div>
</div>
</div>
)
}
{/* Manual Tab Content */}
{activeTab === 'manual' && (
<div className="bg-black/40 backdrop-blur-sm p-8 rounded-2xl shadow-2xl border border-purple-500/20 max-w-4xl mx-auto">
<MarkdownRenderer
content={manualContent || '# Loading...'}
className="prose-invert"
/>
</div>
)}
{/* Log Tab Content */}
{activeTab === 'log' && (
<div className="max-w-4xl mx-auto mb-8">
<LearningLog changes={weightChanges} />
</div>
)}
</div>
</div>
</div >
);
};

View File

@ -0,0 +1,177 @@
import React, { useState, useEffect } from 'react';
import { X, Trash2, Play, Database, RefreshCw } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { calculateReward } from './neuralAI';
interface TrainingDataModalProps {
isOpen: boolean;
onClose: () => void;
onTrain: () => void;
history: any[];
setHistory: (history: any[]) => void;
}
export const TrainingDataModal: React.FC<TrainingDataModalProps> = ({
isOpen,
onClose,
onTrain,
history,
setHistory,
}) => {
const [selectedGames, setSelectedGames] = useState<number[]>([]);
// Calculate stats
const avgScore = history.length > 0
? Math.round(history.reduce((sum, g) => sum + g.score, 0) / history.length)
: 0;
if (!isOpen) return null;
const handleDelete = (index: number) => {
const newHistory = [...history];
newHistory.splice(index, 1);
setHistory(newHistory);
localStorage.setItem('tetris-game-history', JSON.stringify(newHistory));
};
const handleDeleteSelected = () => {
const newHistory = history.filter((_, idx) => !selectedGames.includes(idx));
setHistory(newHistory);
localStorage.setItem('tetris-game-history', JSON.stringify(newHistory));
setSelectedGames([]);
};
const toggleSelection = (index: number) => {
if (selectedGames.includes(index)) {
setSelectedGames(selectedGames.filter(i => i !== index));
} else {
setSelectedGames([...selectedGames, index]);
}
};
const getGradeColor = (score: number) => {
if (score > avgScore * 1.5) return 'text-purple-400';
if (score > avgScore) return 'text-green-400';
if (score < avgScore * 0.5) return 'text-red-400';
return 'text-gray-400';
};
return (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4">
<div className="bg-gray-900 border border-purple-500/30 rounded-2xl w-full max-w-4xl max-h-[90vh] flex flex-col shadow-2xl">
{/* Header */}
<div className="p-6 border-b border-white/10 flex justify-between items-center bg-black/20">
<div className="flex items-center gap-3">
<div className="p-2 bg-purple-500/20 rounded-lg">
<Database className="w-6 h-6 text-purple-400" />
</div>
<div>
<h2 className="text-xl font-bold text-white">Training Data Curator</h2>
<p className="text-sm text-gray-400">
{history.length} games in buffer Avg Score: {avgScore.toLocaleString()}
</p>
</div>
</div>
<button onClick={onClose} className="text-gray-400 hover:text-white transition-colors">
<X className="w-6 h-6" />
</button>
</div>
{/* Toolbar */}
<div className="p-4 border-b border-white/10 flex justify-between items-center bg-black/10">
<div className="flex gap-2">
{selectedGames.length > 0 && (
<Button
variant="destructive"
size="sm"
onClick={handleDeleteSelected}
className="bg-red-500/20 text-red-400 hover:bg-red-500/30 border border-red-500/50"
>
<Trash2 className="w-4 h-4 mr-2" />
Delete Selected ({selectedGames.length})
</Button>
)}
<span className="text-sm text-gray-500 self-center ml-2">
Select bad games to remove them from training.
</span>
</div>
<Button
onClick={() => {
onTrain();
onClose();
}}
className="bg-gradient-to-r from-purple-600 to-cyan-600 hover:from-purple-500 hover:to-cyan-500 text-white font-bold"
>
<RefreshCw className="w-4 h-4 mr-2" />
Train on {history.length} Games
</Button>
</div>
{/* List */}
<div className="flex-1 overflow-y-auto p-4 custom-scrollbar">
<div className="space-y-2">
{history.slice().reverse().map((game, reverseIdx) => {
const realIdx = history.length - 1 - reverseIdx;
const isSelected = selectedGames.includes(realIdx);
const reward = calculateReward(game.score, game.lines, game.level);
return (
<div
key={game.timestamp}
className={`flex items-center justify-between p-3 rounded-lg border transition-all cursor-pointer ${isSelected
? 'bg-red-900/20 border-red-500/50'
: 'bg-black/40 border-white/5 hover:border-white/10'
}`}
onClick={() => toggleSelection(realIdx)}
>
<div className="flex items-center gap-4">
<div className={`w-4 h-4 rounded border ${isSelected ? 'bg-red-500 border-red-500' : 'border-gray-600'}`} />
<div className="w-16 text-xs text-gray-500">
{new Date(game.timestamp).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit', second: '2-digit' })}
</div>
<div className="flex items-baseline gap-4 w-48">
<span className={`font-mono font-bold text-lg ${getGradeColor(game.score)}`}>
{game.score.toLocaleString()}
</span>
<span className="text-xs text-gray-500">score</span>
</div>
<div className="flex gap-6 text-sm text-gray-300">
<span>Lines: <b className="text-white">{game.lines}</b></span>
<span>Lvl: <b className="text-white">{game.level}</b></span>
<span title="Calculated Training Reward (0.0 - 1.0)">
Reward: <b className={reward > 0.5 ? 'text-green-400' : 'text-yellow-400'}>{reward.toFixed(2)}</b>
</span>
</div>
</div>
<Button
variant="ghost"
size="icon"
className="text-gray-500 hover:text-red-400"
onClick={(e) => {
e.stopPropagation();
handleDelete(realIdx);
}}
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
);
})}
{history.length === 0 && (
<div className="text-center py-20 text-gray-500">
<Database className="w-12 h-12 mx-auto mb-4 opacity-20" />
<p>No games in history buffer.</p>
<p className="text-sm">Play some games to collect training data!</p>
</div>
)}
</div>
</div>
</div>
</div>
);
};

View File

@ -0,0 +1,390 @@
import React, { useState, useMemo } from 'react';
import { AIWeights } from './aiPlayer';
interface WeightChange {
timestamp: number;
gameNumber: number;
score: number;
lines: number;
oldWeights: AIWeights;
newWeights: AIWeights;
}
interface WeightsHistoryChartProps {
changes: WeightChange[];
}
const WEIGHT_COLORS: Record<keyof AIWeights, string> = {
lineCleared: 'rgb(34, 197, 94)', // green
contact: 'rgb(59, 130, 246)', // blue
holesCreated: 'rgb(239, 68, 68)', // red
overhangsCreated: 'rgb(236, 72, 153)', // pink
overhangsFilled: 'rgb(16, 185, 129)', // emerald
heightAdded: 'rgb(251, 146, 60)', // orange
wellDepthSquared: 'rgb(168, 85, 247)', // purple
bumpiness: 'rgb(234, 179, 8)', // yellow
avgHeight: 'rgb(249, 115, 22)', // orange-500
rowTransitions: 'rgb(14, 165, 233)', // sky
};
const WEIGHT_LABELS: Record<keyof AIWeights, string> = {
lineCleared: 'Lines',
contact: 'Contact',
holesCreated: 'Holes',
overhangsCreated: 'Overhangs',
overhangsFilled: 'Fill',
heightAdded: 'Height',
wellDepthSquared: 'Wells',
bumpiness: 'Bumpy',
avgHeight: 'Avg H',
rowTransitions: 'Transitions',
};
export const WeightsHistoryChart: React.FC<WeightsHistoryChartProps> = ({ changes }) => {
const [activeTab, setActiveTab] = useState<'log' | 'chart'>(() => {
const stored = localStorage.getItem('tetris-history-tab');
return (stored === 'chart' ? 'chart' : 'log');
});
const [selectedWeights, setSelectedWeights] = useState<Set<keyof AIWeights | 'score'>>(() => {
const stored = localStorage.getItem('tetris-history-weights');
if (stored) {
try {
return new Set(JSON.parse(stored));
} catch (e) {
console.error("Failed to parse stored weights", e);
}
}
return new Set(['lineCleared', 'holesCreated', 'heightAdded', 'wellDepthSquared', 'score']);
});
const [hoveredIndex, setHoveredIndex] = useState<number | null>(null);
// Prepare data for chart - MUST be before any early returns to satisfy Rules of Hooks
const recentChanges = useMemo(() => changes.slice(-20), [changes]);
const weightKeys = Object.keys(WEIGHT_COLORS) as (keyof AIWeights)[];
const allKeys = ['score', ...weightKeys] as const;
const handleTabChange = (tab: 'log' | 'chart') => {
setActiveTab(tab);
localStorage.setItem('tetris-history-tab', tab);
};
if (changes.length === 0) {
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<h3 className="text-lg font-bold text-cyan-400 mb-3">Learning History</h3>
<p className="text-gray-400 text-sm">No weight changes yet. Play some games in Neural mode!</p>
</div>
);
}
const toggleWeight = (weight: keyof AIWeights | 'score') => {
const newSet = new Set(selectedWeights);
if (newSet.has(weight)) {
newSet.delete(weight);
} else {
newSet.add(weight);
}
setSelectedWeights(newSet);
localStorage.setItem('tetris-history-weights', JSON.stringify(Array.from(newSet)));
};
const calculateWeightDelta = (oldVal: number | undefined, newVal: number | undefined): { delta: number; percent: number } | null => {
if (oldVal === undefined || newVal === undefined) return null;
const delta = newVal - oldVal;
const percent = oldVal !== 0 ? (delta / oldVal) * 100 : 0;
return { delta, percent };
};
const getSignificantChanges = (change: WeightChange) => {
const keys = Object.keys(change.newWeights) as (keyof AIWeights)[];
const significant = keys
.map(key => {
const result = calculateWeightDelta(change.oldWeights[key], change.newWeights[key]);
if (!result) return null;
const newValue = change.newWeights[key];
if (newValue === undefined || newValue === null) return null;
return { key, delta: result.delta, percent: result.percent, newValue };
})
.filter((item): item is NonNullable<typeof item> => item !== null)
.sort((a, b) => Math.abs(b.percent) - Math.abs(a.percent))
.slice(0, 3);
return significant;
};
const handleMouseMove = (e: React.MouseEvent<HTMLDivElement>) => {
if (recentChanges.length === 0) return;
const rect = e.currentTarget.getBoundingClientRect();
const x = e.clientX - rect.left;
const width = rect.width;
// Find closest index
const index = Math.round((x / width) * (recentChanges.length - 1));
const clampedIndex = Math.max(0, Math.min(recentChanges.length - 1, index));
setHoveredIndex(clampedIndex);
};
const handleMouseLeave = () => {
setHoveredIndex(null);
};
return (
<div className="bg-black/40 backdrop-blur-sm p-6 rounded-2xl shadow-2xl border border-purple-500/20">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-bold text-cyan-400">Learning History</h3>
{/* Tab Toggles */}
<div className="flex gap-1 bg-black/40 rounded p-1">
<button
onClick={() => handleTabChange('log')}
className={`px-3 py-1 rounded text-sm font-semibold transition-all ${activeTab === 'log'
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
: 'text-gray-400 hover:text-gray-300'
}`}
>
📋 Log
</button>
<button
onClick={() => handleTabChange('chart')}
className={`px-3 py-1 rounded text-sm font-semibold transition-all ${activeTab === 'chart'
? 'bg-purple-500/30 text-purple-300 border border-purple-400/50'
: 'text-gray-400 hover:text-gray-300'
}`}
>
📊 Chart
</button>
</div>
</div>
{/* Log View */}
{activeTab === 'log' && (
<div className="space-y-3 max-h-96 overflow-y-auto">
{changes.slice().reverse().map((change, idx) => {
const significantChanges = getSignificantChanges(change);
const timeAgo = new Date(change.timestamp).toLocaleTimeString();
return (
<div
key={idx}
className="bg-black/30 p-4 rounded-lg border border-purple-500/10 hover:border-purple-500/30 transition-colors"
>
<div className="flex items-center justify-between mb-2">
<div className="flex items-center gap-2">
<span className="text-purple-400 font-bold">Game #{change.gameNumber}</span>
<span className="text-gray-500 text-xs">{timeAgo}</span>
</div>
<div className="text-xs text-gray-400">
Score: <span className="text-cyan-400 font-semibold">{change.score}</span> |
Lines: <span className="text-green-400 font-semibold">{change.lines}</span>
</div>
</div>
{significantChanges.length > 0 ? (
<div className="space-y-1">
<p className="text-xs text-gray-500 mb-1">Top weight changes:</p>
{significantChanges.map((item, i) => (
<div key={i} className="flex items-center justify-between text-xs">
<span className="text-gray-300">{item.key}:</span>
<div className="flex items-center gap-2">
<span className="text-gray-400">{(item.newValue ?? 0).toFixed(0)}</span>
<span className={`font-semibold ${item.percent > 0 ? 'text-green-400' : 'text-red-400'}`}>
{item.percent > 0 ? '+' : ''}{item.percent.toFixed(1)}%
</span>
</div>
</div>
))}
</div>
) : (
<p className="text-xs text-gray-500">No significant changes</p>
)}
</div>
);
})}
</div>
)}
{/* Chart View */}
{activeTab === 'chart' && (
<div className="space-y-4">
{/* Weight Selection */}
<div className="bg-black/30 p-3 rounded-lg border border-purple-500/10">
<p className="text-xs text-gray-400 mb-2">
Select metrics to display (Trends normalized to chart height):
</p>
<div className="flex flex-wrap gap-2">
{allKeys.map(key => {
const label = key === 'score' ? 'Score' : WEIGHT_LABELS[key as keyof AIWeights];
const color = key === 'score' ? '#ffffff' : WEIGHT_COLORS[key as keyof AIWeights];
return (
<button
key={key}
onClick={() => toggleWeight(key)}
className={`px-2 py-1 rounded text-xs font-semibold transition-all ${selectedWeights.has(key)
? 'border-2 opacity-100'
: 'border border-gray-600 opacity-50 hover:opacity-70'
}`}
style={{
borderColor: selectedWeights.has(key) ? color : undefined,
color: color,
}}
>
{label}
</button>
)
})}
</div>
</div>
{/* Chart */}
<div
className="relative h-64 bg-black/30 rounded-lg p-4 border border-gray-700 cursor-crosshair"
onMouseMove={handleMouseMove}
onMouseLeave={handleMouseLeave}
>
{/* Chart area */}
<div className="w-full h-full relative">
{/* Grid lines */}
<div className="absolute inset-0 flex flex-col justify-between pointer-events-none">
{[0, 1, 2, 3, 4].map(i => (
<div key={i} className="border-t border-gray-800" />
))}
</div>
{/* SVG Multi-line Chart */}
<svg className="absolute inset-0 w-full h-full pointer-events-none" viewBox="0 0 100 100" preserveAspectRatio="none">
{Array.from(selectedWeights).map(weightKey => {
const color = weightKey === 'score' ? '#ffffff' : WEIGHT_COLORS[weightKey as keyof AIWeights];
// Calculate range for THIS metric
const values = recentChanges.map(c =>
weightKey === 'score' ? c.score : (c.newWeights[weightKey as keyof AIWeights] || 0)
);
let min = Math.min(...values);
let max = Math.max(...values);
// Handle flat lines (min === max) by centering them or adding small range
if (min === max) {
min = min - 10;
max = max + 10;
} else {
// Add 5% padding
const range = max - min;
min -= range * 0.05;
max += range * 0.05;
}
const range = max - min;
const points = recentChanges.map((change, idx) => {
const x = (idx / Math.max(1, recentChanges.length - 1)) * 100;
const value = weightKey === 'score'
? change.score
: (change.newWeights[weightKey as keyof AIWeights] || 0);
// Inverse Y (100% is bottom, 0% is top is standard SVG coords logic, but usually 0,0 is top-left)
// Here: value - min / range gives 0..1.
// We want MAX at TOP (y=0%) and MIN at BOTTOM (y=100%).
const y = 100 - ((value - min) / range) * 100;
return { x, y, value, idx };
});
const path = points.map((p, i) =>
`${i === 0 ? 'M' : 'L'} ${p.x} ${p.y}`
).join(' ');
const isActive = hoveredIndex !== null && points[hoveredIndex];
return (
<g key={weightKey}>
{/* Line */}
<path
d={path}
fill="none"
stroke={color}
strokeWidth={weightKey === 'score' ? "2" : "1.5"}
strokeDasharray={weightKey === 'score' ? "4 2" : "none"}
vectorEffect="non-scaling-stroke"
opacity={weightKey === 'score' ? "0.9" : "0.7"}
/>
{/* Data Points (only show on hover or endpoints if needed, but clean is better) */}
{/* Hover Highlight Point */}
{hoveredIndex !== null && points[hoveredIndex] && (
<circle
cx={`${points[hoveredIndex].x}%`}
cy={`${points[hoveredIndex].y}%`}
r="3"
fill={color}
stroke="#000"
strokeWidth="1"
/>
)}
</g>
);
})}
</svg>
{/* Hover Vertical Line */}
{hoveredIndex !== null && (
<div
className="absolute top-0 bottom-0 w-px bg-white/20 pointer-events-none transition-all duration-75"
style={{
left: `${(hoveredIndex / Math.max(1, recentChanges.length - 1)) * 100}%`
}}
/>
)}
</div>
{/* X-axis label */}
<div className="absolute bottom-0 left-0 right-0 text-center text-xs text-gray-500 mt-2 pointer-events-none">
Last {recentChanges.length} Games
</div>
{/* Hover Tooltip */}
{hoveredIndex !== null && recentChanges[hoveredIndex] && (
<div
className="absolute z-10 bg-black/90 border border-purple-500/30 p-3 rounded-lg shadow-xl text-xs pointer-events-none backdrop-blur-md"
style={{
top: '10px',
left: hoveredIndex < recentChanges.length / 2
? `${(hoveredIndex / Math.max(1, recentChanges.length - 1)) * 100 + 5}%`
: 'auto',
right: hoveredIndex >= recentChanges.length / 2
? `${100 - (hoveredIndex / Math.max(1, recentChanges.length - 1)) * 100 + 5}%`
: 'auto',
}}
>
<div className="font-bold text-gray-300 mb-2 border-b border-gray-700 pb-1">
Game #{recentChanges[hoveredIndex].gameNumber}
</div>
<div className="space-y-1">
{Array.from(selectedWeights).map(key => {
const color = key === 'score' ? '#ffffff' : WEIGHT_COLORS[key as keyof AIWeights];
const label = key === 'score' ? 'Score' : WEIGHT_LABELS[key as keyof AIWeights];
const value = key === 'score'
? recentChanges[hoveredIndex!].score
: recentChanges[hoveredIndex!].newWeights[key as keyof AIWeights];
return (
<div key={key} className="flex items-center gap-2 whitespace-nowrap">
<div className="w-2 h-2 rounded-full" style={{ backgroundColor: color }} />
<span className="text-gray-400">{label}:</span>
<span className="font-mono font-bold" style={{ color }}>
{value?.toFixed(0)}
</span>
</div>
);
})}
</div>
</div>
)}
</div>
{/* Stats */}
<div className="text-xs text-gray-400">
<span className="font-semibold text-purple-400">{changes.length}</span> total updates
</div>
</div>
)}
</div>
);
};

View File

@ -56,16 +56,12 @@ export const WeightsTuner: React.FC<WeightsTunerProps> = ({ isOpen, onClose, onW
const weightFields: { key: keyof AIWeights; label: string; step: number }[] = [
{ key: 'lineCleared', label: 'Lines Cleared', step: 100 },
{ key: 'contact', label: 'Contact', step: 10 },
{ key: 'holes', label: 'Holes (penalty)', step: 10 },
{ key: 'holesCreated', label: 'Holes Created (penalty)', step: 10 },
{ key: 'overhangs', label: 'Overhangs (penalty)', step: 10 },
{ key: 'overhangsCreated', label: 'Overhangs Created (penalty)', step: 10 },
{ key: 'overhangsFilled', label: 'Overhangs Filled (bonus)', step: 10 },
{ key: 'heightAdded', label: 'Height Added (penalty)', step: 10 },
{ key: 'wells', label: 'Wells (penalty)', step: 10 },
{ key: 'wellDepthSquared', label: 'Well Depth² (penalty)', step: 10 },
{ key: 'bumpiness', label: 'Bumpiness (penalty)', step: 5 },
{ key: 'maxHeight', label: 'Max Height (penalty)', step: 5 },
{ key: 'avgHeight', label: 'Avg Height (penalty)', step: 5 },
];
@ -80,24 +76,22 @@ export const WeightsTuner: React.FC<WeightsTunerProps> = ({ isOpen, onClose, onW
>
{/* Header */}
<div className="flex items-center justify-between mb-6">
<h2 className="text-2xl font-bold text-transparent bg-clip-text bg-gradient-to-r from-cyan-400 to-purple-400">
AI Weights Tuner
</h2>
<div className="flex gap-2">
<div className="flex items-center gap-2">
<Button
onClick={resetToDefaults}
variant="outline"
size="sm"
className="border-purple-500/50 hover:bg-purple-500/20"
size="icon"
className="border-purple-500/50 hover:bg-purple-500/20 h-9 w-9"
title="Reset to defaults"
>
<RotateCcw className="w-4 h-4 mr-2" />
Reset
<RotateCcw className="w-4 h-4" />
</Button>
<Button
onClick={onClose}
variant="outline"
size="sm"
className="border-purple-500/50 hover:bg-purple-500/20"
size="icon"
className="border-purple-500/50 hover:bg-purple-500/20 h-9 w-9"
title="Close"
>
<X className="w-4 h-4" />
</Button>
@ -116,6 +110,167 @@ export const WeightsTuner: React.FC<WeightsTunerProps> = ({ isOpen, onClose, onW
</p>
</div>
{/* Visual Charts */}
<div className="mb-6 grid grid-cols-2 gap-4">
{/* Rewards Chart */}
<div className="bg-gradient-to-br from-green-500/10 to-emerald-500/10 border border-green-500/30 rounded-lg p-4">
<h3 className="text-sm font-semibold text-green-400 mb-3">Rewards</h3>
<svg viewBox="0 0 200 120" className="w-full h-24">
{/* Grid lines */}
<line x1="0" y1="100" x2="200" y2="100" stroke="#ffffff10" strokeWidth="1" />
<line x1="0" y1="50" x2="200" y2="50" stroke="#ffffff10" strokeWidth="1" />
{/* Line Cleared - tallest */}
<path
d={`M 20 ${100 - (weights.lineCleared / 200)} Q 30 ${100 - (weights.lineCleared / 200)} 40 100`}
fill="url(#greenGradient1)"
stroke="#10b981"
strokeWidth="2"
/>
<text x="30" y="115" fontSize="10" fill="#10b981" textAnchor="middle">Lines</text>
{/* Contact */}
<path
d={`M 60 ${100 - (weights.contact / 10)} Q 70 ${100 - (weights.contact / 10)} 80 100`}
fill="url(#greenGradient2)"
stroke="#34d399"
strokeWidth="2"
/>
<text x="70" y="115" fontSize="10" fill="#34d399" textAnchor="middle">Contact</text>
{/* Overhangs Filled */}
<path
d={`M 100 ${100 - (weights.overhangsFilled / 20)} Q 110 ${100 - (weights.overhangsFilled / 20)} 120 100`}
fill="url(#greenGradient3)"
stroke="#6ee7b7"
strokeWidth="2"
/>
<text x="110" y="115" fontSize="10" fill="#6ee7b7" textAnchor="middle">Fill</text>
{/* Gradients */}
<defs>
<linearGradient id="greenGradient1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#10b981" stopOpacity="0.6" />
<stop offset="100%" stopColor="#10b981" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="greenGradient2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#34d399" stopOpacity="0.6" />
<stop offset="100%" stopColor="#34d399" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="greenGradient3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#6ee7b7" stopOpacity="0.6" />
<stop offset="100%" stopColor="#6ee7b7" stopOpacity="0.1" />
</linearGradient>
</defs>
</svg>
</div>
{/* Penalties Chart */}
<div className="bg-gradient-to-br from-red-500/10 to-orange-500/10 border border-red-500/30 rounded-lg p-4">
<h3 className="text-sm font-semibold text-red-400 mb-3">Penalties</h3>
<svg viewBox="0 0 200 120" className="w-full h-24">
{/* Grid lines */}
<line x1="0" y1="100" x2="200" y2="100" stroke="#ffffff10" strokeWidth="1" />
<line x1="0" y1="50" x2="200" y2="50" stroke="#ffffff10" strokeWidth="1" />
{/* Holes Created */}
<path
d={`M 15 ${100 - (weights.holesCreated / 40)} Q 22 ${100 - (weights.holesCreated / 40)} 29 100`}
fill="url(#redGradient1)"
stroke="#ef4444"
strokeWidth="2"
/>
<text x="22" y="115" fontSize="9" fill="#ef4444" textAnchor="middle">Holes</text>
{/* Overhangs Created */}
<path
d={`M 35 ${100 - (weights.overhangsCreated / 50)} Q 42 ${100 - (weights.overhangsCreated / 50)} 49 100`}
fill="url(#redGradient2)"
stroke="#f87171"
strokeWidth="2"
/>
<text x="42" y="115" fontSize="9" fill="#f87171" textAnchor="middle">Over</text>
{/* Height Added */}
<path
d={`M 55 ${100 - (weights.heightAdded / 40)} Q 62 ${100 - (weights.heightAdded / 40)} 69 100`}
fill="url(#redGradient3)"
stroke="#fca5a5"
strokeWidth="2"
/>
<text x="62" y="115" fontSize="9" fill="#fca5a5" textAnchor="middle">Height</text>
{/* Well Depth */}
<path
d={`M 75 ${100 - (weights.wellDepthSquared / 10)} Q 82 ${100 - (weights.wellDepthSquared / 10)} 89 100`}
fill="url(#redGradient4)"
stroke="#fb923c"
strokeWidth="2"
/>
<text x="82" y="115" fontSize="9" fill="#fb923c" textAnchor="middle">Well</text>
{/* Bumpiness */}
<path
d={`M 95 ${100 - (weights.bumpiness / 2)} Q 102 ${100 - (weights.bumpiness / 2)} 109 100`}
fill="url(#redGradient5)"
stroke="#fdba74"
strokeWidth="2"
/>
<text x="102" y="115" fontSize="9" fill="#fdba74" textAnchor="middle">Bump</text>
{/* Avg Height */}
<path
d={`M 115 ${100 - (weights.avgHeight / 2)} Q 122 ${100 - (weights.avgHeight / 2)} 129 100`}
fill="url(#redGradient6)"
stroke="#fed7aa"
strokeWidth="2"
/>
<text x="122" y="115" fontSize="9" fill="#fed7aa" textAnchor="middle">Avg</text>
{/* Row Transitions */}
<path
d={`M 135 ${100 - (weights.rowTransitions / 2)} Q 142 ${100 - (weights.rowTransitions / 2)} 149 100`}
fill="url(#redGradient7)"
stroke="#fef3c7"
strokeWidth="2"
/>
<text x="142" y="115" fontSize="9" fill="#fef3c7" textAnchor="middle">Row</text>
{/* Gradients */}
<defs>
<linearGradient id="redGradient1" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#ef4444" stopOpacity="0.6" />
<stop offset="100%" stopColor="#ef4444" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient2" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#f87171" stopOpacity="0.6" />
<stop offset="100%" stopColor="#f87171" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient3" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fca5a5" stopOpacity="0.6" />
<stop offset="100%" stopColor="#fca5a5" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient4" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fb923c" stopOpacity="0.6" />
<stop offset="100%" stopColor="#fb923c" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient5" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fdba74" stopOpacity="0.6" />
<stop offset="100%" stopColor="#fdba74" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient6" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fed7aa" stopOpacity="0.6" />
<stop offset="100%" stopColor="#fed7aa" stopOpacity="0.1" />
</linearGradient>
<linearGradient id="redGradient7" x1="0%" y1="0%" x2="0%" y2="100%">
<stop offset="0%" stopColor="#fef3c7" stopOpacity="0.6" />
<stop offset="100%" stopColor="#fef3c7" stopOpacity="0.1" />
</linearGradient>
</defs>
</svg>
</div>
</div>
{/* Weight Fields */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{weightFields.map(({ key, label, step }) => (

View File

@ -4,35 +4,139 @@ import { checkCollision, mergePieceToBoard, rotateTetromino, clearLines } from '
export interface AIWeights {
lineCleared: number;
contact: number;
holes: number;
holesCreated: number;
overhangs: number;
overhangsCreated: number;
overhangsFilled: number;
heightAdded: number;
wells: number;
wellDepthSquared: number;
bumpiness: number;
maxHeight: number;
avgHeight: number;
rowTransitions: number;
}
export const DEFAULT_WEIGHTS: AIWeights = {
lineCleared: 10000,
contact: 100,
holes: 500,
holesCreated: 800,
overhangs: 500,
overhangsCreated: 1000,
lineCleared: 500,
contact: 400,
holesCreated: 400,
overhangsCreated: 400,
overhangsFilled: 200,
heightAdded: 800,
wells: 100,
wellDepthSquared: 100,
wellDepthSquared: 500,
bumpiness: 50,
maxHeight: 50,
avgHeight: 20,
rowTransitions: 40,
};
export interface AdaptiveScalingMode {
// How much each weight changes at this mode's maximum (0-1 scale)
lineCleared: number; // Decrease multiplier (e.g., 0.3 = -30% at max)
contact: number; // Decrease multiplier
overhangsFilled: number; // Decrease multiplier
holesCreated: number; // Increase multiplier (e.g., 2.0 = +200% at max)
overhangsCreated: number; // Increase multiplier
heightAdded: number; // Increase multiplier
avgHeight: number; // Increase multiplier
wellDepthSquared: number; // Decrease multiplier
bumpiness: number; // Increase multiplier
rowTransitions: number; // Increase multiplier
}
export interface AdaptiveScaling {
normal: AdaptiveScalingMode; // Height 0-9
defense: AdaptiveScalingMode; // Height 9-12
crisis: AdaptiveScalingMode; // Height 12+
}
export const DEFAULT_ADAPTIVE_SCALING: AdaptiveScaling = {
normal: {
lineCleared: 0, // No change in normal mode
contact: 0,
overhangsFilled: 0,
holesCreated: 0,
overhangsCreated: 0,
heightAdded: 0,
avgHeight: 0,
wellDepthSquared: 0,
bumpiness: 0,
rowTransitions: 0,
},
defense: {
lineCleared: 0.15, // -15% at defense mode
contact: 0.35, // -35% at defense mode
overhangsFilled: 0.25,
holesCreated: 1.0, // +100% at defense mode
overhangsCreated: 0.75,
heightAdded: 1.5,
avgHeight: 1.25,
wellDepthSquared: 0.2,
bumpiness: 0.25,
rowTransitions: 0.3,
},
crisis: {
lineCleared: 0.3, // -30% at crisis
contact: 0.7, // -70% at crisis
overhangsFilled: 0.5,
holesCreated: 2.0, // +200-300% at crisis
overhangsCreated: 1.5,
heightAdded: 3.0,
avgHeight: 2.5,
wellDepthSquared: 0.4,
bumpiness: 0.5,
rowTransitions: 0.6,
},
};
/**
* Adaptive weight scaling based on board height
* As the board fills up, the AI becomes more defensive:
* - Heavily penalizes height increases and hole creation
* - Reduces emphasis on score optimization (line clearing, contact)
* - Prioritizes survival over points
*/
export function getAdaptiveWeights(
baseWeights: AIWeights,
avgHeight: number,
scaling: AdaptiveScaling = DEFAULT_ADAPTIVE_SCALING
): AIWeights {
// Determine which mode and ratio
let mode: AdaptiveScalingMode;
let ratio: number;
let crisisMultiplier = 1.0;
if (avgHeight < 9) {
// Normal mode (0-9): minimal scaling
mode = scaling.normal;
ratio = Math.min(avgHeight / 9, 1.0);
} else if (avgHeight < 12) {
// Defense mode (9-12): moderate scaling
mode = scaling.defense;
ratio = Math.min((avgHeight - 9) / 3, 1.0);
} else {
// Crisis mode (12+): aggressive scaling with multiplier
mode = scaling.crisis;
ratio = Math.min((avgHeight - 12) / 3, 1.0);
crisisMultiplier = 1.5; // Extra aggressive in crisis
}
return {
// Bonuses decrease (focus shifts from score to survival)
lineCleared: baseWeights.lineCleared * (1 - ratio * mode.lineCleared),
contact: baseWeights.contact * (1 - ratio * mode.contact),
overhangsFilled: baseWeights.overhangsFilled * (1 - ratio * mode.overhangsFilled),
// Critical penalties increase
holesCreated: baseWeights.holesCreated * (1 + ratio * mode.holesCreated * crisisMultiplier),
overhangsCreated: baseWeights.overhangsCreated * (1 + ratio * mode.overhangsCreated * crisisMultiplier),
heightAdded: baseWeights.heightAdded * (1 + ratio * mode.heightAdded * crisisMultiplier),
avgHeight: baseWeights.avgHeight * (1 + ratio * mode.avgHeight * crisisMultiplier),
// Moderate adjustments
wellDepthSquared: baseWeights.wellDepthSquared * (1 - ratio * mode.wellDepthSquared),
bumpiness: baseWeights.bumpiness * (1 + ratio * mode.bumpiness),
rowTransitions: baseWeights.rowTransitions * (1 + ratio * mode.rowTransitions),
};
}
function countContacts(board: number[][], piece: Tetromino, position: Position): number {
let contacts = 0;
@ -137,6 +241,22 @@ function countBumpiness(board: number[][]): number {
return bumpiness;
}
function countRowTransitions(board: number[][]): number {
let transitions = 0;
for (let y = 0; y < BOARD_HEIGHT; y++) {
for (let x = 0; x < BOARD_WIDTH - 1; x++) {
// Count transition from filled to empty or empty to filled
if ((board[y][x] === 0) !== (board[y][x + 1] === 0)) {
transitions++;
}
}
}
return transitions;
}
function countOverhangs(board: number[][]): number {
let overhangs = 0;
@ -208,13 +328,12 @@ function evaluatePosition(board: number[][], piece: Tetromino, position: Positio
const { wells, wellDepth } = countWells(newBoard);
const bumpiness = countBumpiness(newBoard);
const avgHeight = heightsAfter.reduce((a, b) => a + b, 0) / heightsAfter.length;
const rowTransitions = countRowTransitions(newBoard);
// Calculate score components using configurable weights
const lineScore = linesCleared * weights.lineCleared;
const contactScore = contacts * weights.contact;
const holesPenalty = holesAfter * weights.holes;
const holesCreatedPenalty = holesCreated * weights.holesCreated;
const overhangPenalty = overhangsAfter * weights.overhangs;
const overhangsCreatedPenalty = overhangsCreated * weights.overhangsCreated;
const overhangsFilledBonus = overhangsFilled * weights.overhangsFilled;
@ -226,15 +345,17 @@ function evaluatePosition(board: number[][], piece: Tetromino, position: Positio
const heightAddedPenalty = heightAdded * weights.heightAdded * heightMultiplier * (1 - contactRatio * 0.7); // Up to 70% reduction
// Wells penalty - exponential with depth to heavily penalize deep gaps
const wellsPenalty = wells * weights.wells + (wellDepth * wellDepth * weights.wellDepthSquared);
const wellsPenalty = (wellDepth * wellDepth * weights.wellDepthSquared);
// Bumpiness penalty - encourage smooth surface
const bumpinessPenalty = bumpiness * weights.bumpiness;
const maxHeightPenalty = maxHeightAfter * weights.maxHeight;
const avgHeightPenalty = avgHeight * weights.avgHeight;
const totalScore = lineScore + contactScore - holesPenalty - holesCreatedPenalty - overhangPenalty - overhangsCreatedPenalty + overhangsFilledBonus - heightAddedPenalty - wellsPenalty - bumpinessPenalty - maxHeightPenalty - avgHeightPenalty;
// Row transitions penalty - encourage smooth, fillable rows
const rowTransitionsPenalty = rowTransitions * weights.rowTransitions;
const totalScore = lineScore + contactScore - holesCreatedPenalty - overhangsCreatedPenalty + overhangsFilledBonus - heightAddedPenalty - wellsPenalty - bumpinessPenalty - avgHeightPenalty - rowTransitionsPenalty;
return {
score: totalScore,
@ -244,11 +365,9 @@ function evaluatePosition(board: number[][], piece: Tetromino, position: Positio
contacts,
contactScore,
holes: holesAfter,
holesPenalty,
holesCreated,
holesCreatedPenalty,
overhangs: overhangsAfter,
overhangPenalty,
overhangsCreated,
overhangsCreatedPenalty,
overhangsFilled,
@ -261,9 +380,10 @@ function evaluatePosition(board: number[][], piece: Tetromino, position: Positio
bumpiness,
bumpinessPenalty,
maxHeight: maxHeightAfter,
maxHeightPenalty,
avgHeight: avgHeight.toFixed(1),
avgHeightPenalty: avgHeightPenalty.toFixed(0),
rowTransitions,
rowTransitionsPenalty,
totalScore,
}
};
@ -271,6 +391,23 @@ function evaluatePosition(board: number[][], piece: Tetromino, position: Positio
export function findBestMove(board: number[][], piece: Tetromino, weights: AIWeights = DEFAULT_WEIGHTS): Move | null {
// Calculate current board average height for adaptive weighting
const columnHeights = [];
for (let x = 0; x < BOARD_WIDTH; x++) {
let height = 0;
for (let y = 0; y < BOARD_HEIGHT; y++) {
if (board[y][x] !== 0) {
height = BOARD_HEIGHT - y;
break;
}
}
columnHeights.push(height);
}
const currentAvgHeight = columnHeights.reduce((sum, h) => sum + h, 0) / BOARD_WIDTH;
// Apply adaptive weights based on current board height
const adaptiveWeights = getAdaptiveWeights(weights, currentAvgHeight);
let bestMove: Move | null = null;
let bestScore = -Infinity;
@ -289,8 +426,8 @@ export function findBestMove(board: number[][], piece: Tetromino, weights: AIWei
// Check if this position is valid
if (!checkCollision(board, currentPiece, dropPosition)) {
// Evaluate this position
const evaluation = evaluatePosition(board, currentPiece, dropPosition, weights);
// Evaluate this position with adaptive weights
const evaluation = evaluatePosition(board, currentPiece, dropPosition, adaptiveWeights);
if (evaluation.score > bestScore) {
bestScore = evaluation.score;

View File

@ -0,0 +1,285 @@
import { NeuralNetwork, TrainingExample } from './neuralNetwork';
import { AIWeights } from './aiPlayer';
import { calculateReward, weightsToOutput, saveNeuralNetwork, getNeuralNetwork } from './neuralAI';
// --- Types ---
export interface GameResult {
score: number;
lines: number;
level: number;
weights: AIWeights;
boardFeatures: number[];
timestamp: number;
}
export interface AIStrategy {
id: string;
name: string;
description: string;
isEnabled: boolean;
logsEnabled?: boolean;
// Called when a game finishes
onGameEnd?(result: GameResult, allHistory: GameResult[]): void;
// Called before training a batch - allows injecting/modifying examples
onTrainingStart?(network: NeuralNetwork, currentBatch: TrainingExample[]): TrainingExample[];
// Called when the AI needs weights - allows overriding the network entirely
onWeightsRequested?(currentWeights: AIWeights, boardState: any): AIWeights | null;
}
// --- Strategy Manager ---
export class StrategyManager {
private static instance: StrategyManager;
private strategies: Map<string, AIStrategy> = new Map();
private storageKey = 'tetris-ai-strategies-config';
private constructor() {
// Config is loaded when strategies are registered
}
public static getInstance(): StrategyManager {
if (!StrategyManager.instance) {
StrategyManager.instance = new StrategyManager();
}
return StrategyManager.instance;
}
public registerStrategy(strategy: AIStrategy): void {
// Load enabled state from config if it exists
const config = this.getStoredConfig();
if (config[strategy.id] !== undefined) {
const stored = config[strategy.id];
// Handle migration from old boolean format
if (typeof stored === 'boolean') {
strategy.isEnabled = stored;
strategy.logsEnabled = true;
} else {
strategy.isEnabled = stored.isEnabled;
strategy.logsEnabled = stored.logsEnabled ?? true;
}
} else {
// Default logs to true if new strategy
if (strategy.logsEnabled === undefined) strategy.logsEnabled = true;
}
this.strategies.set(strategy.id, strategy);
}
public getStrategies(): AIStrategy[] {
return Array.from(this.strategies.values());
}
public toggleStrategy(id: string, enabled: boolean): void {
const strategy = this.strategies.get(id);
if (strategy) {
strategy.isEnabled = enabled;
this.saveConfig();
}
}
public toggleLogs(id: string, enabled: boolean): void {
const strategy = this.strategies.get(id);
if (strategy) {
strategy.logsEnabled = enabled;
this.saveConfig();
}
}
private getStoredConfig(): Record<string, any> {
try {
return JSON.parse(localStorage.getItem(this.storageKey) || '{}');
} catch {
return {};
}
}
private saveConfig(): void {
const config: Record<string, { isEnabled: boolean, logsEnabled: boolean }> = {};
this.strategies.forEach(s => {
config[s.id] = {
isEnabled: s.isEnabled,
logsEnabled: s.logsEnabled ?? true
};
});
localStorage.setItem(this.storageKey, JSON.stringify(config));
}
// --- Hooks ---
public runOnGameEnd(result: GameResult, allHistory: GameResult[]): void {
this.strategies.forEach(s => {
if (s.isEnabled && s.onGameEnd) {
try {
s.onGameEnd(result, allHistory);
} catch (e) {
console.error(`[AI Strategy] Error in ${s.name}.onGameEnd:`, e);
}
}
});
}
public processTrainingBatch(network: NeuralNetwork, batch: TrainingExample[]): TrainingExample[] {
let currentBatch = [...batch];
this.strategies.forEach(s => {
if (s.isEnabled && s.onTrainingStart) {
try {
currentBatch = s.onTrainingStart(network, currentBatch);
} catch (e) {
console.error(`[AI Strategy] Error in ${s.name}.onTrainingStart:`, e);
}
}
});
return currentBatch;
}
public getWeightOverride(currentWeights: AIWeights, boardState: any): AIWeights | null {
for (const s of this.strategies.values()) {
if (s.isEnabled && s.onWeightsRequested) {
try {
const override = s.onWeightsRequested(currentWeights, boardState);
if (override) return override;
} catch (e) {
console.error(`[AI Strategy] Error in ${s.name}.onWeightsRequested:`, e);
}
}
}
return null; // No override
}
}
// --- Implementations ---
export class HallOfFameStrategy implements AIStrategy {
id = 'hall-of-fame';
name = 'Hall of Fame (Experience Replay)';
description = 'Keeps top 20 games and mixes them (50/50) into training to reinforce winning habits.';
isEnabled = true;
logsEnabled = true;
private storageKey = 'tetris-hall-of-fame';
onGameEnd(result: GameResult): void {
try {
const hallOfFame = this.getHallOfFame();
hallOfFame.push(result);
hallOfFame.sort((a: any, b: any) => b.score - a.score);
if (hallOfFame.length > 50) hallOfFame.length = 50;
localStorage.setItem(this.storageKey, JSON.stringify(hallOfFame));
if (this.logsEnabled && result.score > 0) {
// Only log significant updates or if user wants verbose
}
} catch (e) {
if (this.logsEnabled) console.error('[HoF Strategy] Failed to save result', e);
}
}
onTrainingStart(network: NeuralNetwork, batch: TrainingExample[]): TrainingExample[] {
const hallOfFame = this.getHallOfFame();
if (hallOfFame.length === 0) return batch;
const hofExamples: TrainingExample[] = hallOfFame.map((game: any) => {
const reward = calculateReward(game.score, game.lines, game.level);
// Use actual board features if available, otherwise fallback to neutral
const input = game.boardFeatures || Array(10).fill(0.5);
const expectedOutput = weightsToOutput(game.weights);
return { input, expectedOutput, reward };
});
// Mix 50/50
const mixedBatch: TrainingExample[] = [];
const targetSize = batch.length; // Keep same batch size usually, or extend?
// Let's match the original logic: mix recent (batch) and HoF
// Note: The input 'batch' here is usually the recent history batch.
// We will return a new batch that mixes them.
// Take 50% from recent (batch)
const recentCount = Math.ceil(targetSize * 0.5);
for (let i = 0; i < recentCount && i < batch.length; i++) {
mixedBatch.push(batch[batch.length - 1 - i]); // Latest first
}
// Fill rest with HoF
const remainingSlots = targetSize - mixedBatch.length;
for (let i = 0; i < remainingSlots; i++) {
const randomHoF = hofExamples[Math.floor(Math.random() * hofExamples.length)];
mixedBatch.push(randomHoF);
}
return mixedBatch;
}
private getHallOfFame(): any[] {
try {
return JSON.parse(localStorage.getItem(this.storageKey) || '[]');
} catch {
return [];
}
}
}
export class AutoRevertStrategy implements AIStrategy {
id = 'auto-revert';
name = 'Auto-Revert Safety Net';
description = 'Snapshots best performance. If average score drops by 50%, reverts network to backup.';
isEnabled = true;
logsEnabled = true;
private bestNetworkKey = 'tetris-neural-network-best';
private bestPerformanceKey = 'tetris-best-performance';
onGameEnd(result: GameResult, allHistory: GameResult[]): void {
// Calculate recent performance (last 10 games)
const recentGames = allHistory.slice(-50);
if (recentGames.length < 10) return;
const currentAvg = recentGames.reduce((sum, g) => sum + g.score, 0) / recentGames.length;
this.checkAndSaveBest(currentAvg);
this.checkAndRevert(currentAvg);
}
private checkAndSaveBest(currentAvg: number) {
try {
const storedBest = localStorage.getItem(this.bestPerformanceKey);
const bestPerf = storedBest ? parseFloat(storedBest) : 0;
if (currentAvg > bestPerf) {
if (this.logsEnabled) console.log(`[Auto-Revert] New best performance: ${currentAvg} (prev: ${bestPerf}). Saving snapshot.`);
const network = getNeuralNetwork(); // Get current state
localStorage.setItem(this.bestNetworkKey, JSON.stringify(network.toJSON()));
localStorage.setItem(this.bestPerformanceKey, currentAvg.toString());
}
} catch (e) {
if (this.logsEnabled) console.error('[Auto-Revert] Failed to save best', e);
}
}
private checkAndRevert(currentAvg: number) {
try {
const bestPerf = parseFloat(localStorage.getItem(this.bestPerformanceKey) || '0');
// Check for 50% drop, but only if we have a meaningful baseline (> 1000 score)
if (bestPerf > 1000 && currentAvg < bestPerf * 0.5) {
if (this.logsEnabled) console.warn(`[Auto-Revert] COLLAPSE DETECTED (Current: ${currentAvg}, Best: ${bestPerf}). Reverting...`);
const stored = localStorage.getItem(this.bestNetworkKey);
if (stored) {
const data = JSON.parse(stored);
const bestNetwork = NeuralNetwork.fromJSON(data);
saveNeuralNetwork(bestNetwork); // Overwrite IDB/LocalStorage
if (this.logsEnabled) console.log('[Auto-Revert] Network restored to best snapshot.');
}
}
} catch (e) {
if (this.logsEnabled) console.error('[Auto-Revert] Failed to revert', e);
}
}
}
// Initialize and export singleton
export const strategyManager = StrategyManager.getInstance();
strategyManager.registerStrategy(new HallOfFameStrategy());
strategyManager.registerStrategy(new AutoRevertStrategy());

View File

@ -64,12 +64,105 @@ export function createTetromino(type: TetrominoType, rotation: number = 0): Tetr
};
}
export function getRandomTetromino(): Tetromino {
const types: TetrominoType[] = ['I', 'O', 'T', 'S', 'Z', 'J', 'L'];
const randomType = types[Math.floor(Math.random() * types.length)];
// TGM3 Randomizer - provides better piece distribution
let tgm3Generator: Generator<TetrominoType, never, unknown> | null = null;
let classicGenerator: Generator<TetrominoType, never, unknown> | null = null;
function* tgm3Randomizer(): Generator<TetrominoType, never, unknown> {
const pieces: TetrominoType[] = ["I", "J", "L", "O", "S", "T", "Z"];
let order: TetrominoType[] = [];
// Create 35 pool (5 of each piece)
let pool: TetrominoType[] = pieces.concat(pieces, pieces, pieces, pieces);
// First piece special conditions (I, J, L, or T only)
const firstPiece: TetrominoType = ["I", "J", "L", "T"][Math.floor(Math.random() * 4)] as TetrominoType;
yield firstPiece;
let history: TetrominoType[] = ["S", "Z", "S", firstPiece];
while (true) {
let roll: number;
let i: number = 0;
let piece: TetrominoType = "I";
// Roll for piece (up to 6 attempts)
for (roll = 0; roll < 6; ++roll) {
i = Math.floor(Math.random() * 35);
piece = pool[i];
if (!history.includes(piece) || roll === 5) {
break;
}
if (order.length) pool[i] = order[0];
}
// Update piece order
if (order.includes(piece)) {
order.splice(order.indexOf(piece), 1);
}
order.push(piece);
pool[i] = order[0];
// Update history
history.shift();
history[3] = piece;
yield piece;
}
}
// Classic Randomizer (History 3, Roll 4) - "Stupid" logic
function* classicRandomizer(historyLength: number = 3): Generator<TetrominoType, never, unknown> {
const pieces: TetrominoType[] = ["I", "J", "L", "O", "S", "T", "Z"];
// Initialize history with dummy values
const history: TetrominoType[] = (["Z", "Z", "Z"] as TetrominoType[]).slice(0, historyLength); // Fill with duplicates to encourage start variety? No, just random.
// Actually, TGM classic starts with history ZZZZ or similar.
while (true) {
let piece: TetrominoType = pieces[Math.floor(Math.random() * pieces.length)];
// Roll up to 4 times to find a piece not in history
for (let i = 0; i < 4; i++) {
if (!history.includes(piece)) {
break;
}
piece = pieces[Math.floor(Math.random() * pieces.length)];
}
history.shift();
history.push(piece);
yield piece;
}
}
function getNextTGM3Piece(): TetrominoType {
if (!tgm3Generator) {
tgm3Generator = tgm3Randomizer();
}
return tgm3Generator.next().value;
}
function getNextClassicPiece(): TetrominoType {
if (!classicGenerator) {
classicGenerator = classicRandomizer(3);
}
return classicGenerator.next().value;
}
export type RandomizerMode = 'tgm3' | 'classic';
export function getRandomTetromino(mode: RandomizerMode = 'tgm3'): Tetromino {
const randomType = mode === 'classic' ? getNextClassicPiece() : getNextTGM3Piece();
return createTetromino(randomType);
}
// Reset the randomizer (useful for new games)
export function resetRandomizer(): void {
tgm3Generator = null;
classicGenerator = null;
}
export function rotateTetromino(piece: Tetromino): Tetromino {
const rotations = TETROMINO_SHAPES[piece.type];
const currentIndex = rotations.findIndex(
@ -82,6 +175,19 @@ export function rotateTetromino(piece: Tetromino): Tetromino {
};
}
export function rotateTetrominoReverse(piece: Tetromino): Tetromino {
const rotations = TETROMINO_SHAPES[piece.type];
const currentIndex = rotations.findIndex(
(shape) => JSON.stringify(shape) === JSON.stringify(piece.shape)
);
// Go backwards: subtract 1 and add length to handle negative modulo
const prevIndex = (currentIndex - 1 + rotations.length) % rotations.length;
return {
...piece,
shape: rotations[prevIndex],
};
}
export function checkCollision(
board: number[][],
piece: Tetromino,

View File

@ -3,75 +3,67 @@ import { NeuralNetwork, TrainingExample } from './neuralNetwork';
import { AIWeights } from './aiPlayer';
const NETWORK_STORAGE_KEY = 'tetris-neural-network';
const NETWORK_VERSION_KEY = 'tetris-neural-network-version';
const CURRENT_VERSION = '5'; // Increment when network structure changes (now with rebalanced weights - v5)
const MANUAL_OVERRIDE_KEY = 'tetris-neural-manual-override';
import { strategyManager } from './aiStrategies';
// Network configuration
const NETWORK_CONFIG = {
inputSize: 13, // 13 board features (same as weight count)
hiddenLayers: [20, 15], // Two hidden layers
outputSize: 13, // 13 weight outputs
export const NETWORK_CONFIG = {
inputSize: 10,
hiddenLayers: [16, 12],
outputSize: 10,
learningRate: 0.01,
};
// Extract features from board state for neural network input
// Extract board features from debug info for neural network input
export const extractBoardFeatures = (debugInfo: any): number[] => {
if (!debugInfo) {
return Array(13).fill(0);
}
// Normalize features to 0-1 range for better training
// Normalize all features to 0-1 range
return [
Math.min(debugInfo.linesCleared / 4, 1), // 0-4 lines max
Math.min(debugInfo.contacts / 10, 1), // 0-10 contacts typical
Math.min(debugInfo.holes / 20, 1), // 0-20 holes
Math.min(debugInfo.holesCreated / 5, 1), // 0-5 new holes
Math.min(debugInfo.overhangs / 20, 1), // 0-20 overhangs
Math.min(debugInfo.overhangsCreated / 5, 1), // 0-5 new overhangs
Math.min(debugInfo.linesCleared / 4, 1), // 0-4 lines
Math.min(debugInfo.contacts / 12, 1), // 0-12 contacts
Math.min(debugInfo.holesCreated / 5, 1), // 0-5 holes
Math.min(debugInfo.overhangsCreated / 5, 1), // 0-5 overhangs
Math.min(debugInfo.overhangsFilled / 5, 1), // 0-5 filled
Math.min(debugInfo.heightAdded / 10, 1), // 0-10 height added
Math.min(debugInfo.wells / 10, 1), // 0-10 wells
Math.min(debugInfo.wellDepth / 10, 1), // 0-10 depth
Math.min(debugInfo.bumpiness / 20, 1), // 0-20 bumpiness
Math.min(debugInfo.maxHeight / 20, 1), // 0-20 max height
Math.min(parseFloat(debugInfo.avgHeight) / 10, 1), // 0-10 avg height
Math.min((debugInfo.rowTransitions || 0) / 50, 1), // 0-50 row transitions
];
};
// Convert neural network output to weights
export const outputToWeights = (output: number[]): AIWeights => {
// Scale outputs to reasonable weight ranges
// Scale outputs to reasonable weight ranges - heavily tuned for stability over greed
return {
lineCleared: output[0] * 20000, // 0-20000
contact: output[1] * 500, // 0-500
holes: output[2] * 2000, // 0-2000
holesCreated: output[3] * 2000, // 0-2000
overhangs: output[4] * 2000, // 0-2000
overhangsCreated: output[5] * 3000, // 0-3000
overhangsFilled: output[6] * 1000, // 0-1000
heightAdded: output[7] * 2000, // 0-2000
wells: output[8] * 500, // 0-500
wellDepthSquared: output[9] * 500, // 0-500
bumpiness: output[10] * 200, // 0-200
maxHeight: output[11] * 200, // 0-200
avgHeight: output[12] * 100, // 0-100
lineCleared: output[0] * 1000, // was 20000 - drastically reduced to prevent suicide for lines
contact: output[1] * 500, // unchanged
holesCreated: output[2] * 3000, // was 2000 - increased to penalize holes more
overhangsCreated: output[3] * 3000, // unchanged
overhangsFilled: output[4] * 1000, // unchanged
heightAdded: output[5] * 3000, // was 2000 - significantly increased penalty cap
wellDepthSquared: output[6] * 1000, // was 500
bumpiness: output[7] * 500, // was 200
avgHeight: output[8] * 200, // was 100
rowTransitions: output[9] * 200, // was 100
};
};
// Convert weights to neural network target output
// Convert weights to neural network output format (for training)
export const weightsToOutput = (weights: AIWeights): number[] => {
return [
Math.min(weights.lineCleared / 20000, 1),
Math.min(weights.lineCleared / 1000, 1),
Math.min(weights.contact / 500, 1),
Math.min(weights.holes / 2000, 1),
Math.min(weights.holesCreated / 2000, 1),
Math.min(weights.overhangs / 2000, 1),
Math.min(weights.holesCreated / 3000, 1),
Math.min(weights.overhangsCreated / 3000, 1),
Math.min(weights.overhangsFilled / 1000, 1),
Math.min(weights.heightAdded / 2000, 1),
Math.min(weights.wells / 500, 1),
Math.min(weights.wellDepthSquared / 500, 1),
Math.min(weights.bumpiness / 200, 1),
Math.min(weights.maxHeight / 200, 1),
Math.min(weights.avgHeight / 100, 1),
Math.min(weights.heightAdded / 3000, 1),
Math.min(weights.wellDepthSquared / 1000, 1),
Math.min(weights.bumpiness / 500, 1),
Math.min(weights.avgHeight / 200, 1),
Math.min(weights.rowTransitions / 200, 1),
];
};
@ -89,40 +81,86 @@ export const calculateReward = (score: number, lines: number, level: number): nu
// Get or create neural network
export const getNeuralNetwork = (): NeuralNetwork => {
try {
const storedVersion = localStorage.getItem(NETWORK_VERSION_KEY);
const stored = localStorage.getItem(NETWORK_STORAGE_KEY);
// If version mismatch, clear old network and start fresh
if (storedVersion !== CURRENT_VERSION) {
localStorage.removeItem(NETWORK_STORAGE_KEY);
localStorage.removeItem('tetris-game-history'); // Clear old history without board features
localStorage.setItem(NETWORK_VERSION_KEY, CURRENT_VERSION);
const newNetwork = new NeuralNetwork(NETWORK_CONFIG);
return newNetwork;
}
// Version matches - try to load stored network
if (stored) {
try {
return NeuralNetwork.fromJSON(stored);
} catch (e) {
console.warn('Failed to load neural network, creating new one');
const data = JSON.parse(stored);
return NeuralNetwork.fromJSON(data);
} catch (error) {
console.error('[Neural AI] Failed to load network:', error);
}
}
// Create new network if loading failed
return new NeuralNetwork(NETWORK_CONFIG);
} catch (error) {
console.error('[Neural AI] Failed to access localStorage or initialize network:', error);
return new NeuralNetwork(NETWORK_CONFIG);
}
};
// Save neural network
// Save neural network to localStorage
export const saveNeuralNetwork = (network: NeuralNetwork): void => {
localStorage.setItem(NETWORK_STORAGE_KEY, network.toJSON());
try {
const data = network.toJSON();
localStorage.setItem(NETWORK_STORAGE_KEY, JSON.stringify(data));
} catch (error) {
console.error('[Neural AI] Failed to save network:', error);
}
};
// Notify strategies about game end
export const notifyGameEnd = (gameResult: any, allHistory: any[]): void => {
strategyManager.runOnGameEnd(gameResult, allHistory);
};
// Train network on game history
export const trainOnHistory = (network: NeuralNetwork): void => {
const history = JSON.parse(localStorage.getItem('tetris-game-history') || '[]');
try {
const historyData = localStorage.getItem('tetris-game-history');
const history = historyData ? JSON.parse(historyData) : [];
if (history.length === 0) return;
const examples: TrainingExample[] = history.map((game: any) => {
// We don't have board features from history, so we'll use a simplified approach
// In a real implementation, you'd store board states during gameplay
// Check sample size configuration
// Check sample size configuration
const sampleSize = parseInt(localStorage.getItem('tetris-training-sample-size') || '3');
const gameCounter = parseInt(localStorage.getItem('tetris-game-counter') || '0');
// Use game counter (total games played) instead of history length (which caps at 1000)
if (gameCounter % sampleSize !== 0) {
console.log(`[Neural AI] Skipping training (Sample Size: ${sampleSize}, Game: ${gameCounter})`);
return;
}
// Create base training examples from recent history
let examples: TrainingExample[] = history.map((game: any) => {
const reward = calculateReward(game.score, game.lines, game.level);
// Use dummy input (in real version, store actual board states)
const input = Array(13).fill(0.5);
// Use actual board features if available, otherwise fallback to neutral
const input = game.boardFeatures || Array(10).fill(0.5);
const expectedOutput = weightsToOutput(game.weights);
return { input, expectedOutput, reward };
});
// Apply strategies (modifiers)
examples = strategyManager.processTrainingBatch(network, examples);
// Train in batches
const batchSize = 10;
for (let i = 0; i < examples.length; i += batchSize) {
@ -131,15 +169,66 @@ export const trainOnHistory = (network: NeuralNetwork): void => {
}
saveNeuralNetwork(network);
} catch (error) {
console.error('[Neural AI] Failed to train on history:', error);
}
};
// Get weights from neural network prediction
// Get weights from neural network based on current board state
export const getNeuralWeights = (boardFeatures?: number[]): AIWeights => {
const network = getNeuralNetwork();
// If no features provided, use neutral input
const input = boardFeatures || Array(13).fill(0.5);
// Check for manual override
const override = getManualOverride();
if (override) {
return override;
}
// Use provided features or default to neutral state
const input = boardFeatures || Array(10).fill(0.5);
const output = network.predict(input);
return outputToWeights(output);
};
// Manual override functions
export const setManualOverride = (weights: AIWeights): void => {
localStorage.setItem(MANUAL_OVERRIDE_KEY, JSON.stringify(weights));
};
export const getManualOverride = (): AIWeights | null => {
const stored = localStorage.getItem(MANUAL_OVERRIDE_KEY);
if (stored) {
try {
return JSON.parse(stored);
} catch {
return null;
}
}
return null;
};
export const clearManualOverride = (): void => {
localStorage.removeItem(MANUAL_OVERRIDE_KEY);
};
export const hasManualOverride = (): boolean => {
return localStorage.getItem(MANUAL_OVERRIDE_KEY) !== null;
};
// Train network on manual adjustment
export const trainOnManualAdjustment = (
network: NeuralNetwork,
boardFeatures: number[],
manualWeights: AIWeights,
reward: number = 1.0 // High reward for manual adjustments
): void => {
const example: TrainingExample = {
input: boardFeatures,
expectedOutput: weightsToOutput(manualWeights),
reward,
};
network.train([example]);
saveNeuralNetwork(network);
};

View File

@ -175,8 +175,6 @@ export const ListLayout = ({
return <div className="p-8 text-center text-muted-foreground">No posts found.</div>;
}
console.log('ListLayout FeedPosts', feedPosts);
if (!isMobile) {
// Desktop Split Layout
return (

View File

@ -56,7 +56,6 @@ export const PageActions = ({
const [showPagePicker, setShowPagePicker] = useState(false);
const [showCreationWizard, setShowCreationWizard] = useState(false);
const [showCategoryManager, setShowCategoryManager] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const baseUrl = import.meta.env.VITE_SERVER_IMAGE_API_URL || window.location.origin;
@ -261,40 +260,14 @@ export const PageActions = ({
const handleExportMarkdown = () => {
try {
let content = processPageContent(page.content);
// Get current URL and append .md extension
const currentUrl = new URL(window.location.href);
const pathWithExtension = currentUrl.pathname + '.md';
const exportUrl = `${currentUrl.origin}${pathWithExtension}${currentUrl.search}`;
// Generate TOC
const lines = content.split('\n');
let toc = '# Table of Contents\n\n';
let hasHeadings = false;
lines.forEach(line => {
// Determine header level
const match = line.match(/^(#{1,3})\s+(.+)/);
if (match) {
hasHeadings = true;
const level = match[1].length;
const text = match[2];
const slug = getSlug(text);
const indent = ' '.repeat(level - 1);
toc += `${indent}- [${text}](#${slug})\n`;
}
});
if (hasHeadings) {
content = `${toc}\n---\n\n${content}`;
}
const blob = new Blob([content], { type: 'text/markdown' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.md`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
toast.success("Markdown downloaded");
// Open in new tab to trigger download
window.open(exportUrl, '_blank');
toast.success("Markdown export opened");
} catch (e) {
console.error("Markdown export failed", e);
toast.error("Failed to export Markdown");
@ -391,21 +364,18 @@ draft: ${!page.visible}
};
const handleExportPdf = async () => {
setIsGeneratingPdf(true);
toast.info("Generating PDF...");
try {
const link = document.createElement('a');
link.href = `${baseUrl}/api/render/pdf/page/${page.id}`;
link.target = "_blank";
link.download = `${(page.title || 'page').replace(/[^a-z0-9]/gi, '_')}.pdf`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Get current URL and append .pdf extension
const currentUrl = new URL(window.location.href);
const pathWithExtension = currentUrl.pathname + '.pdf';
const exportUrl = `${currentUrl.origin}${pathWithExtension}${currentUrl.search}`;
// Open in new tab to trigger download
window.open(exportUrl, '_blank');
toast.success("PDF export opened");
} catch (e) {
console.error(e);
toast.error("Failed to download PDF");
} finally {
setIsGeneratingPdf(false);
toast.error("Failed to export PDF");
}
};
@ -423,28 +393,28 @@ draft: ${!page.visible}
return (
<div className={cn("flex items-center gap-2", className)}>
{/* Share Menu */}
{/* Export Menu */}
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm" className="gap-2">
<Share2 className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Share</T></span>}
<Download className="h-4 w-4" />
{showLabels && <span className="hidden md:inline"><T>Export</T></span>}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Share & Export</DropdownMenuLabel>
<DropdownMenuLabel>Export & Share</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyLink}>
<LinkIcon className="h-4 w-4 mr-2" />
<span>Copy Link</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportMarkdown}>
<Download className="h-4 w-4 mr-2" />
<FileText className="h-4 w-4 mr-2" />
<span>Export Markdown</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportPdf} disabled={isGeneratingPdf}>
<DropdownMenuItem onClick={handleExportPdf}>
<FileText className="h-4 w-4 mr-2" />
<span>{isGeneratingPdf ? 'Generating PDF...' : 'Export PDF'}</span>
<span>Export PDF</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleExportAstro}>
<FileText className="mr-2 h-4 w-4" />

View File

@ -252,6 +252,7 @@ const MediaGrid = ({
};
const handleMediaClick = (mediaId: string, type: MediaType, index: number) => {
console.log('handleMediaClick', mediaId, type, index);
// Handle Page navigation
if (type === 'page-intern') {
const item = mediaItems.find(i => i.id === mediaId);
@ -528,10 +529,8 @@ const MediaGrid = ({
{section.items.map((item, index) => {
const itemType = normalizeMediaType(item.type);
const isVideo = isVideoType(itemType);
// For images, convert URL to optimized format
const displayUrl = item.image_url;
if (isVideo) {
return (
<div key={item.id} className="relative group">

View File

@ -138,6 +138,7 @@ const TopNavigation = () => {
</Button>
{/* Magic Button - AI Image Generator */}
{user && (
<Button
variant="ghost"
size="sm"
@ -147,6 +148,7 @@ const TopNavigation = () => {
>
<Wand2 className="h-4 w-4" />
</Button>
)}
{/* Profile Grid Button - Direct to profile feed */}
{user && (
@ -202,6 +204,7 @@ const TopNavigation = () => {
<ThemeToggle />
{user && (
<Button
variant="ghost"
size="sm"
@ -213,7 +216,7 @@ const TopNavigation = () => {
<Activity className="h-4 w-4" />
</Link>
</Button>
)}
{user ? (
<DropdownMenu>

View File

@ -827,8 +827,25 @@ export const fetchFeedPostsPaginated = async (
// 6. Transform Pages
// We treat Pages as FeedPosts with a single "virtual" picture of type 'page-intern'
// Fetch author profiles for pages
let pageProfileMap = new Map();
if (pagesData.length > 0) {
const pageOwnerIds = Array.from(new Set(pagesData.map((p: any) => p.owner).filter(Boolean)));
if (pageOwnerIds.length > 0) {
const { data: pageProfiles } = await supabase
.from('profiles')
.select('user_id, username, display_name, avatar_url')
.in('user_id', pageOwnerIds);
if (pageProfiles) {
pageProfileMap = new Map(pageProfiles.map(p => [p.user_id, p]));
}
}
}
const transformedPages: FeedPost[] = pagesData.map(page => {
// Resolve mage
// Resolve image
let displayImage = "https://picsum.photos/640"; // Fallback
const requiredPicId = pageIdToImageId.get(page.id);
if (requiredPicId) {
@ -860,6 +877,7 @@ export const fetchFeedPostsPaginated = async (
description: null, // Could parse content?
created_at: page.created_at,
user_id: page.owner,
author: pageProfileMap.get(page.owner), // Add author profile
pictures: [virtualPic],
cover: virtualPic,
likes_count: 0,
@ -913,7 +931,7 @@ export const mapFeedPostsToMediaItems = (posts: FeedPost[], sortBy: 'latest' | '
description: post.description,
image_url: cover.image_url,
thumbnail_url: cover.thumbnail_url,
type: cover.mediaType as MediaType,
type: ((cover as any).type || (cover as any).mediaType) as MediaType, // Handle both legacy 'type' and new 'mediaType'
meta: cover.meta,
created_at: post.created_at,
user_id: post.user_id,

View File

@ -18,7 +18,7 @@ import { JSONSchema } from 'openai/lib/jsonschema';
import { createImage as createImageRouter, editImage as editImageRouter } from '@/lib/image-router';
import { generateTextWithImagesTool } from '@/lib/markdownImageTools';
import { createPageTool } from '@/lib/pageTools';
import { getUserOpenAIKey } from '@/lib/db';
// import { getUserOpenAIKey } from '@/lib/db';
type LogFunction = (level: string, message: string, data?: any) => void;
@ -72,25 +72,17 @@ const PRESET_TOOLS: Record<PresetType, (apiKey?: string) => RunnableToolFunction
],
};
// Get user's OpenAI API key from user_secrets
const getOpenAIApiKey = async (): Promise<string | null> => {
// Get user's session token for proxy authentication
const getAuthToken = async (): Promise<string | null> => {
try {
const { data: { user } } = await supabase.auth.getUser();
if (!user) {
consoleLogger.error('No authenticated user found');
const { data: { session } } = await supabase.auth.getSession();
if (!session?.access_token) {
consoleLogger.error('No authenticated session found');
return null;
}
const apiKey = await getUserOpenAIKey(user.id);
if (!apiKey) {
consoleLogger.error('No OpenAI API key found in user secrets. Please add your OpenAI API key in your profile settings.');
return null;
}
return apiKey;
return session.access_token;
} catch (error) {
consoleLogger.error('Error getting OpenAI API key:', error);
consoleLogger.error('Error getting auth token:', error);
return null;
}
};
@ -98,16 +90,28 @@ const getOpenAIApiKey = async (): Promise<string | null> => {
// Create OpenAI client
export const createOpenAIClient = async (apiKey?: string): Promise<OpenAI | null> => {
const key = apiKey || await getOpenAIApiKey();
// We use the Supabase session token as the "apiKey" for the proxy
// If a legacy OpenAI key (sk-...) is passed, we ignore it and use the session token
let token = apiKey;
if (!key) {
consoleLogger.error('No OpenAI API key found. Please provide an API key or set it in your profile.');
if (!token || token.startsWith('sk-')) {
if (token?.startsWith('sk-')) {
consoleLogger.warn('Legacy OpenAI key detected and ignored. Using Supabase session token for proxy.');
}
token = (await getAuthToken()) || undefined;
}
if (!token) {
consoleLogger.error('No authentication token found. Please sign in.');
return null;
}
try {
console.log('[createOpenAIClient] apiKey arg:', apiKey ? apiKey.substring(0, 10) + '...' : 'undefined');
console.log('[createOpenAIClient] resolved token:', token ? token.substring(0, 10) + '...' : 'null');
return new OpenAI({
apiKey: key,
apiKey: token, // This is sent as Bearer token to our proxy
baseURL: `${import.meta.env.VITE_SERVER_URL || 'http://localhost:3333'}/api/openai/v1`, // Use our server proxy with absolute URL
dangerouslyAllowBrowser: true // Required for client-side usage
});
} catch (error) {

View File

@ -955,7 +955,7 @@ const Post = ({ postId: propPostId, embedded = false, className }: PostProps) =>
return (
<div className={containerClassName}>
<div className={embedded ? "w-full h-full" : "w-full max-w-[1600px] mx-auto"}>
<div className={embedded ? "w-full h-full" : "w-full h-full max-w-[1600px] mx-auto"}>
{viewMode === 'article' ? (
<ArticleRenderer {...rendererProps} mediaItem={mediaItem} />

View File

@ -33,7 +33,6 @@ export const ExportDropdown: React.FC<ExportDropdownProps> = ({
className
}) => {
const [isZipping, setIsZipping] = useState(false);
const [isGeneratingPdf, setIsGeneratingPdf] = useState(false);
const [isEmbedDialogOpen, setIsEmbedDialogOpen] = useState(false);
const [isEmailDialogOpen, setIsEmailDialogOpen] = useState(false);
const [emailHtml, setEmailHtml] = useState('');
@ -65,19 +64,18 @@ export const ExportDropdown: React.FC<ExportDropdownProps> = ({
const handleExportPdf = async () => {
if (!post?.id) return;
setIsGeneratingPdf(true);
toast.info("Downloading PDF...");
try {
const link = document.createElement('a');
link.href = `${baseUrl}/api/render/pdf/${post.id}`;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
// Get current URL and append .pdf extension
const currentUrl = new URL(window.location.href);
const pathWithExtension = currentUrl.pathname + '.pdf';
const exportUrl = `${currentUrl.origin}${pathWithExtension}${currentUrl.search}`;
// Open in new tab to trigger download
window.open(exportUrl, '_blank');
toast.success("PDF export opened");
} catch (e) {
console.error(e);
toast.error("Failed to download PDF");
} finally {
setIsGeneratingPdf(false);
toast.error("Failed to export PDF");
}
};
@ -191,12 +189,12 @@ export const ExportDropdown: React.FC<ExportDropdownProps> = ({
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="sm" className="h-8 gap-2 px-2 text-muted-foreground hover:text-primary">
<Share2 className="h-4 w-4" />
<span className="hidden lg:inline">Share</span>
<Download className="h-4 w-4" />
<span className="hidden lg:inline">Export</span>
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuLabel>Share & Export</DropdownMenuLabel>
<DropdownMenuLabel>Export & Share</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem onClick={handleCopyLink}>
@ -211,9 +209,9 @@ export const ExportDropdown: React.FC<ExportDropdownProps> = ({
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={handleExportPdf} disabled={isGeneratingPdf}>
<DropdownMenuItem onClick={handleExportPdf}>
<FileText className="h-4 w-4 mr-2" />
<span>{isGeneratingPdf ? 'Generating PDF...' : 'Export PDF'}</span>
<span>Export PDF</span>
</DropdownMenuItem>
<DropdownMenuItem onClick={handleDownloadZip} disabled={isZipping}>

View File

@ -73,10 +73,10 @@ export const CompactRenderer: React.FC<PostRendererProps> = (props) => {
</div>
{/* Desktop layout: Media on left, content on right */}
<div className="overflow-hidden group lg:h-full">
<div className="overflow-hidden-x group h-full">
<div className="grid grid-cols-1 lg:grid-cols-2 h-full">
{/* Left Column - Media */}
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative lg:h-full`}>
<div className={`${isVideo ? 'aspect-video' : 'aspect-square'} lg:aspect-auto bg-background border flex flex-col relative h-full`}>
{/* Desktop Gallery - Combines Media Viewer + Filmstrip */}
<div className="hidden lg:block h-full">