docs: Add comprehensive implementation plans for all todo items
Created detailed markdown plans for all items in todo.md: 1. 01-playwright-scaffolding.md - Base Playwright infrastructure 2. 02-magnitude-tests-comprehensive.md - Complete test coverage 3. 03-stream-ai-to-deepgram-tts.md - TTS latency optimization 4. 04-fix-galaxy-node-clicking.md - Galaxy navigation bugs 5. 05-dark-light-mode-theme.md - Dark/light mode with dynamic favicons 6. 06-fix-double-border-desktop.md - UI polish 7. 07-delete-backup-files.md - Code cleanup 8. 08-ai-transition-to-edit.md - Intelligent node creation flow 9. 09-umap-minimum-nodes-analysis.md - Technical analysis Each plan includes: - Detailed problem analysis - Proposed solutions with code examples - Manual Playwright MCP testing strategy - Magnitude test specifications - Implementation steps - Success criteria Ready to implement in sequence. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
404
plans/01-playwright-scaffolding.md
Normal file
404
plans/01-playwright-scaffolding.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# Plan: Add Playwright Scaffolding Files
|
||||
|
||||
**Priority:** CRITICAL - Must be done first
|
||||
**Dependencies:** None
|
||||
**Affects:** All future testing work
|
||||
|
||||
## Overview
|
||||
|
||||
Create base Playwright scaffolding files to improve efficiency of both manual Playwright MCP testing and Magnitude test automation. This infrastructure will make it easier to write, run, and maintain browser-based tests.
|
||||
|
||||
## Current State
|
||||
|
||||
- No Playwright configuration file exists
|
||||
- No test helpers or utilities for common operations
|
||||
- No fixtures for authenticated states
|
||||
- Manual testing with Playwright MCP is inefficient
|
||||
- Magnitude tests will lack reusable components
|
||||
|
||||
## Proposed Solution
|
||||
|
||||
Create a complete Playwright testing infrastructure with:
|
||||
|
||||
### 1. Core Configuration Files
|
||||
|
||||
#### `playwright.config.ts`
|
||||
```typescript
|
||||
import { defineConfig, devices } from '@playwright/test';
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './tests/playwright',
|
||||
fullyParallel: true,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 2 : 0,
|
||||
workers: process.env.CI ? 1 : undefined,
|
||||
reporter: 'html',
|
||||
|
||||
use: {
|
||||
baseURL: process.env.PLAYWRIGHT_BASE_URL || 'http://localhost:3000',
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
},
|
||||
|
||||
projects: [
|
||||
// Setup authentication
|
||||
{ name: 'setup', testMatch: /.*\.setup\.ts/ },
|
||||
|
||||
// Desktop browsers
|
||||
{
|
||||
name: 'chromium',
|
||||
use: {
|
||||
...devices['Desktop Chrome'],
|
||||
storageState: 'tests/playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
{
|
||||
name: 'firefox',
|
||||
use: {
|
||||
...devices['Desktop Firefox'],
|
||||
storageState: 'tests/playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
|
||||
// Mobile browsers
|
||||
{
|
||||
name: 'mobile-chrome',
|
||||
use: {
|
||||
...devices['Pixel 5'],
|
||||
storageState: 'tests/playwright/.auth/user.json',
|
||||
},
|
||||
dependencies: ['setup'],
|
||||
},
|
||||
],
|
||||
|
||||
webServer: {
|
||||
command: 'pnpm dev',
|
||||
url: 'http://localhost:3000',
|
||||
reuseExistingServer: !process.env.CI,
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
#### `.gitignore` updates
|
||||
```
|
||||
# Playwright
|
||||
tests/playwright/.auth/
|
||||
test-results/
|
||||
playwright-report/
|
||||
playwright/.cache/
|
||||
```
|
||||
|
||||
### 2. Authentication Setup
|
||||
|
||||
#### `tests/playwright/auth.setup.ts`
|
||||
```typescript
|
||||
import { test as setup, expect } from '@playwright/test';
|
||||
|
||||
const authFile = 'tests/playwright/.auth/user.json';
|
||||
|
||||
setup('authenticate', async ({ page }) => {
|
||||
// Check if auth already exists and is valid
|
||||
const authCookie = process.env.PLAYWRIGHT_AUTH_COOKIE;
|
||||
|
||||
if (authCookie) {
|
||||
// Use existing auth from environment
|
||||
await page.context().addCookies([{
|
||||
name: 'ponderants-auth',
|
||||
value: authCookie,
|
||||
domain: 'localhost',
|
||||
path: '/',
|
||||
}]);
|
||||
} else {
|
||||
// Perform OAuth login
|
||||
await page.goto('/');
|
||||
await page.click('text=Log in with Bluesky');
|
||||
|
||||
// Wait for OAuth redirect
|
||||
await page.waitForURL(/bsky\.social/);
|
||||
|
||||
// Fill in credentials
|
||||
await page.fill('input[name="identifier"]', process.env.TEST_USER_HANDLE!);
|
||||
await page.fill('input[name="password"]', process.env.TEST_USER_PASSWORD!);
|
||||
await page.click('button[type="submit"]');
|
||||
|
||||
// Wait for redirect back to app
|
||||
await page.waitForURL(/localhost:3000/);
|
||||
|
||||
// Verify we're authenticated
|
||||
await expect(page.getByRole('button', { name: /profile/i })).toBeVisible();
|
||||
}
|
||||
|
||||
// Save signed-in state
|
||||
await page.context().storageState({ path: authFile });
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Test Fixtures and Helpers
|
||||
|
||||
#### `tests/playwright/fixtures.ts`
|
||||
```typescript
|
||||
import { test as base, Page } from '@playwright/test';
|
||||
|
||||
type Fixtures = {
|
||||
authenticatedPage: Page;
|
||||
chatPage: Page;
|
||||
galaxyPage: Page;
|
||||
};
|
||||
|
||||
export const test = base.extend<Fixtures>({
|
||||
authenticatedPage: async ({ page }, use) => {
|
||||
// Already authenticated via setup
|
||||
await page.goto('/');
|
||||
await use(page);
|
||||
},
|
||||
|
||||
chatPage: async ({ page }, use) => {
|
||||
await page.goto('/chat');
|
||||
await use(page);
|
||||
},
|
||||
|
||||
galaxyPage: async ({ page }, use) => {
|
||||
await page.goto('/galaxy');
|
||||
await use(page);
|
||||
},
|
||||
});
|
||||
|
||||
export { expect } from '@playwright/test';
|
||||
```
|
||||
|
||||
#### `tests/playwright/helpers/chat.ts`
|
||||
```typescript
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class ChatHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async sendMessage(message: string) {
|
||||
await this.page.fill('textarea[placeholder*="Type"]', message);
|
||||
await this.page.press('textarea[placeholder*="Type"]', 'Enter');
|
||||
}
|
||||
|
||||
async waitForAIResponse() {
|
||||
// Wait for typing indicator to disappear
|
||||
await this.page.waitForSelector('[data-testid="typing-indicator"]', {
|
||||
state: 'hidden',
|
||||
timeout: 30000,
|
||||
});
|
||||
}
|
||||
|
||||
async createNode() {
|
||||
await this.page.click('button:has-text("Create Node")');
|
||||
}
|
||||
|
||||
async getLastMessage() {
|
||||
const messages = this.page.locator('[data-testid="chat-message"]');
|
||||
const count = await messages.count();
|
||||
return messages.nth(count - 1);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `tests/playwright/helpers/galaxy.ts`
|
||||
```typescript
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class GalaxyHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async waitForGalaxyLoad() {
|
||||
// Wait for canvas to be visible
|
||||
await this.page.waitForSelector('canvas', { timeout: 10000 });
|
||||
|
||||
// Wait for nodes to be loaded (check for at least one node)
|
||||
await this.page.waitForFunction(() => {
|
||||
const canvas = document.querySelector('canvas');
|
||||
return canvas && canvas.getContext('2d') !== null;
|
||||
});
|
||||
}
|
||||
|
||||
async clickNode(nodeId: string) {
|
||||
// Find and click on a specific node
|
||||
await this.page.evaluate((id) => {
|
||||
const event = new CustomEvent('node-click', { detail: { nodeId: id } });
|
||||
window.dispatchEvent(event);
|
||||
}, nodeId);
|
||||
}
|
||||
|
||||
async getNodeCount() {
|
||||
return await this.page.evaluate(() => {
|
||||
// Access R3F scene data
|
||||
return window.__galaxyNodes?.length || 0;
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### `tests/playwright/helpers/node.ts`
|
||||
```typescript
|
||||
import { Page, expect } from '@playwright/test';
|
||||
|
||||
export class NodeHelper {
|
||||
constructor(private page: Page) {}
|
||||
|
||||
async createNode(title: string, body: string) {
|
||||
// Navigate to chat
|
||||
await this.page.goto('/chat');
|
||||
|
||||
// Trigger node creation
|
||||
await this.page.fill('input[placeholder*="Title"]', title);
|
||||
await this.page.fill('textarea[placeholder*="Body"]', body);
|
||||
await this.page.click('button:has-text("Publish")');
|
||||
|
||||
// Wait for success
|
||||
await expect(this.page.getByText(/published/i)).toBeVisible();
|
||||
}
|
||||
|
||||
async waitForUMAPCalculation() {
|
||||
// Poll /api/galaxy until nodes have coords_3d
|
||||
await this.page.waitForFunction(async () => {
|
||||
const response = await fetch('/api/galaxy');
|
||||
const data = await response.json();
|
||||
return data.nodes.every((n: any) => n.coords_3d !== null);
|
||||
}, { timeout: 60000 });
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Example Tests
|
||||
|
||||
#### `tests/playwright/smoke.spec.ts`
|
||||
```typescript
|
||||
import { test, expect } from './fixtures';
|
||||
|
||||
test.describe('Smoke Tests', () => {
|
||||
test('homepage loads', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await expect(page).toHaveTitle(/Ponderants/);
|
||||
});
|
||||
|
||||
test('authenticated user can access chat', async ({ chatPage }) => {
|
||||
await expect(chatPage.getByRole('textbox')).toBeVisible();
|
||||
});
|
||||
|
||||
test('authenticated user can access galaxy', async ({ galaxyPage }) => {
|
||||
await expect(galaxyPage.locator('canvas')).toBeVisible();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Environment Variables
|
||||
|
||||
#### `.env.test` (template)
|
||||
```bash
|
||||
# Test user credentials
|
||||
TEST_USER_HANDLE=your-test-handle.bsky.social
|
||||
TEST_USER_PASSWORD=your-test-password
|
||||
|
||||
# Optional: Pre-authenticated cookie for faster tests
|
||||
PLAYWRIGHT_AUTH_COOKIE=
|
||||
|
||||
# Playwright settings
|
||||
PLAYWRIGHT_BASE_URL=http://localhost:3000
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create directory structure**
|
||||
```bash
|
||||
mkdir -p tests/playwright/.auth
|
||||
mkdir -p tests/playwright/helpers
|
||||
```
|
||||
|
||||
2. **Install dependencies**
|
||||
```bash
|
||||
pnpm add -D @playwright/test
|
||||
```
|
||||
|
||||
3. **Create configuration files**
|
||||
- playwright.config.ts
|
||||
- .gitignore updates
|
||||
- .env.test template
|
||||
|
||||
4. **Create authentication setup**
|
||||
- auth.setup.ts
|
||||
|
||||
5. **Create fixtures and helpers**
|
||||
- fixtures.ts
|
||||
- helpers/chat.ts
|
||||
- helpers/galaxy.ts
|
||||
- helpers/node.ts
|
||||
|
||||
6. **Create example tests**
|
||||
- smoke.spec.ts
|
||||
|
||||
7. **Update package.json scripts**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test:playwright": "playwright test",
|
||||
"test:playwright:ui": "playwright test --ui",
|
||||
"test:playwright:debug": "playwright test --debug"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Plan
|
||||
|
||||
### Manual Playwright MCP Testing
|
||||
1. Open Playwright MCP browser
|
||||
2. Navigate to http://localhost:3000
|
||||
3. Test authentication flow
|
||||
4. Test chat interaction
|
||||
5. Test node creation
|
||||
6. Test galaxy visualization
|
||||
7. Test node detail modal
|
||||
|
||||
### Magnitude Test Integration
|
||||
After manual testing succeeds, create magnitude tests that use the same patterns:
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can create and view nodes in galaxy', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Log in with test credentials');
|
||||
await agent.check('User is authenticated');
|
||||
|
||||
await agent.act('Navigate to chat');
|
||||
await agent.act('Create a new node with title "Test Node" and body "Test content"');
|
||||
await agent.check('Node is published to Bluesky');
|
||||
|
||||
await agent.act('Navigate to galaxy');
|
||||
await agent.check('Can see the new node in 3D visualization');
|
||||
});
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Playwright configuration file exists
|
||||
- ✅ Authentication setup works (both OAuth and cookie-based)
|
||||
- ✅ Helper classes for chat, galaxy, and node operations exist
|
||||
- ✅ Example smoke tests pass
|
||||
- ✅ Can run `pnpm test:playwright` successfully
|
||||
- ✅ Manual Playwright MCP testing is efficient with helpers
|
||||
- ✅ Foundation exists for Magnitude test creation
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `playwright.config.ts` - Main configuration
|
||||
2. `tests/playwright/auth.setup.ts` - Authentication setup
|
||||
3. `tests/playwright/fixtures.ts` - Custom fixtures
|
||||
4. `tests/playwright/helpers/chat.ts` - Chat helper functions
|
||||
5. `tests/playwright/helpers/galaxy.ts` - Galaxy helper functions
|
||||
6. `tests/playwright/helpers/node.ts` - Node helper functions
|
||||
7. `tests/playwright/smoke.spec.ts` - Example smoke tests
|
||||
8. `.env.test` - Environment variables template
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `.gitignore` - Add Playwright artifacts
|
||||
2. `package.json` - Add test scripts
|
||||
3. `README.md` - Document testing approach (optional)
|
||||
569
plans/02-magnitude-tests-comprehensive.md
Normal file
569
plans/02-magnitude-tests-comprehensive.md
Normal file
@@ -0,0 +1,569 @@
|
||||
# Plan: Add Comprehensive Magnitude Tests for All Features
|
||||
|
||||
**Priority:** CRITICAL - Must be done second (after Playwright scaffolding)
|
||||
**Dependencies:** 01-playwright-scaffolding.md
|
||||
**Affects:** Code quality, regression prevention, production confidence
|
||||
|
||||
## Overview
|
||||
|
||||
Create comprehensive Magnitude test coverage for ALL existing and new features. Every user flow must be tested fully, including both happy paths and unhappy paths.
|
||||
|
||||
## Current State
|
||||
|
||||
- No Magnitude tests exist
|
||||
- No test coverage for critical flows (auth, chat, node creation, galaxy)
|
||||
- No regression testing
|
||||
- Production deployments lack confidence
|
||||
|
||||
## Test Coverage Required
|
||||
|
||||
### 1. Authentication Flow Tests
|
||||
|
||||
#### `tests/magnitude/auth.mag.ts`
|
||||
|
||||
**Happy Paths:**
|
||||
- User can log in with valid Bluesky credentials
|
||||
- User can log out successfully
|
||||
- User session persists across page refreshes
|
||||
- User can access protected routes after authentication
|
||||
|
||||
**Unhappy Paths:**
|
||||
- Login fails with invalid credentials
|
||||
- Login fails with non-existent handle
|
||||
- Unauthenticated user redirected from protected routes
|
||||
- Session expiry handled gracefully
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can log in with valid credentials', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.check('Login page is visible');
|
||||
|
||||
await agent.act('Click "Log in with Bluesky" button')
|
||||
.data({
|
||||
handle: process.env.TEST_USER_HANDLE,
|
||||
password: process.env.TEST_USER_PASSWORD
|
||||
});
|
||||
|
||||
await agent.check('User is redirected to Bluesky OAuth');
|
||||
await agent.check('User is redirected back to Ponderants');
|
||||
await agent.check('User sees authenticated interface');
|
||||
await agent.check('Profile menu is visible');
|
||||
});
|
||||
|
||||
test('Login fails with invalid credentials', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Click "Log in with Bluesky" button')
|
||||
.data({
|
||||
handle: 'invalid-user.bsky.social',
|
||||
password: 'wrongpassword'
|
||||
});
|
||||
|
||||
await agent.check('Error message is displayed');
|
||||
await agent.check('User remains on login page');
|
||||
});
|
||||
|
||||
test('User session persists across refresh', async (agent) => {
|
||||
// First login
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Log in with valid credentials');
|
||||
await agent.check('User is authenticated');
|
||||
|
||||
// Refresh page
|
||||
await agent.act('Refresh the page');
|
||||
await agent.check('User is still authenticated');
|
||||
await agent.check('Profile menu is still visible');
|
||||
});
|
||||
|
||||
test('User can log out', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Log in with valid credentials');
|
||||
await agent.check('User is authenticated');
|
||||
|
||||
await agent.act('Click profile menu');
|
||||
await agent.act('Click "Log out" button');
|
||||
await agent.check('User is redirected to login page');
|
||||
await agent.check('Session is cleared');
|
||||
});
|
||||
```
|
||||
|
||||
### 2. Chat Interface Tests
|
||||
|
||||
#### `tests/magnitude/chat.mag.ts`
|
||||
|
||||
**Happy Paths:**
|
||||
- User can send text message and receive AI response
|
||||
- User can use voice input and receive AI response
|
||||
- User can see typing indicator while AI is thinking
|
||||
- User can create a node from conversation
|
||||
- User can select AI persona
|
||||
- Chat history persists
|
||||
|
||||
**Unhappy Paths:**
|
||||
- Empty message submission is prevented
|
||||
- Voice input fails gracefully without microphone permission
|
||||
- AI error displays user-friendly message
|
||||
- Network error handled with retry option
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can send message and receive AI response', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Type "What is the meaning of life?" into chat input');
|
||||
await agent.act('Press Enter to send');
|
||||
|
||||
await agent.check('Message appears in chat history');
|
||||
await agent.check('Typing indicator is visible');
|
||||
await agent.check('AI response appears after typing indicator disappears');
|
||||
await agent.check('Response contains thoughtful content');
|
||||
});
|
||||
|
||||
test('User can use voice input', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Click microphone button');
|
||||
await agent.check('Recording indicator is visible');
|
||||
|
||||
await agent.act('Speak "Hello, how are you?"');
|
||||
await agent.act('Click microphone button to stop recording');
|
||||
|
||||
await agent.check('Transcribed text appears in chat');
|
||||
await agent.check('AI response is generated');
|
||||
});
|
||||
|
||||
test('User can create node from conversation', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Have a conversation about "quantum computing"');
|
||||
await agent.check('At least 3 messages exchanged');
|
||||
|
||||
await agent.act('Click "Create Node" button');
|
||||
await agent.check('Node editor opens with AI-generated draft');
|
||||
await agent.check('Title is populated');
|
||||
await agent.check('Body contains conversation insights');
|
||||
});
|
||||
|
||||
test('Empty message submission is prevented', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Press Enter without typing anything');
|
||||
await agent.check('No message is sent');
|
||||
await agent.check('Send button remains disabled');
|
||||
});
|
||||
|
||||
test('AI error displays user-friendly message', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
// Simulate network error by blocking API calls
|
||||
await agent.act('Send a message while API is blocked');
|
||||
|
||||
await agent.check('Error notification appears');
|
||||
await agent.check('Error message is user-friendly');
|
||||
await agent.check('Retry option is available');
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Node Creation and Publishing Tests
|
||||
|
||||
#### `tests/magnitude/nodes.mag.ts`
|
||||
|
||||
**Happy Paths:**
|
||||
- User can create a simple node (title + body)
|
||||
- Node is published to Bluesky as a post
|
||||
- Node is published to Bluesky as a thread (long content)
|
||||
- Node appears in local cache (SurrealDB)
|
||||
- Embedding is generated for node
|
||||
- Node can link to other existing nodes
|
||||
- AI suggests relevant node links
|
||||
- Node appears in galaxy after creation
|
||||
|
||||
**Unhappy Paths:**
|
||||
- Node creation fails without title
|
||||
- Node creation fails without body
|
||||
- Bluesky API error handled gracefully
|
||||
- Embedding generation failure doesn't block node creation
|
||||
- Link suggestion failure doesn't block node creation
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can create a simple node', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Click "New Node" button');
|
||||
await agent.check('Node editor opens');
|
||||
|
||||
await agent.act('Type "My First Thought" into title field');
|
||||
await agent.act('Type "This is my first ponderant about philosophy" into body field');
|
||||
await agent.act('Click "Publish" button');
|
||||
|
||||
await agent.check('Success notification appears');
|
||||
await agent.check('Node is published to Bluesky');
|
||||
await agent.check('Node appears in cache');
|
||||
});
|
||||
|
||||
test('Long node is published as thread', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
const longBody = 'This is a very long thought that exceeds 300 graphemes. '.repeat(20);
|
||||
|
||||
await agent.act('Create new node');
|
||||
await agent.act(`Type "Long Thought" into title`);
|
||||
await agent.act(`Type long content into body`).data({ body: longBody });
|
||||
await agent.act('Click "Publish" button');
|
||||
|
||||
await agent.check('Node is published as Bluesky thread');
|
||||
await agent.check('Thread contains multiple posts');
|
||||
await agent.check('First post contains link to detail page');
|
||||
await agent.check('Thread indicators show (2/3), (3/3), etc.');
|
||||
});
|
||||
|
||||
test('Node with emojis handles grapheme counting correctly', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
const emojiContent = '🎉 '.repeat(100) + 'Hello world! 👋 ';
|
||||
|
||||
await agent.act('Create new node');
|
||||
await agent.act('Type "Emoji Test" into title');
|
||||
await agent.act('Type emoji content into body').data({ body: emojiContent });
|
||||
await agent.act('Click "Publish" button');
|
||||
|
||||
await agent.check('Node is published without exceeding 300 grapheme limit');
|
||||
await agent.check('Emojis are counted as 1 grapheme each');
|
||||
});
|
||||
|
||||
test('Node creation fails without title', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Create new node');
|
||||
await agent.act('Type "Some body content" into body field');
|
||||
await agent.act('Click "Publish" button without entering title');
|
||||
|
||||
await agent.check('Validation error appears');
|
||||
await agent.check('Error message says "Title is required"');
|
||||
await agent.check('Node is not published');
|
||||
});
|
||||
|
||||
test('Node links to existing nodes', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
// Create first node
|
||||
await agent.act('Create node titled "Philosophy Basics"');
|
||||
await agent.check('First node is published');
|
||||
|
||||
// Create second node that links to first
|
||||
await agent.act('Create new node titled "Advanced Philosophy"');
|
||||
await agent.check('AI suggests linking to "Philosophy Basics"');
|
||||
await agent.act('Accept suggested link');
|
||||
await agent.act('Publish node');
|
||||
|
||||
await agent.check('Node is published with link');
|
||||
await agent.check('Graph relationship exists in database');
|
||||
});
|
||||
```
|
||||
|
||||
### 4. Galaxy Visualization Tests
|
||||
|
||||
#### `tests/magnitude/galaxy.mag.ts`
|
||||
|
||||
**Happy Paths:**
|
||||
- Galaxy loads and displays nodes with 3D coordinates
|
||||
- User can rotate/zoom/pan the galaxy
|
||||
- User can click on a node to view details
|
||||
- Node detail modal displays correct information
|
||||
- User can navigate from modal to chat
|
||||
- User can navigate from modal to node detail page
|
||||
- Empty state shows when no nodes exist
|
||||
- UMAP calculation triggers automatically after 3rd node
|
||||
|
||||
**Unhappy Paths:**
|
||||
- Galaxy handles zero nodes gracefully
|
||||
- Galaxy handles nodes without coordinates
|
||||
- WebGL not available fallback
|
||||
- Node click on non-existent node handled
|
||||
- Modal close doesn't break navigation
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('Galaxy loads and displays nodes', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
// Ensure user has at least 3 nodes with embeddings
|
||||
await agent.check('Galaxy canvas is visible');
|
||||
await agent.check('3D nodes are rendered');
|
||||
await agent.check('Can rotate the galaxy by dragging');
|
||||
await agent.check('Can zoom with scroll wheel');
|
||||
});
|
||||
|
||||
test('User can click on node to view details', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.check('At least one node is visible');
|
||||
await agent.act('Click on a node sphere');
|
||||
|
||||
await agent.check('Node detail modal opens');
|
||||
await agent.check('Modal shows node title');
|
||||
await agent.check('Modal shows node body');
|
||||
await agent.check('Modal shows Bluesky post link');
|
||||
});
|
||||
|
||||
test('Node detail modal navigation works', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Click on a node');
|
||||
await agent.check('Modal is open');
|
||||
|
||||
await agent.act('Click "View Full Detail" button');
|
||||
await agent.check('Navigated to /galaxy/[node-id] page');
|
||||
await agent.check('URL contains node ID');
|
||||
await agent.check('Page shows full node content');
|
||||
});
|
||||
|
||||
test('Empty galaxy shows helpful message', async (agent) => {
|
||||
// Use a new test user with no nodes
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Log in with empty account');
|
||||
|
||||
await agent.check('Empty state message is visible');
|
||||
await agent.check('Message says "No thoughts yet"');
|
||||
await agent.check('Button to create first node is visible');
|
||||
});
|
||||
|
||||
test('UMAP calculation triggers after 3rd node', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in with empty account');
|
||||
|
||||
// Create 3 nodes
|
||||
await agent.act('Create node 1: "First Thought"');
|
||||
await agent.act('Create node 2: "Second Thought"');
|
||||
await agent.act('Create node 3: "Third Thought"');
|
||||
|
||||
await agent.check('UMAP calculation started automatically');
|
||||
|
||||
await agent.act('Navigate to /galaxy');
|
||||
await agent.check('All 3 nodes have 3D coordinates');
|
||||
await agent.check('Nodes are positioned in 3D space');
|
||||
});
|
||||
|
||||
test('Galaxy handles WebGL unavailable', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Log in if needed');
|
||||
await agent.act('Disable WebGL in browser');
|
||||
|
||||
await agent.check('Fallback message is displayed');
|
||||
await agent.check('Message explains WebGL requirement');
|
||||
await agent.check('Link to enable WebGL provided');
|
||||
});
|
||||
```
|
||||
|
||||
### 5. Voice Feature Tests
|
||||
|
||||
#### `tests/magnitude/voice.mag.ts`
|
||||
|
||||
**Happy Paths:**
|
||||
- User can start voice recording
|
||||
- User can stop voice recording
|
||||
- Voice is transcribed to text
|
||||
- Transcribed text is sent to AI
|
||||
- TTS plays AI response
|
||||
- User can toggle TTS on/off
|
||||
|
||||
**Unhappy Paths:**
|
||||
- Microphone permission denied
|
||||
- Deepgram API error
|
||||
- Network error during transcription
|
||||
- TTS fails gracefully
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can record and transcribe voice', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
|
||||
await agent.act('Click microphone button');
|
||||
await agent.check('Recording indicator shows');
|
||||
await agent.check('Microphone permission granted');
|
||||
|
||||
await agent.act('Speak "What is artificial intelligence?"');
|
||||
await agent.wait(2000);
|
||||
await agent.act('Click microphone button to stop');
|
||||
|
||||
await agent.check('Recording stops');
|
||||
await agent.check('Text is transcribed');
|
||||
await agent.check('Transcribed text appears in chat input');
|
||||
});
|
||||
|
||||
test('Microphone permission denied handled gracefully', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
await agent.act('Deny microphone permission');
|
||||
|
||||
await agent.act('Click microphone button');
|
||||
|
||||
await agent.check('Error notification appears');
|
||||
await agent.check('Error explains permission is needed');
|
||||
await agent.check('Link to browser settings provided');
|
||||
});
|
||||
|
||||
test('TTS plays AI response', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Log in if needed');
|
||||
await agent.act('Enable TTS in settings');
|
||||
|
||||
await agent.act('Send message "Hello"');
|
||||
await agent.check('AI responds with text');
|
||||
await agent.check('TTS audio plays automatically');
|
||||
await agent.check('Audio player controls are visible');
|
||||
});
|
||||
```
|
||||
|
||||
### 6. Integration Tests
|
||||
|
||||
#### `tests/magnitude/integration.mag.ts`
|
||||
|
||||
**Full user journeys:**
|
||||
- Complete flow: Login → Chat → Create Node → View in Galaxy
|
||||
- Complete flow: Login → Galaxy → View Node → Edit Node
|
||||
- Complete flow: Login → Create 3 Nodes → UMAP → Explore Galaxy
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('Complete user journey: Login to Galaxy visualization', async (agent) => {
|
||||
// 1. Login
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Log in with valid credentials');
|
||||
await agent.check('User is authenticated');
|
||||
|
||||
// 2. Chat with AI
|
||||
await agent.act('Navigate to /chat');
|
||||
await agent.act('Send message "Tell me about philosophy"');
|
||||
await agent.check('AI responds');
|
||||
|
||||
// 3. Create node from conversation
|
||||
await agent.act('Click "Create Node" button');
|
||||
await agent.check('Node editor opens with AI draft');
|
||||
await agent.act('Review and edit node content');
|
||||
await agent.act('Click "Publish"');
|
||||
await agent.check('Node is published to Bluesky');
|
||||
await agent.check('Success notification appears');
|
||||
|
||||
// 4. View in galaxy
|
||||
await agent.act('Navigate to /galaxy');
|
||||
await agent.check('Galaxy canvas loads');
|
||||
await agent.check('New node appears in galaxy');
|
||||
await agent.act('Click on the new node');
|
||||
await agent.check('Node details modal opens');
|
||||
await agent.check('Content matches published node');
|
||||
});
|
||||
|
||||
test('User creates 3 nodes and explores galaxy', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Log in with empty account');
|
||||
|
||||
// Create 3 nodes
|
||||
for (let i = 1; i <= 3; i++) {
|
||||
await agent.act(`Create node titled "Thought ${i}"`);
|
||||
await agent.check(`Node ${i} published successfully`);
|
||||
}
|
||||
|
||||
// Check UMAP triggered
|
||||
await agent.check('UMAP calculation started');
|
||||
await agent.wait(5000); // Wait for UMAP to complete
|
||||
|
||||
// Explore galaxy
|
||||
await agent.act('Navigate to /galaxy');
|
||||
await agent.check('All 3 nodes have coordinates');
|
||||
await agent.check('Nodes are spatially distributed');
|
||||
|
||||
await agent.act('Rotate galaxy to different angle');
|
||||
await agent.act('Zoom in on cluster of nodes');
|
||||
await agent.act('Click on each node to view details');
|
||||
await agent.check('All node details accessible');
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Create test directory structure**
|
||||
```bash
|
||||
mkdir -p tests/magnitude
|
||||
```
|
||||
|
||||
2. **Create test files in order**
|
||||
- `tests/magnitude/auth.mag.ts` - Authentication tests
|
||||
- `tests/magnitude/chat.mag.ts` - Chat interface tests
|
||||
- `tests/magnitude/nodes.mag.ts` - Node creation tests
|
||||
- `tests/magnitude/galaxy.mag.ts` - Galaxy visualization tests
|
||||
- `tests/magnitude/voice.mag.ts` - Voice feature tests
|
||||
- `tests/magnitude/integration.mag.ts` - Full user journeys
|
||||
|
||||
3. **Update package.json**
|
||||
```json
|
||||
{
|
||||
"scripts": {
|
||||
"test": "npx magnitude",
|
||||
"test:watch": "npx magnitude --watch"
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
4. **Create test data helpers**
|
||||
```typescript
|
||||
// tests/magnitude/helpers/data.ts
|
||||
export const createTestNode = (index: number) => ({
|
||||
title: `Test Node ${index}`,
|
||||
body: `This is test content for node ${index}. It discusses various topics.`
|
||||
});
|
||||
```
|
||||
|
||||
5. **Run tests and fix issues**
|
||||
- Run each test file individually first
|
||||
- Fix failing tests
|
||||
- Run full suite
|
||||
- Ensure all tests pass
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ All authentication flows tested (happy + unhappy paths)
|
||||
- ✅ All chat interactions tested (text, voice, AI response)
|
||||
- ✅ All node creation scenarios tested (simple, thread, emojis, links)
|
||||
- ✅ All galaxy visualization features tested (load, click, navigate)
|
||||
- ✅ All voice features tested (record, transcribe, TTS)
|
||||
- ✅ Integration tests cover complete user journeys
|
||||
- ✅ All tests pass reliably
|
||||
- ✅ Test suite runs in CI/CD
|
||||
- ✅ New features cannot be merged without tests
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `tests/magnitude/auth.mag.ts`
|
||||
2. `tests/magnitude/chat.mag.ts`
|
||||
3. `tests/magnitude/nodes.mag.ts`
|
||||
4. `tests/magnitude/galaxy.mag.ts`
|
||||
5. `tests/magnitude/voice.mag.ts`
|
||||
6. `tests/magnitude/integration.mag.ts`
|
||||
7. `tests/magnitude/helpers/data.ts`
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `package.json` - Add test scripts
|
||||
2. `.github/workflows/test.yml` - Add CI test workflow (if exists)
|
||||
382
plans/03-stream-ai-to-deepgram-tts.md
Normal file
382
plans/03-stream-ai-to-deepgram-tts.md
Normal file
@@ -0,0 +1,382 @@
|
||||
# Plan: Stream AI Output to Deepgram for Faster TTS Synthesis
|
||||
|
||||
**Priority:** MEDIUM
|
||||
**Dependencies:** None
|
||||
**Affects:** Voice interaction latency, user experience
|
||||
|
||||
## Overview
|
||||
|
||||
Currently, the app waits for the complete AI response before sending it to Deepgram for TTS. This creates a laggy experience. By streaming the AI output directly to Deepgram as it's generated, we can start playing audio much faster and create a more responsive voice interaction.
|
||||
|
||||
## Current Implementation
|
||||
|
||||
### Current Flow (SLOW)
|
||||
```
|
||||
User speaks → Deepgram transcribe → Send to AI
|
||||
↓
|
||||
Wait for full response (3-10s)
|
||||
↓
|
||||
Send complete text to Deepgram TTS
|
||||
↓
|
||||
Wait for audio generation (1-3s)
|
||||
↓
|
||||
Play audio
|
||||
```
|
||||
|
||||
**Total latency:** 4-13 seconds before first audio plays
|
||||
|
||||
## Proposed Implementation
|
||||
|
||||
### New Flow (FAST)
|
||||
```
|
||||
User speaks → Deepgram transcribe → Stream to AI
|
||||
↓
|
||||
Stream chunks to Deepgram TTS
|
||||
↓ (chunks arrive)
|
||||
Play audio chunks immediately
|
||||
```
|
||||
|
||||
**Total latency:** 1-2 seconds before first audio plays
|
||||
|
||||
## Technical Approach
|
||||
|
||||
### 1. Modify AI SDK Integration
|
||||
|
||||
Currently using `useChat` from Vercel AI SDK with async completion:
|
||||
|
||||
```typescript
|
||||
// Current (app/api/chat/route.ts)
|
||||
const result = await streamText({
|
||||
model: google('gemini-2.0-flash-exp'),
|
||||
messages,
|
||||
system: systemPrompt,
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
```
|
||||
|
||||
Need to add TTS streaming:
|
||||
|
||||
```typescript
|
||||
// New approach
|
||||
const result = streamText({
|
||||
model: google('gemini-2.0-flash-exp'),
|
||||
messages,
|
||||
system: systemPrompt,
|
||||
async onChunk({ chunk }) {
|
||||
// Stream each chunk to Deepgram TTS
|
||||
if (chunk.type === 'text-delta') {
|
||||
await streamToDeepgram(chunk.textDelta);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
```
|
||||
|
||||
### 2. Create Deepgram TTS Streaming Service
|
||||
|
||||
#### `lib/deepgram-tts-stream.ts`
|
||||
```typescript
|
||||
import { createClient, LiveClient } from '@deepgram/sdk';
|
||||
|
||||
export class DeepgramTTSStream {
|
||||
private client: LiveClient;
|
||||
private audioQueue: Uint8Array[] = [];
|
||||
private isPlaying = false;
|
||||
|
||||
constructor(apiKey: string) {
|
||||
const deepgram = createClient(apiKey);
|
||||
this.client = deepgram.speak.live({
|
||||
model: 'aura-asteria-en',
|
||||
encoding: 'linear16',
|
||||
sample_rate: 24000,
|
||||
});
|
||||
|
||||
this.client.on('data', (data: Buffer) => {
|
||||
this.audioQueue.push(new Uint8Array(data));
|
||||
this.playNextChunk();
|
||||
});
|
||||
}
|
||||
|
||||
async streamText(text: string) {
|
||||
// Send text chunk to Deepgram for synthesis
|
||||
this.client.send(text);
|
||||
}
|
||||
|
||||
async flush() {
|
||||
// Signal end of text stream
|
||||
this.client.close();
|
||||
}
|
||||
|
||||
private async playNextChunk() {
|
||||
if (this.isPlaying || this.audioQueue.length === 0) return;
|
||||
|
||||
this.isPlaying = true;
|
||||
const chunk = this.audioQueue.shift()!;
|
||||
|
||||
// Play audio chunk using Web Audio API
|
||||
await this.playAudioChunk(chunk);
|
||||
|
||||
this.isPlaying = false;
|
||||
this.playNextChunk(); // Play next if available
|
||||
}
|
||||
|
||||
private async playAudioChunk(chunk: Uint8Array) {
|
||||
const audioContext = new AudioContext({ sampleRate: 24000 });
|
||||
const audioBuffer = audioContext.createBuffer(
|
||||
1, // mono
|
||||
chunk.length / 2, // 16-bit samples
|
||||
24000
|
||||
);
|
||||
|
||||
const channelData = audioBuffer.getChannelData(0);
|
||||
for (let i = 0; i < chunk.length / 2; i++) {
|
||||
// Convert 16-bit PCM to float32
|
||||
const sample = (chunk[i * 2] | (chunk[i * 2 + 1] << 8));
|
||||
channelData[i] = sample / 32768.0;
|
||||
}
|
||||
|
||||
const source = audioContext.createBufferSource();
|
||||
source.buffer = audioBuffer;
|
||||
source.connect(audioContext.destination);
|
||||
|
||||
return new Promise((resolve) => {
|
||||
source.onended = resolve;
|
||||
source.start();
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Create Server-Sent Events (SSE) Endpoint for TTS
|
||||
|
||||
#### `app/api/chat-with-tts/route.ts`
|
||||
```typescript
|
||||
import { DeepgramTTSStream } from '@/lib/deepgram-tts-stream';
|
||||
import { streamText } from 'ai';
|
||||
import { google } from '@ai-sdk/google';
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { messages } = await request.json();
|
||||
|
||||
// Create a TransformStream for SSE
|
||||
const stream = new TransformStream();
|
||||
const writer = stream.writable.getWriter();
|
||||
const encoder = new TextEncoder();
|
||||
|
||||
// Start streaming AI response
|
||||
(async () => {
|
||||
const ttsStream = new DeepgramTTSStream(process.env.DEEPGRAM_API_KEY!);
|
||||
|
||||
try {
|
||||
const result = streamText({
|
||||
model: google('gemini-2.0-flash-exp'),
|
||||
messages,
|
||||
async onChunk({ chunk }) {
|
||||
if (chunk.type === 'text-delta') {
|
||||
// Send text to client
|
||||
await writer.write(
|
||||
encoder.encode(`data: ${JSON.stringify({ text: chunk.textDelta })}\n\n`)
|
||||
);
|
||||
|
||||
// Stream to Deepgram TTS
|
||||
await ttsStream.streamText(chunk.textDelta);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
await result.text; // Wait for completion
|
||||
await ttsStream.flush();
|
||||
|
||||
await writer.write(encoder.encode('data: [DONE]\n\n'));
|
||||
} catch (error) {
|
||||
await writer.write(
|
||||
encoder.encode(`data: ${JSON.stringify({ error: error.message })}\n\n`)
|
||||
);
|
||||
} finally {
|
||||
await writer.close();
|
||||
}
|
||||
})();
|
||||
|
||||
return new Response(stream.readable, {
|
||||
headers: {
|
||||
'Content-Type': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache',
|
||||
Connection: 'keep-alive',
|
||||
},
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Update Client to Consume SSE with TTS
|
||||
|
||||
#### `components/ChatInterface.tsx`
|
||||
```typescript
|
||||
const [isTTSEnabled, setIsTTSEnabled] = useState(false);
|
||||
const ttsStreamRef = useRef<DeepgramTTSStream | null>(null);
|
||||
|
||||
async function sendMessageWithTTS(message: string) {
|
||||
const response = await fetch('/api/chat-with-tts', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ messages: [...messages, { role: 'user', content: message }] }),
|
||||
});
|
||||
|
||||
const reader = response.body!.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
|
||||
// Initialize TTS stream
|
||||
if (isTTSEnabled) {
|
||||
ttsStreamRef.current = new DeepgramTTSStream();
|
||||
}
|
||||
|
||||
let fullText = '';
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) break;
|
||||
|
||||
const chunk = decoder.decode(value);
|
||||
const lines = chunk.split('\n');
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
const data = line.slice(6);
|
||||
if (data === '[DONE]') {
|
||||
if (ttsStreamRef.current) {
|
||||
await ttsStreamRef.current.flush();
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(data);
|
||||
if (parsed.text) {
|
||||
fullText += parsed.text;
|
||||
// Update UI with incremental text
|
||||
setMessages((prev) => {
|
||||
const last = prev[prev.length - 1];
|
||||
if (last && last.role === 'assistant') {
|
||||
return [...prev.slice(0, -1), { ...last, content: fullText }];
|
||||
}
|
||||
return [...prev, { role: 'assistant', content: fullText }];
|
||||
});
|
||||
|
||||
// Stream to TTS
|
||||
if (ttsStreamRef.current) {
|
||||
await ttsStreamRef.current.streamText(parsed.text);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Failed to parse SSE data:', e);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Alternative: Use Deepgram's Native Streaming TTS
|
||||
|
||||
Deepgram has a WebSocket-based streaming TTS API that's even more efficient:
|
||||
|
||||
```typescript
|
||||
const deepgram = createClient(process.env.DEEPGRAM_API_KEY);
|
||||
|
||||
const connection = deepgram.speak.live({
|
||||
model: 'aura-asteria-en',
|
||||
encoding: 'linear16',
|
||||
sample_rate: 24000,
|
||||
});
|
||||
|
||||
connection.on('open', () => {
|
||||
console.log('TTS connection established');
|
||||
});
|
||||
|
||||
connection.on('data', (audioData: Buffer) => {
|
||||
// Play audio chunk immediately
|
||||
playAudioBuffer(audioData);
|
||||
});
|
||||
|
||||
// As AI chunks arrive, send to Deepgram
|
||||
aiStream.on('text-delta', (text) => {
|
||||
connection.send(text);
|
||||
});
|
||||
|
||||
// When AI completes
|
||||
aiStream.on('finish', () => {
|
||||
connection.close();
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Research Deepgram TTS Streaming API**
|
||||
- Review docs: https://developers.deepgram.com/docs/tts-streaming
|
||||
- Test WebSocket connection manually
|
||||
- Understand audio format and buffering
|
||||
|
||||
2. **Create TTS streaming service**
|
||||
- `lib/deepgram-tts-stream.ts`
|
||||
- Implement audio queue and playback
|
||||
- Handle reconnection and errors
|
||||
|
||||
3. **Modify API route for streaming**
|
||||
- Create `/api/chat-with-tts` route
|
||||
- Implement SSE response
|
||||
- Connect AI stream to TTS stream
|
||||
|
||||
4. **Update client components**
|
||||
- Add TTS toggle in UI
|
||||
- Implement SSE consumption
|
||||
- Connect to audio playback
|
||||
|
||||
5. **Test with Playwright MCP**
|
||||
- Enable TTS
|
||||
- Send message
|
||||
- Verify audio starts playing quickly (< 2s)
|
||||
- Verify audio quality
|
||||
- Test error handling (network drop, TTS failure)
|
||||
|
||||
6. **Add Magnitude test**
|
||||
```typescript
|
||||
test('TTS streams audio with low latency', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Enable TTS in settings');
|
||||
await agent.act('Send message "Hello"');
|
||||
|
||||
await agent.check('Audio starts playing within 2 seconds');
|
||||
await agent.check('Audio continues as AI generates response');
|
||||
await agent.check('Audio completes without gaps');
|
||||
});
|
||||
```
|
||||
|
||||
## Performance Targets
|
||||
|
||||
- **Time to first audio:** < 2 seconds (vs current 4-13s)
|
||||
- **Perceived latency:** Near real-time streaming
|
||||
- **Audio quality:** No degradation from current implementation
|
||||
- **Reliability:** Graceful fallback if streaming fails
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ TTS audio starts playing within 2 seconds of AI response beginning
|
||||
- ✅ Audio streams continuously as AI generates text
|
||||
- ✅ No perceptible gaps or stuttering in audio playback
|
||||
- ✅ Graceful fallback to batch TTS if streaming fails
|
||||
- ✅ Playwright MCP manual test passes
|
||||
- ✅ Magnitude test passes
|
||||
- ✅ No regression in audio quality
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `lib/deepgram-tts-stream.ts` - TTS streaming service
|
||||
2. `app/api/chat-with-tts/route.ts` - SSE endpoint for TTS
|
||||
3. `tests/playwright/tts-streaming.spec.ts` - Manual Playwright test
|
||||
4. `tests/magnitude/tts-streaming.mag.ts` - Magnitude test
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `components/ChatInterface.tsx` - Add TTS streaming consumption
|
||||
2. `app/theme.ts` - Add TTS toggle styling if needed
|
||||
383
plans/04-fix-galaxy-node-clicking.md
Normal file
383
plans/04-fix-galaxy-node-clicking.md
Normal file
@@ -0,0 +1,383 @@
|
||||
# Plan: Fix Galaxy Node Clicking and Navigation
|
||||
|
||||
**Priority:** HIGH - Critical user experience issue
|
||||
**Dependencies:** None
|
||||
**Affects:** Galaxy visualization navigation, user frustration
|
||||
|
||||
## Overview
|
||||
|
||||
There are two critical bugs with galaxy node interaction:
|
||||
1. When going directly to a node ID link (`/galaxy/node:xxx`), it redirects to `/chat`
|
||||
2. When clicking on a node in `/galaxy` (either general or on a specific node ID URL), the modal closes automatically
|
||||
|
||||
Both issues severely impact the galaxy user experience and need immediate fixing.
|
||||
|
||||
## Current Broken Behavior
|
||||
|
||||
### Issue 1: Direct Node URL Redirects to Chat
|
||||
```
|
||||
User clicks: /galaxy/node:abc123
|
||||
Expected: Shows node detail page with full content
|
||||
Actual: Redirects to /chat
|
||||
```
|
||||
|
||||
### Issue 2: Modal Auto-Closes on Node Click
|
||||
```
|
||||
User action: Click node sphere in galaxy visualization
|
||||
Expected: Modal stays open showing node details
|
||||
Actual: Modal opens briefly then closes immediately
|
||||
```
|
||||
|
||||
## Root Cause Analysis
|
||||
|
||||
Let me investigate the current implementation:
|
||||
|
||||
### 1. Check `/app/galaxy/[node-id]/page.tsx`
|
||||
|
||||
Likely issues:
|
||||
- Missing authentication check causing redirect
|
||||
- Incorrect route parameter parsing
|
||||
- Navigation logic in wrong place
|
||||
|
||||
### 2. Check Galaxy Modal Component
|
||||
|
||||
Likely issues:
|
||||
- Event handler conflict (click bubbling to parent)
|
||||
- State management issue (modal state reset)
|
||||
- React Three Fiber event handling bug
|
||||
|
||||
## Proposed Fixes
|
||||
|
||||
### Fix 1: Node Detail Page Route
|
||||
|
||||
#### Current (broken):
|
||||
```typescript
|
||||
// app/galaxy/[node-id]/page.tsx
|
||||
export default async function NodeDetailPage({ params }: { params: { 'node-id': string } }) {
|
||||
// Redirects to /chat if not authenticated?
|
||||
}
|
||||
```
|
||||
|
||||
#### Fixed:
|
||||
```typescript
|
||||
// app/galaxy/[node-id]/page.tsx
|
||||
import { cookies } from 'next/headers';
|
||||
import { redirect } from 'next/navigation';
|
||||
import { verifySurrealJwt } from '@/lib/auth/jwt';
|
||||
import { connectToDB } from '@/lib/db';
|
||||
|
||||
export default async function NodeDetailPage({
|
||||
params,
|
||||
}: {
|
||||
params: { 'node-id': string };
|
||||
}) {
|
||||
// 1. Verify authentication
|
||||
const cookieStore = await cookies();
|
||||
const surrealJwt = cookieStore.get('ponderants-auth')?.value;
|
||||
|
||||
if (!surrealJwt) {
|
||||
// Redirect to login, preserving the intended destination
|
||||
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
|
||||
}
|
||||
|
||||
const userSession = verifySurrealJwt(surrealJwt);
|
||||
if (!userSession) {
|
||||
redirect(`/login?redirect=/galaxy/${params['node-id']}`);
|
||||
}
|
||||
|
||||
// 2. Fetch node data
|
||||
const db = await connectToDB();
|
||||
const nodeId = params['node-id'];
|
||||
|
||||
const nodes = await db.select<NodeData>(nodeId);
|
||||
const node = nodes[0];
|
||||
|
||||
if (!node) {
|
||||
// Node doesn't exist or user doesn't have access
|
||||
redirect('/galaxy?error=node-not-found');
|
||||
}
|
||||
|
||||
// 3. Verify user owns this node
|
||||
if (node.user_did !== userSession.did) {
|
||||
redirect('/galaxy?error=unauthorized');
|
||||
}
|
||||
|
||||
// 4. Render node detail page
|
||||
return (
|
||||
<Stack p="xl">
|
||||
<Title order={1}>{node.title}</Title>
|
||||
<Text>{node.body}</Text>
|
||||
{node.atp_uri && (
|
||||
<Anchor href={node.atp_uri} target="_blank">
|
||||
View on Bluesky
|
||||
</Anchor>
|
||||
)}
|
||||
<Button component={Link} href="/galaxy">
|
||||
Back to Galaxy
|
||||
</Button>
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 2: Galaxy Modal Component
|
||||
|
||||
#### Problem: Click event bubbling
|
||||
|
||||
When user clicks node in R3F, the click event might be:
|
||||
- Bubbling to parent canvas
|
||||
- Triggering canvas click handler that closes modal
|
||||
- Or: React state update race condition
|
||||
|
||||
#### Solution: Stop event propagation
|
||||
|
||||
```typescript
|
||||
// components/galaxy/NodeSphere.tsx
|
||||
import { useRef } from 'react';
|
||||
import { ThreeEvent } from '@react-three/fiber';
|
||||
|
||||
function NodeSphere({ node, onClick }: { node: NodeData; onClick: (node: NodeData) => void }) {
|
||||
const meshRef = useRef();
|
||||
|
||||
const handleClick = (event: ThreeEvent<MouseEvent>) => {
|
||||
// CRITICAL: Stop propagation to prevent canvas click handler
|
||||
event.stopPropagation();
|
||||
|
||||
// Call parent onClick
|
||||
onClick(node);
|
||||
};
|
||||
|
||||
return (
|
||||
<mesh
|
||||
ref={meshRef}
|
||||
position={node.coords_3d}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<sphereGeometry args={[0.1, 16, 16]} />
|
||||
<meshStandardMaterial color="#fff" />
|
||||
</mesh>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
#### Problem: Modal state management
|
||||
|
||||
Modal might be controlled by both:
|
||||
- Galaxy component state
|
||||
- URL query params
|
||||
- Leading to race condition
|
||||
|
||||
#### Solution: Single source of truth
|
||||
|
||||
```typescript
|
||||
// app/galaxy/page.tsx
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useSearchParams, useRouter } from 'next/navigation';
|
||||
|
||||
export default function GalaxyPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// Modal state controlled ONLY by URL query param
|
||||
const selectedNodeId = searchParams.get('node');
|
||||
|
||||
const handleNodeClick = (nodeId: string) => {
|
||||
// Update URL to open modal
|
||||
router.push(`/galaxy?node=${encodeURIComponent(nodeId)}`, { scroll: false });
|
||||
};
|
||||
|
||||
const handleModalClose = () => {
|
||||
// Remove query param to close modal
|
||||
router.push('/galaxy', { scroll: false });
|
||||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
<ThoughtGalaxy
|
||||
nodes={nodes}
|
||||
links={links}
|
||||
onNodeClick={handleNodeClick}
|
||||
/>
|
||||
|
||||
<Modal
|
||||
opened={!!selectedNodeId}
|
||||
onClose={handleModalClose}
|
||||
size="lg"
|
||||
>
|
||||
{selectedNodeId && <NodeDetailModal nodeId={selectedNodeId} />}
|
||||
</Modal>
|
||||
</>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### Fix 3: Canvas Click Handler
|
||||
|
||||
If canvas has a click handler that closes modal, remove it or add condition:
|
||||
|
||||
```typescript
|
||||
// components/galaxy/ThoughtGalaxy.tsx
|
||||
<Canvas
|
||||
onClick={(e) => {
|
||||
// Only close modal if clicking on background (not a node)
|
||||
if (e.target === e.currentTarget) {
|
||||
onBackgroundClick?.();
|
||||
}
|
||||
}}
|
||||
>
|
||||
{/* ... */}
|
||||
</Canvas>
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Playwright MCP Testing
|
||||
|
||||
#### Test 1: Direct Node URL
|
||||
```typescript
|
||||
test('Direct node URL loads correctly', async ({ page }) => {
|
||||
// Create a node first
|
||||
const nodeId = await createTestNode(page);
|
||||
|
||||
// Navigate directly to node detail page
|
||||
await page.goto(`/galaxy/${nodeId}`);
|
||||
|
||||
// Verify we're on the correct page
|
||||
await expect(page).toHaveURL(/\/galaxy\/node:/);
|
||||
await expect(page.getByRole('heading', { level: 1 })).toBeVisible();
|
||||
await expect(page.getByText(/View on Bluesky/)).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
#### Test 2: Modal Stays Open
|
||||
```typescript
|
||||
test('Galaxy modal stays open when clicking node', async ({ page }) => {
|
||||
await page.goto('/galaxy');
|
||||
|
||||
// Click on a node sphere
|
||||
await page.evaluate(() => {
|
||||
const event = new CustomEvent('node-click', { detail: { nodeId: 'node:123' } });
|
||||
window.dispatchEvent(event);
|
||||
});
|
||||
|
||||
// Modal should open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Wait 2 seconds
|
||||
await page.waitForTimeout(2000);
|
||||
|
||||
// Modal should still be open
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
#### Test 3: Modal Navigation
|
||||
```typescript
|
||||
test('Can navigate from galaxy to node detail page', async ({ page }) => {
|
||||
await page.goto('/galaxy');
|
||||
|
||||
// Click node to open modal
|
||||
await clickNode(page, 'node:123');
|
||||
|
||||
// Modal opens
|
||||
await expect(page.getByRole('dialog')).toBeVisible();
|
||||
|
||||
// Click "View Full Detail"
|
||||
await page.click('button:has-text("View Full Detail")');
|
||||
|
||||
// Navigate to detail page
|
||||
await expect(page).toHaveURL(/\/galaxy\/node:123/);
|
||||
await expect(page.getByRole('dialog')).not.toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Magnitude Tests
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can navigate to node via direct URL', async (agent) => {
|
||||
// Create a node first
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Create a test node');
|
||||
const nodeUrl = await agent.getData('node-url');
|
||||
|
||||
// Navigate directly to node URL
|
||||
await agent.open(nodeUrl);
|
||||
await agent.check('Node detail page loads');
|
||||
await agent.check('Node title is visible');
|
||||
await agent.check('Node body is visible');
|
||||
await agent.check('Bluesky link is visible');
|
||||
});
|
||||
|
||||
test('Galaxy modal stays open when clicking nodes', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Click on a node sphere');
|
||||
|
||||
await agent.check('Modal opens');
|
||||
await agent.wait(2000);
|
||||
await agent.check('Modal is still open');
|
||||
await agent.check('Node details are visible');
|
||||
});
|
||||
|
||||
test('Can navigate from galaxy modal to node detail page', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.act('Click on a node');
|
||||
await agent.check('Modal opens');
|
||||
|
||||
await agent.act('Click "View Full Detail" button');
|
||||
await agent.check('Navigated to node detail page');
|
||||
await agent.check('URL contains node ID');
|
||||
await agent.check('Full node content is visible');
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Fix node detail page route**
|
||||
- Add proper authentication check
|
||||
- Add redirect with return URL
|
||||
- Add node ownership verification
|
||||
- Add error handling for missing nodes
|
||||
|
||||
2. **Fix modal event handling**
|
||||
- Add `event.stopPropagation()` to node click handler
|
||||
- Remove or condition any canvas-level click handlers
|
||||
- Ensure modal state is controlled by URL query param
|
||||
|
||||
3. **Test with Playwright MCP**
|
||||
- Test direct URL navigation
|
||||
- Test modal stays open
|
||||
- Test modal to detail page navigation
|
||||
- Fix any issues found
|
||||
|
||||
4. **Add Magnitude tests**
|
||||
- Write tests covering all 3 scenarios
|
||||
- Ensure tests pass
|
||||
|
||||
5. **Deploy and verify in production**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Direct node URLs (`/galaxy/node:xxx`) load correctly
|
||||
- ✅ No redirect to `/chat` when accessing node URLs
|
||||
- ✅ Galaxy modal stays open after clicking node
|
||||
- ✅ Can navigate from modal to detail page
|
||||
- ✅ Can navigate from detail page back to galaxy
|
||||
- ✅ All Playwright MCP tests pass
|
||||
- ✅ All Magnitude tests pass
|
||||
- ✅ No console errors during navigation
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `app/galaxy/[node-id]/page.tsx` - Fix authentication and routing
|
||||
2. `app/galaxy/page.tsx` - Fix modal state management
|
||||
3. `components/galaxy/ThoughtGalaxy.tsx` - Fix canvas click handling
|
||||
4. `components/galaxy/NodeSphere.tsx` - Fix node click event propagation
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `tests/playwright/galaxy-navigation.spec.ts` - Manual tests
|
||||
2. `tests/magnitude/galaxy-navigation.mag.ts` - Magnitude tests
|
||||
463
plans/05-dark-light-mode-theme.md
Normal file
463
plans/05-dark-light-mode-theme.md
Normal file
@@ -0,0 +1,463 @@
|
||||
# Plan: Add Dark/Light Mode with Dynamic Favicons
|
||||
|
||||
**Priority:** MEDIUM
|
||||
**Dependencies:** None
|
||||
**Affects:** User experience, accessibility, branding
|
||||
|
||||
## Overview
|
||||
|
||||
Add full dark mode / light mode support throughout the app, including:
|
||||
- Dynamic theme switching
|
||||
- Persistent user preference
|
||||
- System preference detection
|
||||
- Mode-specific favicons
|
||||
- Smooth transitions
|
||||
|
||||
## Current State
|
||||
|
||||
- App uses a minimal grayscale theme
|
||||
- No dark mode support
|
||||
- Single favicon for all modes
|
||||
- No theme toggle UI
|
||||
|
||||
## Proposed Implementation
|
||||
|
||||
### 1. Mantine Color Scheme Support
|
||||
|
||||
Mantine has built-in dark mode support. We'll leverage `@mantine/core` ColorSchemeScript and hooks.
|
||||
|
||||
#### Update `app/layout.tsx`
|
||||
```typescript
|
||||
import {
|
||||
ColorSchemeScript,
|
||||
MantineProvider,
|
||||
} from '@mantine/core';
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<head>
|
||||
<ColorSchemeScript defaultColorScheme="auto" />
|
||||
{/* Dynamic favicon based on theme */}
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-light.svg"
|
||||
media="(prefers-color-scheme: light)"
|
||||
/>
|
||||
<link
|
||||
rel="icon"
|
||||
href="/favicon-dark.svg"
|
||||
media="(prefers-color-scheme: dark)"
|
||||
/>
|
||||
</head>
|
||||
<body>
|
||||
<MantineProvider defaultColorScheme="auto">
|
||||
{children}
|
||||
</MantineProvider>
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Update Theme Configuration
|
||||
|
||||
#### `app/theme.ts`
|
||||
```typescript
|
||||
import { createTheme, MantineColorsTuple } from '@mantine/core';
|
||||
|
||||
const gray: MantineColorsTuple = [
|
||||
'#f8f9fa',
|
||||
'#f1f3f5',
|
||||
'#e9ecef',
|
||||
'#dee2e6',
|
||||
'#ced4da',
|
||||
'#adb5bd',
|
||||
'#868e96',
|
||||
'#495057',
|
||||
'#343a40',
|
||||
'#212529',
|
||||
];
|
||||
|
||||
export const theme = createTheme({
|
||||
fontFamily: 'Inter, system-ui, sans-serif',
|
||||
primaryColor: 'gray',
|
||||
colors: {
|
||||
gray,
|
||||
},
|
||||
|
||||
// Dark mode specific colors
|
||||
black: '#0a0a0a',
|
||||
white: '#ffffff',
|
||||
|
||||
// Component-specific dark mode overrides
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: (theme) => ({
|
||||
main: {
|
||||
backgroundColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[8]
|
||||
: theme.white,
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
Paper: {
|
||||
styles: (theme) => ({
|
||||
root: {
|
||||
backgroundColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.white,
|
||||
borderColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[5]
|
||||
: theme.colors.gray[3],
|
||||
},
|
||||
}),
|
||||
},
|
||||
|
||||
Modal: {
|
||||
styles: (theme) => ({
|
||||
content: {
|
||||
backgroundColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[7]
|
||||
: theme.white,
|
||||
},
|
||||
header: {
|
||||
backgroundColor: theme.colorScheme === 'dark'
|
||||
? theme.colors.dark[6]
|
||||
: theme.colors.gray[0],
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### 3. Theme Toggle Component
|
||||
|
||||
#### `components/ThemeToggle.tsx`
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { ActionIcon, useMantineColorScheme, useComputedColorScheme } from '@mantine/core';
|
||||
import { IconSun, IconMoon } from '@tabler/icons-react';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setColorScheme } = useMantineColorScheme();
|
||||
const computedColorScheme = useComputedColorScheme('light');
|
||||
|
||||
return (
|
||||
<ActionIcon
|
||||
onClick={() => setColorScheme(computedColorScheme === 'dark' ? 'light' : 'dark')}
|
||||
variant="subtle"
|
||||
size="lg"
|
||||
aria-label="Toggle color scheme"
|
||||
>
|
||||
{computedColorScheme === 'dark' ? (
|
||||
<IconSun size={20} />
|
||||
) : (
|
||||
<IconMoon size={20} />
|
||||
)}
|
||||
</ActionIcon>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 4. Add Toggle to Desktop Navigation
|
||||
|
||||
#### `components/DesktopNav.tsx`
|
||||
```typescript
|
||||
import { ThemeToggle } from './ThemeToggle';
|
||||
|
||||
export function DesktopNav() {
|
||||
return (
|
||||
<AppShell.Navbar p="md">
|
||||
<Stack justify="space-between" h="100%">
|
||||
<Stack>
|
||||
{/* Navigation items */}
|
||||
</Stack>
|
||||
|
||||
<Stack>
|
||||
<ThemeToggle />
|
||||
{/* Profile menu */}
|
||||
</Stack>
|
||||
</Stack>
|
||||
</AppShell.Navbar>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 5. Create Mode-Specific Favicons
|
||||
|
||||
#### Design Requirements
|
||||
- **Light mode favicon:** Dark icon on transparent background
|
||||
- **Dark mode favicon:** Light icon on transparent background
|
||||
- Both should be SVG for scalability
|
||||
- Simple, recognizable icon representing "thoughts" or "ponderants"
|
||||
|
||||
#### `public/favicon-light.svg`
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Dark icon for light mode -->
|
||||
<circle cx="50" cy="50" r="40" fill="#212529" />
|
||||
<circle cx="35" cy="45" r="5" fill="#ffffff" />
|
||||
<circle cx="65" cy="45" r="5" fill="#ffffff" />
|
||||
<path d="M 30 65 Q 50 75 70 65" stroke="#ffffff" stroke-width="3" fill="none" />
|
||||
</svg>
|
||||
```
|
||||
|
||||
#### `public/favicon-dark.svg`
|
||||
```svg
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
|
||||
<!-- Light icon for dark mode -->
|
||||
<circle cx="50" cy="50" r="40" fill="#f8f9fa" />
|
||||
<circle cx="35" cy="45" r="5" fill="#212529" />
|
||||
<circle cx="65" cy="45" r="5" fill="#212529" />
|
||||
<path d="M 30 65 Q 50 75 70 65" stroke="#212529" stroke-width="3" fill="none" />
|
||||
</svg>
|
||||
```
|
||||
|
||||
### 6. Update Galaxy Visualization for Dark Mode
|
||||
|
||||
The 3D galaxy needs to look good in both modes:
|
||||
|
||||
#### `components/galaxy/ThoughtGalaxy.tsx`
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useComputedColorScheme } from '@mantine/core';
|
||||
|
||||
export function ThoughtGalaxy() {
|
||||
const colorScheme = useComputedColorScheme('light');
|
||||
const isDark = colorScheme === 'dark';
|
||||
|
||||
return (
|
||||
<Canvas
|
||||
style={{
|
||||
background: isDark
|
||||
? 'linear-gradient(to bottom, #0a0a0a, #1a1a1a)'
|
||||
: 'linear-gradient(to bottom, #f8f9fa, #e9ecef)',
|
||||
}}
|
||||
>
|
||||
<ambientLight intensity={isDark ? 0.3 : 0.5} />
|
||||
<pointLight position={[10, 10, 10]} intensity={isDark ? 0.8 : 1} />
|
||||
|
||||
{/* Node spheres - adjust color based on theme */}
|
||||
<NodeSphere color={isDark ? '#f8f9fa' : '#212529'} />
|
||||
|
||||
{/* Link lines */}
|
||||
<LinkLine color={isDark ? 'rgba(248, 249, 250, 0.3)' : 'rgba(33, 37, 41, 0.3)'} />
|
||||
</Canvas>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 7. Persist User Preference
|
||||
|
||||
Mantine automatically stores the preference in localStorage with the key `mantine-color-scheme-value`.
|
||||
|
||||
For server-side rendering, we can add a cookie fallback:
|
||||
|
||||
#### `middleware.ts`
|
||||
```typescript
|
||||
import { NextResponse } from 'next/server';
|
||||
import type { NextRequest } from 'next/server';
|
||||
|
||||
export function middleware(request: NextRequest) {
|
||||
const response = NextResponse.next();
|
||||
|
||||
// Set color scheme cookie if not present
|
||||
if (!request.cookies.has('mantine-color-scheme')) {
|
||||
response.cookies.set('mantine-color-scheme', 'auto');
|
||||
}
|
||||
|
||||
return response;
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Playwright MCP Testing
|
||||
|
||||
#### Test 1: Theme Toggle
|
||||
```typescript
|
||||
test('User can toggle between light and dark mode', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Check initial theme
|
||||
const initialTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
});
|
||||
|
||||
// Click theme toggle
|
||||
await page.click('[aria-label="Toggle color scheme"]');
|
||||
|
||||
// Check theme changed
|
||||
const newTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
});
|
||||
|
||||
expect(newTheme).not.toBe(initialTheme);
|
||||
});
|
||||
```
|
||||
|
||||
#### Test 2: Theme Persistence
|
||||
```typescript
|
||||
test('Theme preference persists across page refreshes', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Set to dark mode
|
||||
await page.click('[aria-label="Toggle color scheme"]');
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
const darkTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
});
|
||||
|
||||
// Refresh page
|
||||
await page.reload();
|
||||
|
||||
// Check theme persisted
|
||||
const persistedTheme = await page.evaluate(() => {
|
||||
return document.documentElement.getAttribute('data-mantine-color-scheme');
|
||||
});
|
||||
|
||||
expect(persistedTheme).toBe(darkTheme);
|
||||
});
|
||||
```
|
||||
|
||||
#### Test 3: Favicon Changes
|
||||
```typescript
|
||||
test('Favicon changes with theme', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
|
||||
// Get favicon in light mode
|
||||
const lightFavicon = await page.evaluate(() => {
|
||||
const link = document.querySelector('link[rel="icon"]');
|
||||
return link?.getAttribute('href');
|
||||
});
|
||||
|
||||
// Switch to dark mode
|
||||
await page.click('[aria-label="Toggle color scheme"]');
|
||||
await page.waitForTimeout(100);
|
||||
|
||||
// Get favicon in dark mode
|
||||
const darkFavicon = await page.evaluate(() => {
|
||||
const link = document.querySelector('link[rel="icon"]');
|
||||
return link?.getAttribute('href');
|
||||
});
|
||||
|
||||
expect(lightFavicon).toContain('light');
|
||||
expect(darkFavicon).toContain('dark');
|
||||
});
|
||||
```
|
||||
|
||||
### Magnitude Tests
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('User can switch between light and dark mode', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.check('Page loads in default theme');
|
||||
|
||||
await agent.act('Click theme toggle button');
|
||||
await agent.check('Theme switches to opposite mode');
|
||||
await agent.check('All UI elements are visible in new theme');
|
||||
});
|
||||
|
||||
test('Dark mode looks good throughout app', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Switch to dark mode');
|
||||
|
||||
await agent.act('Navigate to /chat');
|
||||
await agent.check('Chat interface is readable in dark mode');
|
||||
|
||||
await agent.act('Navigate to /galaxy');
|
||||
await agent.check('Galaxy visualization looks good in dark mode');
|
||||
|
||||
await agent.act('Navigate to /edit');
|
||||
await agent.check('Editor is readable in dark mode');
|
||||
});
|
||||
|
||||
test('Theme preference persists', async (agent) => {
|
||||
await agent.open('http://localhost:3000');
|
||||
await agent.act('Switch to dark mode');
|
||||
await agent.check('App is in dark mode');
|
||||
|
||||
await agent.act('Refresh page');
|
||||
await agent.check('App remains in dark mode');
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Update Mantine provider and layout**
|
||||
- Add ColorSchemeScript to head
|
||||
- Add dynamic favicon links
|
||||
- Update MantineProvider with defaultColorScheme
|
||||
|
||||
2. **Update theme configuration**
|
||||
- Add dark mode color overrides
|
||||
- Update component styles for both modes
|
||||
|
||||
3. **Create ThemeToggle component**
|
||||
- Add sun/moon icon toggle
|
||||
- Wire up to Mantine color scheme hooks
|
||||
|
||||
4. **Add toggle to navigation**
|
||||
- Add to DesktopNav
|
||||
- Add to mobile nav if applicable
|
||||
|
||||
5. **Create favicons**
|
||||
- Design light mode favicon (dark icon)
|
||||
- Design dark mode favicon (light icon)
|
||||
- Export as SVG
|
||||
- Add to public folder
|
||||
|
||||
6. **Update galaxy visualization**
|
||||
- Add theme-aware colors
|
||||
- Test in both modes
|
||||
|
||||
7. **Test with Playwright MCP**
|
||||
- Test toggle functionality
|
||||
- Test persistence
|
||||
- Test favicon changes
|
||||
- Test all pages in both modes
|
||||
|
||||
8. **Add Magnitude tests**
|
||||
- Write comprehensive theme tests
|
||||
- Ensure all pass
|
||||
|
||||
9. **Commit and push (NO DEPLOY)**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ User can toggle between light and dark mode
|
||||
- ✅ Theme preference persists across sessions
|
||||
- ✅ Favicon changes based on active theme
|
||||
- ✅ All UI elements are readable in both modes
|
||||
- ✅ Galaxy visualization looks good in both modes
|
||||
- ✅ System preference is detected and respected
|
||||
- ✅ Smooth transitions between modes
|
||||
- ✅ All Playwright MCP tests pass
|
||||
- ✅ All Magnitude tests pass
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `components/ThemeToggle.tsx` - Toggle component
|
||||
2. `public/favicon-light.svg` - Light mode favicon
|
||||
3. `public/favicon-dark.svg` - Dark mode favicon
|
||||
4. `tests/playwright/theme.spec.ts` - Manual tests
|
||||
5. `tests/magnitude/theme.mag.ts` - Magnitude tests
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `app/layout.tsx` - Add ColorSchemeScript and dynamic favicons
|
||||
2. `app/theme.ts` - Add dark mode color overrides
|
||||
3. `components/DesktopNav.tsx` - Add ThemeToggle
|
||||
4. `components/galaxy/ThoughtGalaxy.tsx` - Add theme-aware colors
|
||||
171
plans/06-fix-double-border-desktop.md
Normal file
171
plans/06-fix-double-border-desktop.md
Normal file
@@ -0,0 +1,171 @@
|
||||
# Plan: Fix Double Border on Desktop Between Sidebar and Conversation
|
||||
|
||||
**Priority:** LOW - Visual polish
|
||||
**Dependencies:** None
|
||||
**Affects:** Desktop UI aesthetics
|
||||
|
||||
## Overview
|
||||
|
||||
There's a double border appearing on desktop between the sidebar and the main conversation area. This creates a visual inconsistency and needs to be fixed for a polished look.
|
||||
|
||||
## Current Issue
|
||||
|
||||
```
|
||||
+----------+||+-------------+
|
||||
| Sidebar ||| Main |
|
||||
| ||| Content |
|
||||
| ||| |
|
||||
+----------+||+-------------+
|
||||
^^
|
||||
Double border
|
||||
```
|
||||
|
||||
## Root Cause
|
||||
|
||||
Likely causes:
|
||||
1. Both AppShell.Navbar and AppShell.Main have borders
|
||||
2. Border-box sizing causing borders to stack
|
||||
3. Mantine default styles adding extra borders
|
||||
|
||||
## Investigation Steps
|
||||
|
||||
1. **Inspect current implementation**
|
||||
```typescript
|
||||
// Check components/DesktopNav.tsx
|
||||
// Check app/layout.tsx AppShell usage
|
||||
// Check theme.ts AppShell styles
|
||||
```
|
||||
|
||||
2. **Identify which elements have borders**
|
||||
- AppShell.Navbar
|
||||
- AppShell.Main
|
||||
- Any wrapper components
|
||||
|
||||
## Proposed Fixes
|
||||
|
||||
### Option 1: Remove Border from One Side
|
||||
|
||||
```typescript
|
||||
// app/theme.ts
|
||||
export const theme = createTheme({
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: {
|
||||
navbar: {
|
||||
borderRight: 'none', // Remove right border from navbar
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Option 2: Use Single Shared Border
|
||||
|
||||
```typescript
|
||||
// app/theme.ts
|
||||
export const theme = createTheme({
|
||||
components: {
|
||||
AppShell: {
|
||||
styles: (theme) => ({
|
||||
navbar: {
|
||||
borderRight: `1px solid ${theme.colors.gray[3]}`,
|
||||
},
|
||||
main: {
|
||||
borderLeft: 'none', // Remove left border from main
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
```
|
||||
|
||||
### Option 3: Use Divider Component
|
||||
|
||||
```typescript
|
||||
// components/DesktopNav.tsx
|
||||
<Group h="100%" gap={0} wrap="nowrap">
|
||||
<AppShell.Navbar>{/* ... */}</AppShell.Navbar>
|
||||
<Divider orientation="vertical" />
|
||||
<AppShell.Main>{/* ... */}</AppShell.Main>
|
||||
</Group>
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
### Manual Playwright MCP Test
|
||||
```typescript
|
||||
test('No double border between sidebar and main content', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
await page.setViewportSize({ width: 1920, height: 1080 });
|
||||
|
||||
// Take screenshot of border area
|
||||
const borderElement = page.locator('.mantine-AppShell-navbar');
|
||||
await borderElement.screenshot({ path: 'border-before.png' });
|
||||
|
||||
// Check computed styles
|
||||
const navbarBorder = await page.evaluate(() => {
|
||||
const navbar = document.querySelector('.mantine-AppShell-navbar');
|
||||
return window.getComputedStyle(navbar!).borderRight;
|
||||
});
|
||||
|
||||
const mainBorder = await page.evaluate(() => {
|
||||
const main = document.querySelector('.mantine-AppShell-main');
|
||||
return window.getComputedStyle(main!).borderLeft;
|
||||
});
|
||||
|
||||
// Only one should have a border
|
||||
const hasDouble = navbarBorder !== 'none' && mainBorder !== 'none';
|
||||
expect(hasDouble).toBe(false);
|
||||
});
|
||||
```
|
||||
|
||||
### Magnitude Test
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('Desktop sidebar has clean border', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
await agent.act('Resize window to desktop size (1920x1080)');
|
||||
await agent.check('Sidebar is visible');
|
||||
await agent.check('No double border between sidebar and content');
|
||||
await agent.check('Border is clean and single-width');
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Identify current border setup**
|
||||
- Inspect DesktopNav component
|
||||
- Check AppShell configuration
|
||||
- Check theme.ts
|
||||
|
||||
2. **Choose fix approach**
|
||||
- Decide which element keeps the border
|
||||
- Remove border from other element
|
||||
|
||||
3. **Implement fix**
|
||||
- Update theme.ts or component styles
|
||||
|
||||
4. **Test with Playwright MCP**
|
||||
- Visual inspection
|
||||
- Computed styles check
|
||||
|
||||
5. **Add Magnitude test**
|
||||
|
||||
6. **Commit and push**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Only one border between sidebar and main content
|
||||
- ✅ Border is clean and single-width
|
||||
- ✅ Consistent across all pages
|
||||
- ✅ Works in both light and dark mode
|
||||
- ✅ Playwright MCP test passes
|
||||
- ✅ Magnitude test passes
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `app/theme.ts` - Update AppShell border styles
|
||||
2. `tests/playwright/desktop-border.spec.ts` - Manual test
|
||||
3. `tests/magnitude/desktop-border.mag.ts` - Magnitude test
|
||||
78
plans/07-delete-backup-files.md
Normal file
78
plans/07-delete-backup-files.md
Normal file
@@ -0,0 +1,78 @@
|
||||
# Plan: Delete Backup/Old Page Files
|
||||
|
||||
**Priority:** LOW - Code cleanup
|
||||
**Dependencies:** None
|
||||
**Affects:** Code maintenance, repository cleanliness
|
||||
|
||||
## Overview
|
||||
|
||||
Remove all backup and old page.tsx files that are no longer needed. These files clutter the codebase and can cause confusion.
|
||||
|
||||
## Files to Delete
|
||||
|
||||
Need to search for:
|
||||
- `*.backup.*` files
|
||||
- `*.old.*` files
|
||||
- `*-old.*` files
|
||||
- Files with "backup" in the name
|
||||
- Commented-out old implementations
|
||||
|
||||
## Search Strategy
|
||||
|
||||
```bash
|
||||
# Find all backup files
|
||||
find . -name "*.backup.*" -o -name "*.old.*" -o -name "*-old.*"
|
||||
|
||||
# Find files with "backup" in name
|
||||
find . -iname "*backup*"
|
||||
|
||||
# Check git status for renamed files
|
||||
git status | grep -i backup
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Search for backup files**
|
||||
```bash
|
||||
find app -name "*.backup*" -o -name "*.old*"
|
||||
find components -name "*.backup*" -o -name "*.old*"
|
||||
find lib -name "*.backup*" -o -name "*.old*"
|
||||
```
|
||||
|
||||
2. **Verify files are not imported anywhere**
|
||||
```bash
|
||||
# For each found file, check if it's imported
|
||||
grep -r "import.*from.*filename" .
|
||||
```
|
||||
|
||||
3. **Delete files**
|
||||
```bash
|
||||
git rm app/chat/page.tsx.backup
|
||||
# ... repeat for each file
|
||||
```
|
||||
|
||||
4. **Check for commented-out code**
|
||||
- Review recent commits for large commented blocks
|
||||
- Remove if no longer needed
|
||||
|
||||
5. **Commit deletion**
|
||||
```bash
|
||||
git commit -m "chore: Remove backup and old files"
|
||||
git push origin development
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ No `.backup` files in codebase
|
||||
- ✅ No `.old` files in codebase
|
||||
- ✅ No files with "backup" in name
|
||||
- ✅ Code still builds successfully
|
||||
- ✅ All tests still pass
|
||||
|
||||
## Files to Delete
|
||||
|
||||
(Will be determined during search step)
|
||||
|
||||
Example:
|
||||
1. `app/chat/page.tsx.backup`
|
||||
2. Any other discovered backup files
|
||||
290
plans/08-ai-transition-to-edit.md
Normal file
290
plans/08-ai-transition-to-edit.md
Normal file
@@ -0,0 +1,290 @@
|
||||
# Plan: Allow AI to Transition to Edit Mode in Chat
|
||||
|
||||
**Priority:** MEDIUM
|
||||
**Dependencies:** None
|
||||
**Affects:** User experience, conversation flow
|
||||
|
||||
## Overview
|
||||
|
||||
Currently, users must manually click "Create Node" to transition from chat to the node editor. We should allow the AI to intelligently suggest and trigger this transition when appropriate during conversation.
|
||||
|
||||
## Current Flow
|
||||
|
||||
```
|
||||
User: "I want to write about quantum computing"
|
||||
AI: "That's interesting! Tell me more..."
|
||||
[User continues conversation]
|
||||
User: *clicks "Create Node" manually*
|
||||
→ Editor opens with AI-generated draft
|
||||
```
|
||||
|
||||
## Proposed Flow
|
||||
|
||||
```
|
||||
User: "I want to write about quantum computing"
|
||||
AI: "That's interesting! Tell me more..."
|
||||
[Conversation develops]
|
||||
AI: "It sounds like you have a solid idea forming. Would you like me to help you draft a node about this?"
|
||||
User: "Yes" / Clicks button
|
||||
AI: *automatically transitions to edit mode*
|
||||
→ Editor opens with AI-generated draft
|
||||
```
|
||||
|
||||
## Implementation Approach
|
||||
|
||||
### 1. Add Tool Use to AI System Prompt
|
||||
|
||||
Update the AI system prompt to include a `createNode` tool:
|
||||
|
||||
#### `app/api/chat/route.ts`
|
||||
```typescript
|
||||
import { tool } from 'ai';
|
||||
import { z } from 'zod';
|
||||
|
||||
const tools = {
|
||||
createNode: tool({
|
||||
description: 'Create a thought node when the user has developed a coherent idea worth capturing. Use this when the conversation has explored a topic deeply enough to write about it.',
|
||||
parameters: z.object({
|
||||
reason: z.string().describe('Why you think this conversation is ready to become a node'),
|
||||
}),
|
||||
execute: async ({ reason }) => {
|
||||
return { shouldTransition: true, reason };
|
||||
},
|
||||
}),
|
||||
};
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const { messages } = await request.json();
|
||||
|
||||
const result = streamText({
|
||||
model: google('gemini-2.0-flash-exp'),
|
||||
messages,
|
||||
system: `You are a thoughtful AI assistant helping users explore and capture their ideas...
|
||||
|
||||
When a conversation reaches a natural conclusion or the user has developed a coherent thought, you can suggest creating a node to capture it. Use the createNode tool when:
|
||||
- The conversation has explored a specific topic in depth
|
||||
- The user has articulated a complete idea or insight
|
||||
- There's enough substance for a meaningful blog post
|
||||
- The user expresses readiness to capture their thoughts
|
||||
|
||||
Be conversational and natural when suggesting this - don't force it if the conversation is still developing.`,
|
||||
tools,
|
||||
maxSteps: 5,
|
||||
});
|
||||
|
||||
return result.toDataStreamResponse();
|
||||
}
|
||||
```
|
||||
|
||||
### 2. Handle Tool Calls in Client
|
||||
|
||||
Update the chat interface to handle the createNode tool call:
|
||||
|
||||
#### `components/ChatInterface.tsx`
|
||||
```typescript
|
||||
'use client';
|
||||
|
||||
import { useChat } from 'ai/react';
|
||||
import { useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
|
||||
export function ChatInterface() {
|
||||
const router = useRouter();
|
||||
const { messages, input, handleInputChange, handleSubmit } = useChat({
|
||||
api: '/api/chat',
|
||||
onToolCall: async ({ toolCall }) => {
|
||||
if (toolCall.toolName === 'createNode') {
|
||||
// Show confirmation UI
|
||||
setShowNodeCreationPrompt(true);
|
||||
setNodeCreationReason(toolCall.args.reason);
|
||||
}
|
||||
},
|
||||
});
|
||||
|
||||
const [showNodeCreationPrompt, setShowNodeCreationPrompt] = useState(false);
|
||||
const [nodeCreationReason, setNodeCreationReason] = useState('');
|
||||
|
||||
const handleAcceptNodeCreation = async () => {
|
||||
// Generate node draft
|
||||
const response = await fetch('/api/generate-node-draft', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
conversation: messages,
|
||||
}),
|
||||
});
|
||||
|
||||
const { title, body } = await response.json();
|
||||
|
||||
// Transition to edit mode
|
||||
router.push(`/edit?title=${encodeURIComponent(title)}&body=${encodeURIComponent(body)}`);
|
||||
};
|
||||
|
||||
return (
|
||||
<Stack h="100%">
|
||||
{/* Chat messages */}
|
||||
|
||||
{/* Node creation prompt */}
|
||||
{showNodeCreationPrompt && (
|
||||
<Paper p="md" withBorder>
|
||||
<Stack>
|
||||
<Text size="sm" c="dimmed">{nodeCreationReason}</Text>
|
||||
<Group>
|
||||
<Button onClick={handleAcceptNodeCreation}>
|
||||
Create Node
|
||||
</Button>
|
||||
<Button variant="subtle" onClick={() => setShowNodeCreationPrompt(false)}>
|
||||
Continue Conversation
|
||||
</Button>
|
||||
</Group>
|
||||
</Stack>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
### 3. Alternative: Button-Based Approach (Simpler)
|
||||
|
||||
Instead of using tool calls, simply add a button that appears contextually:
|
||||
|
||||
```typescript
|
||||
export function ChatInterface() {
|
||||
const { messages } = useChat();
|
||||
const [showCreateButton, setShowCreateButton] = useState(false);
|
||||
|
||||
// Show "Create Node" button after 5+ messages
|
||||
useEffect(() => {
|
||||
if (messages.length >= 5) {
|
||||
setShowCreateButton(true);
|
||||
}
|
||||
}, [messages.length]);
|
||||
|
||||
return (
|
||||
<Stack>
|
||||
{/* Messages */}
|
||||
|
||||
{showCreateButton && (
|
||||
<Paper p="md" withBorder bg="gray.0">
|
||||
<Group justify="space-between">
|
||||
<Stack gap="xs">
|
||||
<Text fw={500}>Ready to capture this idea?</Text>
|
||||
<Text size="sm" c="dimmed">
|
||||
You've explored this topic well. Would you like to create a node?
|
||||
</Text>
|
||||
</Stack>
|
||||
<Button onClick={handleCreateNode}>
|
||||
Create Node
|
||||
</Button>
|
||||
</Group>
|
||||
</Paper>
|
||||
)}
|
||||
</Stack>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## Testing Strategy
|
||||
|
||||
### Manual Playwright MCP Test
|
||||
|
||||
```typescript
|
||||
test('AI suggests creating node after deep conversation', async ({ page }) => {
|
||||
await page.goto('/chat');
|
||||
|
||||
// Have a conversation
|
||||
await sendMessage(page, 'I want to write about philosophy');
|
||||
await waitForAIResponse(page);
|
||||
|
||||
await sendMessage(page, 'Specifically about existentialism');
|
||||
await waitForAIResponse(page);
|
||||
|
||||
await sendMessage(page, 'And how it relates to modern life');
|
||||
await waitForAIResponse(page);
|
||||
|
||||
// Check if AI suggests creating node
|
||||
await expect(page.getByText(/create a node/i)).toBeVisible({ timeout: 30000 });
|
||||
|
||||
// Click accept
|
||||
await page.click('button:has-text("Create Node")');
|
||||
|
||||
// Should navigate to editor
|
||||
await expect(page).toHaveURL(/\/edit/);
|
||||
await expect(page.getByRole('textbox', { name: /title/i })).toBeVisible();
|
||||
});
|
||||
```
|
||||
|
||||
### Magnitude Test
|
||||
|
||||
```typescript
|
||||
import { test } from 'magnitude-test';
|
||||
|
||||
test('AI intelligently suggests creating node', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
await agent.act('Have a conversation about quantum computing');
|
||||
await agent.act('Discuss multiple aspects over 5+ messages');
|
||||
|
||||
await agent.check('AI suggests creating a node');
|
||||
await agent.check('Suggestion includes reasoning');
|
||||
|
||||
await agent.act('Click "Create Node" button');
|
||||
await agent.check('Transitions to edit mode');
|
||||
await agent.check('Editor has AI-generated draft');
|
||||
});
|
||||
|
||||
test('User can decline node creation and continue chatting', async (agent) => {
|
||||
await agent.open('http://localhost:3000/chat');
|
||||
|
||||
await agent.act('Have a conversation that triggers node suggestion');
|
||||
await agent.check('AI suggests creating a node');
|
||||
|
||||
await agent.act('Click "Continue Conversation"');
|
||||
await agent.check('Suggestion dismisses');
|
||||
await agent.check('Can continue chatting');
|
||||
});
|
||||
```
|
||||
|
||||
## Implementation Steps
|
||||
|
||||
1. **Choose approach**
|
||||
- Tool-based (more intelligent, AI decides)
|
||||
- Button-based (simpler, always available after N messages)
|
||||
- Hybrid (both)
|
||||
|
||||
2. **Implement AI tool/prompt**
|
||||
- Add createNode tool definition
|
||||
- Update system prompt
|
||||
- Handle tool calls
|
||||
|
||||
3. **Update ChatInterface**
|
||||
- Add suggestion UI
|
||||
- Handle accept/decline
|
||||
- Transition to edit mode
|
||||
|
||||
4. **Test with Playwright MCP**
|
||||
- Test suggestion appears
|
||||
- Test accept flow
|
||||
- Test decline flow
|
||||
|
||||
5. **Add Magnitude tests**
|
||||
|
||||
6. **Commit and push**
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ AI suggests node creation at appropriate times
|
||||
- ✅ User can accept and transition to edit mode
|
||||
- ✅ User can decline and continue conversation
|
||||
- ✅ Transition is smooth and intuitive
|
||||
- ✅ Works well in conversation flow
|
||||
- ✅ Playwright MCP tests pass
|
||||
- ✅ Magnitude tests pass
|
||||
|
||||
## Files to Update
|
||||
|
||||
1. `app/api/chat/route.ts` - Add tool or enhanced prompt
|
||||
2. `components/ChatInterface.tsx` - Add suggestion UI and handling
|
||||
3. `tests/playwright/ai-to-edit.spec.ts` - Manual tests
|
||||
4. `tests/magnitude/ai-to-edit.mag.ts` - Magnitude tests
|
||||
254
plans/09-umap-minimum-nodes-analysis.md
Normal file
254
plans/09-umap-minimum-nodes-analysis.md
Normal file
@@ -0,0 +1,254 @@
|
||||
# Plan: Analysis - Why Wait for Three Nodes Before UMAP?
|
||||
|
||||
**Priority:** LOW - Analysis and potential optimization
|
||||
**Dependencies:** None
|
||||
**Affects:** User experience for early galaxy usage
|
||||
|
||||
## Question
|
||||
|
||||
Why do we wait until the user has created 3 nodes before running UMAP to calculate 3D coordinates? Is this an arbitrary choice or is there a technical reason?
|
||||
|
||||
## Current Implementation
|
||||
|
||||
```typescript
|
||||
// app/api/nodes/route.ts (lines 277-305)
|
||||
const totalNodes = countResult[0]?.[0]?.total || 0;
|
||||
|
||||
if (totalNodes >= 3) {
|
||||
// Trigger UMAP calculation
|
||||
fetch(`${process.env.NEXT_PUBLIC_APP_URL}/api/calculate-graph`, {
|
||||
method: 'POST',
|
||||
headers: { 'Cookie': cookieHeader },
|
||||
});
|
||||
}
|
||||
```
|
||||
|
||||
## UMAP Technical Requirements
|
||||
|
||||
### Minimum Data Points
|
||||
|
||||
UMAP (Uniform Manifold Approximation and Projection) is a dimensionality reduction algorithm. Let's investigate its minimum requirements:
|
||||
|
||||
1. **Mathematical minimum**: UMAP needs at least `nNeighbors + 1` data points
|
||||
- Our config: `nNeighbors: Math.min(15, nodes.length - 1)`
|
||||
- So technically we need minimum 2 points
|
||||
|
||||
2. **Practical minimum**: With 1-2 points, the projection is trivial:
|
||||
- 1 point: Just sits at origin [0, 0, 0]
|
||||
- 2 points: Linear projection on single axis
|
||||
- 3+ points: Meaningful 3D spatial distribution
|
||||
|
||||
3. **Meaningful visualization**: For interesting galaxy visualization:
|
||||
- 1 node: Just a single sphere (boring)
|
||||
- 2 nodes: Two spheres in a line (boring)
|
||||
- 3 nodes: Triangle configuration (starting to be interesting)
|
||||
- 4+ nodes: Complex 3D structure (compelling)
|
||||
|
||||
## Options to Consider
|
||||
|
||||
### Option 1: Keep Current (3-node minimum) ✅ RECOMMENDED
|
||||
|
||||
**Pros:**
|
||||
- Ensures meaningful visualization
|
||||
- UMAP produces better results with more data
|
||||
- Avoids wasted computation on trivial cases
|
||||
- User has enough content to make galaxy worth exploring
|
||||
|
||||
**Cons:**
|
||||
- User can't see galaxy until 3rd node
|
||||
- Feels like arbitrary limitation
|
||||
|
||||
### Option 2: Allow 1+ nodes (Calculate always)
|
||||
|
||||
**Pros:**
|
||||
- Galaxy available immediately
|
||||
- No waiting for 3rd node
|
||||
- Simpler logic
|
||||
|
||||
**Cons:**
|
||||
- 1-2 nodes produce boring visualization (single point, line)
|
||||
- Wasted UMAP computation on trivial cases
|
||||
- Poor user experience showing "empty" galaxy
|
||||
|
||||
### Option 3: Fallback Layout for 1-2 Nodes
|
||||
|
||||
**Pros:**
|
||||
- Galaxy available immediately
|
||||
- 1-2 nodes get simple predetermined positions
|
||||
- UMAP kicks in at 3+ for interesting layout
|
||||
- Best of both worlds
|
||||
|
||||
**Cons:**
|
||||
- More complex implementation
|
||||
- Potential confusion when layout suddenly changes at 3rd node
|
||||
|
||||
```typescript
|
||||
function calculateNodePositions(nodes: NodeData[]): NodeData[] {
|
||||
if (nodes.length === 1) {
|
||||
// Single node at origin
|
||||
return [{
|
||||
...nodes[0],
|
||||
coords_3d: [0, 0, 0],
|
||||
}];
|
||||
}
|
||||
|
||||
if (nodes.length === 2) {
|
||||
// Two nodes on X axis
|
||||
return [
|
||||
{ ...nodes[0], coords_3d: [-1, 0, 0] },
|
||||
{ ...nodes[1], coords_3d: [1, 0, 0] },
|
||||
];
|
||||
}
|
||||
|
||||
// 3+ nodes: Use UMAP
|
||||
return runUMAP(nodes);
|
||||
}
|
||||
```
|
||||
|
||||
### Option 4: Show Empty State with Onboarding
|
||||
|
||||
**Pros:**
|
||||
- Clear communication about galaxy feature
|
||||
- Educational for new users
|
||||
- No computation wasted
|
||||
- Encourages node creation
|
||||
|
||||
**Cons:**
|
||||
- More UI work
|
||||
- Doesn't solve the "when to calculate" question
|
||||
|
||||
```typescript
|
||||
// app/galaxy/page.tsx
|
||||
if (nodes.length === 0) {
|
||||
return <EmptyState message="Create your first node to start your galaxy!" />;
|
||||
}
|
||||
|
||||
if (nodes.length < 3) {
|
||||
return <PartialState message={`Create ${3 - nodes.length} more nodes to see your galaxy visualization!`} />;
|
||||
}
|
||||
```
|
||||
|
||||
## Recommendation
|
||||
|
||||
**Keep the 3-node minimum** for the following reasons:
|
||||
|
||||
1. **User Experience**
|
||||
- 1-2 nodes produce boring visualizations that don't showcase the galaxy feature
|
||||
- Better to show compelling visualization from the start
|
||||
- Empty state can explain "create 3 nodes to unlock galaxy"
|
||||
|
||||
2. **Technical Quality**
|
||||
- UMAP produces better results with more data points
|
||||
- 3 points is mathematical minimum for interesting 3D distribution
|
||||
- Avoids wasted computation on trivial cases
|
||||
|
||||
3. **Product Story**
|
||||
- Forces users to create meaningful content before "unlocking" visualization
|
||||
- Makes galaxy feel like a reward for engagement
|
||||
- Aligns with the product vision of "network of thoughts"
|
||||
|
||||
## Potential Enhancements
|
||||
|
||||
### 1. Better Onboarding
|
||||
```typescript
|
||||
// Show progress toward galaxy unlock
|
||||
<Progress value={(nodes.length / 3) * 100} label={`${nodes.length}/3 nodes created`} />
|
||||
```
|
||||
|
||||
### 2. Preview Mode
|
||||
```typescript
|
||||
// Show static preview of galaxy with 1-2 nodes
|
||||
<GalaxyPreview nodes={nodes} message="Create 1 more node to unlock 3D visualization!" />
|
||||
```
|
||||
|
||||
### 3. Configurable Threshold
|
||||
```typescript
|
||||
// Allow power users to adjust in settings
|
||||
const UMAP_MINIMUM_NODES = userSettings.umapMinimum || 3;
|
||||
```
|
||||
|
||||
## Implementation (If Changing)
|
||||
|
||||
If we decide to implement Option 3 (fallback layout):
|
||||
|
||||
1. **Update calculate-graph logic**
|
||||
```typescript
|
||||
if (nodes.length < 3) {
|
||||
return simpleLayout(nodes);
|
||||
}
|
||||
return umapLayout(nodes);
|
||||
```
|
||||
|
||||
2. **Add simple layout function**
|
||||
```typescript
|
||||
function simpleLayout(nodes: NodeData[]): NodeData[] {
|
||||
// Predetermined positions for 1-2 nodes
|
||||
}
|
||||
```
|
||||
|
||||
3. **Update API response**
|
||||
```typescript
|
||||
return NextResponse.json({
|
||||
nodes_mapped: nodes.length,
|
||||
layout_method: nodes.length < 3 ? 'simple' : 'umap',
|
||||
});
|
||||
```
|
||||
|
||||
## Testing
|
||||
|
||||
If implementing changes:
|
||||
|
||||
### Playwright MCP Test
|
||||
```typescript
|
||||
test('Galaxy works with 1-2 nodes', async ({ page }) => {
|
||||
// Create 1 node
|
||||
await createNode(page, 'First Node');
|
||||
await page.goto('/galaxy');
|
||||
await expect(page.locator('canvas')).toBeVisible();
|
||||
|
||||
// Create 2nd node
|
||||
await createNode(page, 'Second Node');
|
||||
await page.goto('/galaxy');
|
||||
await expect(page.locator('canvas')).toBeVisible();
|
||||
|
||||
// Should see 2 nodes
|
||||
const nodeCount = await getNodeCount(page);
|
||||
expect(nodeCount).toBe(2);
|
||||
});
|
||||
```
|
||||
|
||||
### Magnitude Test
|
||||
```typescript
|
||||
test('User understands galaxy requirement', async (agent) => {
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
|
||||
// With 0 nodes
|
||||
await agent.check('Sees message about creating nodes');
|
||||
await agent.check('Message says "3 nodes" or similar');
|
||||
|
||||
// After creating 1 node
|
||||
await agent.act('Create first node');
|
||||
await agent.open('http://localhost:3000/galaxy');
|
||||
await agent.check('Sees progress toward 3 nodes');
|
||||
});
|
||||
```
|
||||
|
||||
## Success Criteria
|
||||
|
||||
- ✅ Clear documentation of why 3-node minimum exists
|
||||
- ✅ User understands requirement through UI/messaging
|
||||
- ✅ If changed: Works correctly with 1-2 nodes
|
||||
- ✅ If changed: Smooth transition to UMAP at 3+ nodes
|
||||
- ✅ Tests pass
|
||||
|
||||
## Files to Update (if implementing changes)
|
||||
|
||||
1. `app/api/calculate-graph/route.ts` - Add fallback layout logic
|
||||
2. `app/galaxy/page.tsx` - Add better onboarding messaging
|
||||
3. `docs/architecture.md` - Document decision (create if needed)
|
||||
|
||||
## Files to Create
|
||||
|
||||
1. `docs/decisions/umap-minimum-nodes.md` - Document the decision
|
||||
2. `tests/playwright/galaxy-onboarding.spec.ts` - If implementing changes
|
||||
3. `tests/magnitude/galaxy-onboarding.mag.ts` - If implementing changes
|
||||
Reference in New Issue
Block a user