playtime - 4/5
39
packages/ui/docs/DEPLOY_INSTRUCTIONS.md
Normal 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.
|
||||
|
||||
223
packages/ui/docs/QUICKSTART_MUX.md
Normal 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! 🎬
|
||||
|
||||
89
packages/ui/docs/SETUP_MUX_SECRETS.md
Normal 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
|
||||
|
||||
49
packages/ui/docs/caching.md
Normal 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.
|
||||
117
packages/ui/docs/canvas-edit.md
Normal 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.
|
||||
82
packages/ui/docs/canvas-html.md
Normal 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/)
|
||||
62
packages/ui/docs/context-aware-content.md
Normal 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").
|
||||
45
packages/ui/docs/custom-canvas.md
Normal 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).
|
||||
48
packages/ui/docs/database-todos.md
Normal 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".
|
||||
169
packages/ui/docs/db-caching.md
Normal 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
@ -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
@ -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
@ -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
@ -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)
|
||||
44
packages/ui/docs/layouts/readme.md
Normal 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
@ -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).
|
||||
64
packages/ui/docs/markdown-html-widget.md
Normal 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.
|
||||
306
packages/ui/docs/mux-integration.md
Normal 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)
|
||||
|
||||
53
packages/ui/docs/overview-todos.md
Normal 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.
|
||||
142
packages/ui/docs/overview.md
Normal 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.
|
||||
|
||||

|
||||
|
||||
## 🚀 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.
|
||||
|
||||

|
||||
|
||||
## 📤 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.*
|
||||
50
packages/ui/docs/page-gen.md
Normal 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.
|
||||
132
packages/ui/docs/pages-wizard.md
Normal 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
@ -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
@ -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.
|
||||
58
packages/ui/docs/pwa-sharing.md
Normal 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).
|
||||
630
packages/ui/docs/render-post.md
Normal 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
@ -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
|
||||
|
||||
BIN
packages/ui/docs/screenshots/auth.png
Normal file
|
After Width: | Height: | Size: 2.6 MiB |
BIN
packages/ui/docs/screenshots/debug-wizard-error.png
Normal file
|
After Width: | Height: | Size: 933 KiB |
BIN
packages/ui/docs/screenshots/design-annotation-system.png
Normal file
|
After Width: | Height: | Size: 1.0 MiB |
BIN
packages/ui/docs/screenshots/home.png
Normal file
|
After Width: | Height: | Size: 2.8 MiB |
BIN
packages/ui/docs/screenshots/new-post.png
Normal file
|
After Width: | Height: | Size: 54 KiB |
BIN
packages/ui/docs/screenshots/post-compact.png
Normal file
|
After Width: | Height: | Size: 197 KiB |
BIN
packages/ui/docs/screenshots/search.png
Normal file
|
After Width: | Height: | Size: 23 KiB |
BIN
packages/ui/docs/screenshots/wizard.png
Normal file
|
After Width: | Height: | Size: 2.7 MiB |
41
packages/ui/docs/security.md
Normal 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.
|
||||
619
packages/ui/docs/supabase.md
Normal 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)
|
||||
172
packages/ui/docs/templates.md
Normal 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
|
||||
|
||||
|
||||
98
packages/ui/docs/tetris-cloud-architecture.md
Normal 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.
|
||||
89
packages/ui/docs/tetris-llm.md
Normal 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".
|
||||
69
packages/ui/docs/tetris-neural-ai.md
Normal 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.
|
||||
332
packages/ui/docs/tetris-neural.md
Normal 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.
|
||||
84
packages/ui/docs/tetris-strategies.md
Normal 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
@ -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*
|
||||
35
packages/ui/src/apps/tetris/AIStrategyControl.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
109
packages/ui/src/apps/tetris/AIStrategyPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
477
packages/ui/src/apps/tetris/AIWeightsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
251
packages/ui/src/apps/tetris/ControlsPanel.tsx
Normal 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';
|
||||
};
|
||||
@ -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 (<5% change)</p>
|
||||
<p className="text-xs text-gray-500">No significant changes</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
@ -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(() => {
|
||||
|
||||
50
packages/ui/src/apps/tetris/NextPieceDisplay.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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>
|
||||
|
||||
150
packages/ui/src/apps/tetris/StatsPanel.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 >
|
||||
);
|
||||
};
|
||||
|
||||
|
||||
177
packages/ui/src/apps/tetris/TrainingDataModal.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
390
packages/ui/src/apps/tetris/WeightsHistoryChart.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
@ -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 }) => (
|
||||
|
||||
@ -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;
|
||||
|
||||
285
packages/ui/src/apps/tetris/aiStrategies.ts
Normal 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());
|
||||
@ -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,
|
||||
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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 (
|
||||
|
||||
@ -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" />
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -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} />
|
||||
|
||||
@ -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}>
|
||||
|
||||
@ -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">
|
||||
|
||||