Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 10 additions & 5 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -120,7 +120,7 @@ interface LearningRecord {
}
```

## Dexie Schema (v10)
## Dexie Schema (v15)

```typescript
contents: 'id, type, category, source, difficulty, createdAt, updatedAt, *tags'
Expand All @@ -132,6 +132,11 @@ favorites: 'id, normalizedText, type, folderId, sourceContentId, targetLang, nex
favoriteFolders: 'id, sortOrder, createdAt'
lookupHistory: 'text, count, lastLookedUp'
translationCache: 'key, createdAt'
mediaBlobs: 'contentId, createdAt'
alignmentCache: 'cacheKey, createdAt'
weakSpots: 'id, module, weakSpotType, normalizedText, lastSeenAt, resolved, [module+weakSpotType+normalizedText]'
collections: 'id, category, source, difficulty, createdAt, updatedAt, *tags'
journals: 'id, lessonDate, source, updatedAt, *tags'
```

## Routing Structure
Expand Down Expand Up @@ -210,14 +215,14 @@ app/

## Stores (src/stores/)

20 Zustand stores, one per feature domain:
`appearance`, `assessment`, `auth`, `book`, `chat`, `content`, `daily-plan`, `favorite`, `language`, `practice-translation`, `preset-tags`, `pronunciation`, `provider`, `shadow-reading`, `shortcut`, `speak`, `sync`, `tts`, `updater`, `wordbook`
25 Zustand stores, one per feature domain:
`appearance`, `assessment`, `auth`, `book`, `chat`, `collection`, `content`, `daily-plan`, `favorite`, `journal`, `language`, `learning-goal`, `practice-translation`, `preset-tags`, `pronunciation`, `provider`, `read-aloud`, `shadow-reading`, `shortcut`, `speak`, `sync`, `tts`, `updater`, `weak-spots`, `wordbook`


<claude-mem-context>
# Memory Context

# [echo-type] recent context, 2026-04-29 9:03am GMT+8
# [echo-type] recent context, 2026-07-02 9:15am GMT+8

No previous sessions found.
</claude-mem-context>
</claude-mem-context>
4 changes: 2 additions & 2 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,7 @@ Package manager is **pnpm** (enforced — do not use npm/yarn).
- **Next.js 16** (App Router) + React 19 + TypeScript
- **Tailwind CSS v4** + shadcn/ui (New York style, Lucide icons)
- **Zustand** for state (one store per domain)
- **Dexie.js** (IndexedDB) for local data, schema version 8
- **Dexie.js** (IndexedDB) for local data, schema version 15
- **Supabase** for auth + cloud sync
- **Vercel AI SDK** with 15+ provider support
- **Tauri v2** for desktop wrapper (Rust backend)
Expand All @@ -47,7 +47,7 @@ Package manager is **pnpm** (enforced — do not use npm/yarn).

**Components** — organized by domain (`components/chat/`, `components/library/`, etc.) with shadcn/ui primitives in `components/ui/`.

**Stores** (`src/stores/`) — 18 Zustand stores, one per feature domain. Stores hydrate from localStorage on app init in `(app)/layout.tsx`.
**Stores** (`src/stores/`) — 25 Zustand stores, one per feature domain. Stores hydrate from localStorage on app init in `(app)/layout.tsx`.

**Hooks** (`src/hooks/`) — typing reducer, TTS, voice recognition, pronunciation, shortcuts, translation, analytics.

Expand Down
26 changes: 12 additions & 14 deletions docs/vercel-deployment.md
Original file line number Diff line number Diff line change
Expand Up @@ -39,12 +39,7 @@

### 3.1 本地文件写入不是持久存储

受影响文件:

- `src/app/api/import/upload-media/route.ts`
- `src/app/api/tools/extract/route.ts`

这两个接口会把文件写到 `public/media`。按 Vercel 当前文档,Functions 文件系统是只读的,只有 `/tmp` 提供临时可写 scratch space,因此不能把运行时写入内容当成长期媒体存储来用。结果就是:
当前本地上传的媒体文件会保存到浏览器 IndexedDB 的 `mediaBlobs` 表;这适合单设备本地使用,但不是跨设备/线上持久对象存储。按 Vercel 当前文档,Functions 文件系统是只读的,只有 `/tmp` 提供临时可写 scratch space,因此不能把运行时写入内容当成长期媒体存储来用。结果就是:

- 上传后文件可能在后续请求中消失
- 多个实例之间不会共享这些文件
Expand Down Expand Up @@ -78,10 +73,11 @@
- **仅支持 YouTube**(使用公开 Transcript API)
- 无需 API key,无需 yt-dlp 二进制
- 自动提取视频字幕和元数据
- 不提供音频下载(Vercel 文件系统限制)
- `/api/tools/download` 返回 501,提示使用字幕导入或本地/桌面端下载

3. **优雅降级**:
- 非 YouTube 平台在 Vercel 上返回 501 错误,提示仅支持 YouTube
- 媒体下载在 Vercel 上返回 501 错误,避免尝试执行不存在的 `yt-dlp`
- 错误信息包含友好提示,告知用户本地开发可支持更多平台

**技术实现**:
Expand Down Expand Up @@ -404,23 +400,25 @@ pnpm dlx vercel --prod
- 登录后改用用户自己的 provider key
- 如果你的产品流量上升,尽快接入更强的服务端限流和配额体系

### 12.3 上传音频后地址能返回,但过一段时间失效
### 12.3 上传音频为什么不能跨设备同步

原因:

- 当前实现把文件写入 `public/media`
- Vercel 运行时文件系统不是持久存储
- 当前本地上传媒体保存在浏览器 IndexedDB 的 `mediaBlobs` 表
- 这不是对象存储,也不会自动跨设备同步
- Vercel 运行时文件系统不是持久存储,不能用来保存长期媒体文件

解决:

- 改成对象存储
- 需要线上持久媒体时,改成 Vercel Blob、S3、R2 或 Supabase Storage

### 12.4 YouTube / 媒体下载功能在线上不可用

原因:

- 当前代码依赖 `yt-dlp`
- `/api/tools/download` 的完整下载能力依赖 `yt-dlp`
- Vercel 运行环境不能假设存在该二进制
- 当前 route 在线上会返回 501,避免误执行本地二进制流程

解决:

Expand All @@ -442,8 +440,8 @@ pnpm dlx vercel --prod

如果你希望 EchoType 在 Vercel 上完整支持“媒体导入”相关能力,优先做这三件事:

1. 把 `upload-media` 改成对象存储上传
2. 把 `yt-dlp` 能力迁移到独立 Worker / 后端
1. 把本地媒体上传改成对象存储上传
2. 把 `yt-dlp` 下载能力迁移到独立 Worker / 后端
3. 给 Supabase 增加生产环境配置与云端数据同步

## 14. 结论
Expand Down
6 changes: 6 additions & 0 deletions e2e/app-shell.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ test.describe('App Shell & Navigation', () => {
await expect(sidebar.getByText('Dashboard')).toBeVisible();
await expect(sidebar.getByText('Listen')).toBeVisible();
await expect(sidebar.getByText('Speak')).toBeVisible();
await expect(sidebar.getByText('Pronunciation')).toBeVisible();
await expect(sidebar.getByText('Read')).toBeVisible();
await expect(sidebar.getByText('Write')).toBeVisible();
await expect(sidebar.getByText('Library')).toBeVisible();
Expand All @@ -35,6 +36,11 @@ test.describe('App Shell & Navigation', () => {
await expect(page).toHaveURL(/\/speak/);
await expect(page.getByRole('heading', { level: 1 })).toContainText('Speak');

// Navigate to Pronunciation
await sidebar.getByText('Pronunciation').click();
await expect(page).toHaveURL(/\/pronunciation/);
await expect(page.getByRole('heading', { level: 1 })).toContainText('Pronunciation Practice');

// Navigate to Read
await sidebar.getByText('Read').click();
await expect(page).toHaveURL(/\/read/);
Expand Down
52 changes: 37 additions & 15 deletions e2e/dashboard-daily-plan.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -394,8 +394,21 @@ async function seedDailyPlanFixture(
} = {},
) {
const { contents = fixtureContents, sessions = fixtureSessions, assessment } = options;

await page.evaluate(async ({ contents: nextContents, sessions: nextSessions, nextAssessment }) => {
const dailyPlanState = {
goal: { wordsPerDay: 3, sessionsPerDay: 4 },
tasks: [],
dateKey: '',
dataSignature: '',
levelKey: '',
streak: 0,
lastActiveDate: '',
};

await page.addInitScript((state) => {
localStorage.setItem('echotype_daily_plan', JSON.stringify(state));
}, dailyPlanState);

await page.evaluate(async ({ contents: nextContents, sessions: nextSessions, nextAssessment, nextDailyPlanState }) => {
localStorage.removeItem('echotype_daily_plan');
if (nextAssessment) {
localStorage.setItem(
Expand Down Expand Up @@ -443,11 +456,13 @@ async function seedDailyPlanFixture(
}
await waitForTransaction(sessionTransaction);
db.close();
}, { contents, sessions, nextAssessment: assessment });

localStorage.setItem('echotype_daily_plan', JSON.stringify(nextDailyPlanState));
}, { contents, sessions, nextAssessment: assessment, nextDailyPlanState: dailyPlanState });
}

async function goBackToDashboard(page: Page) {
await page.getByRole('link', { name: 'Dashboard' }).click();
await page.getByRole('link', { name: 'Dashboard', exact: true }).click();
await expect(page).toHaveURL(/\/dashboard$/);
}

Expand Down Expand Up @@ -520,25 +535,31 @@ test.describe('Dashboard daily plan', () => {
await expect(page.getByText('Weather Report')).toBeVisible();
await expect(page.getByText('0/4 completed')).toBeVisible();

await page.locator('a[href="/write/book/daily-vocab"]').click();
await expect(page).toHaveURL(/\/write\/book\/daily-vocab$/);
await page.getByPlaceholder('Type the text above...').fill('Morning routines build consistency.');
await page.getByRole('button', { name: 'Check' }).click();
await expect(page.getByText('Correct! Moving to next...')).toBeVisible();
await page.locator('a[href^="/write/book/"][href$="?limit=3"]').first().click();
await expect(page).toHaveURL(/\/write\/book\/[^/]+\?limit=3$/);
for (const index of [0, 1, 2]) {
const word = await page.locator('h2').first().textContent();
expect(word).toBeTruthy();
await page.getByPlaceholder('Type the text above...').fill(word!);
await page.getByRole('button', { name: 'Check' }).click();
if (index < 2) {
await expect(page.getByText('Correct! Moving to next...')).toBeVisible();
await expect(page.locator(`text=/${index + 2} \\/ 3/`)).toBeVisible();
}
}
await expect(page.getByText('Session Complete!')).toBeVisible({ timeout: 3000 });

await goBackToDashboard(page);
await expect(page.getByText('1/4 completed')).toBeVisible();
await expect(page.getByText(/routine/i)).toBeVisible();

await page.locator('a[href="/speak/book/coffee-shop"]').click();
await expect(page).toHaveURL(/\/speak\/book\/coffee-shop$/);
await page.locator('a[href^="/speak/book/"]').first().click();
await expect(page).toHaveURL(/\/speak\/book\/[^/]+/);
await page.locator('button.rounded-full.bg-green-500').click();
await emitCurrentPracticeText(page, 'speak');
await expect(page.getByText(/100% accuracy/i)).toBeVisible();
await expect(page.getByTestId('wordbook-pronunciation-panel')).toContainText('100%');

await goBackToDashboard(page);
await expect(page.getByText('2/4 completed')).toBeVisible();
await expect(page.getByRole('link', { name: /Seasonal drink Speak/i })).toBeVisible();

await page.locator('a[href="/read/article-1"]').click();
await expect(page).toHaveURL(/\/read\/article-1$/);
Expand All @@ -556,7 +577,8 @@ test.describe('Dashboard daily plan', () => {

await goBackToDashboard(page);
await expect(page.getByText('4/4 completed')).toBeVisible();
await expect(page.getByRole('link', { name: /Weather Report Listen/i })).toBeVisible();
await expect(page.getByText('Weather Report').first()).toBeVisible();
await expect(page.locator('a[href="/listen/listen-1"]').first()).toBeVisible();
});

test('generates level-aware recommendations that match the user assessment', async ({ page }) => {
Expand Down
94 changes: 94 additions & 0 deletions e2e/pronunciation.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { expect, test, type Page } from '@playwright/test';

async function installSpeechMocks(page: Page) {
await page.addInitScript(() => {
class FakeSpeechRecognition extends EventTarget {
static instances: FakeSpeechRecognition[] = [];
continuous = false;
interimResults = false;
lang = 'en-US';
onresult: ((event: SpeechRecognitionEvent) => void) | null = null;
onend: (() => void) | null = null;

constructor() {
super();
FakeSpeechRecognition.instances.push(this);
}

start() {}
stop() {
this.onend?.();
}
abort() {
this.onend?.();
}
}

const speechWindow = window as Window &
typeof globalThis & {
SpeechRecognition?: typeof FakeSpeechRecognition;
webkitSpeechRecognition?: typeof FakeSpeechRecognition;
__emitPronunciationSpeech?: (text: string) => void;
__spokenTexts?: string[];
};

speechWindow.SpeechRecognition = FakeSpeechRecognition;
speechWindow.webkitSpeechRecognition = FakeSpeechRecognition;
speechWindow.__emitPronunciationSpeech = (text: string) => {
const instance = FakeSpeechRecognition.instances.at(-1);
instance?.onresult?.({
resultIndex: 0,
results: [[{ transcript: text, confidence: 1 }]],
} as unknown as SpeechRecognitionEvent);
};

Object.defineProperty(window, 'speechSynthesis', {
configurable: true,
value: {
cancel: () => {},
speak: (utterance: SpeechSynthesisUtterance) => {
speechWindow.__spokenTexts = [...(speechWindow.__spokenTexts ?? []), utterance.text];
setTimeout(() => utterance.onend?.(new Event('end') as SpeechSynthesisEvent), 0);
},
},
});
});
}

test.describe('Pronunciation practice', () => {
test('practices IPA sounds with speech feedback', async ({ page }) => {
await installSpeechMocks(page);
await page.goto('/pronunciation');

await expect(page.getByRole('heading', { name: 'Pronunciation Practice' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Vowels', exact: true })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Consonants' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Long Vowels' })).toBeVisible();
await expect(page.getByTestId('long-vowel-family-long-a')).toBeVisible();
const longA = page.getByTestId('sound-card-long-a-ai');
await expect(longA).toContainText('Practice /eɪ/: ay');
await expect(longA).toContainText('Long A');
await expect(longA).toContainText('Pattern ai');
await expect(longA).not.toContainText('rain');
await longA.getByRole('button', { name: 'Hear ay' }).click();
await expect.poll(() => page.evaluate(() => window.__spokenTexts?.at(-1))).toBe('ay');
await expect(longA).toContainText('Examples');
await expect(longA).toContainText('rain');
await expect(longA).toContainText('How to make it');
await expect(longA).toContainText('often in the middle of words');
await longA.getByRole('button', { name: 'Hear rain' }).click();
await expect.poll(() => page.evaluate(() => window.__spokenTexts?.at(-1))).toBe('rain');
await expect(longA.getByRole('button', { name: 'Listen' })).toBeVisible();
await longA.getByRole('button', { name: 'Record' }).click();
await page.evaluate(() => window.__emitPronunciationSpeech?.('ay'));

await expect(longA).toContainText('Browser score 100');
await expect(longA).toContainText('Target: ay');
await expect(longA).toContainText('Browser heard: ay');
await expect(longA).toContainText('Browser score');
await expect(longA).toContainText('Result: Clear');
await page.reload();
await expect(page.getByTestId('sound-card-long-a-ai')).toContainText('Browser score 100');
await expect(page.getByTestId('pronunciation-progress')).toContainText('1 /');
});
});
2 changes: 2 additions & 0 deletions e2e/read.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,8 @@ test.describe('Read Module', () => {
await expect(page.getByText('Reference Text')).toBeVisible();
await expect(page.getByRole('button', { name: 'Listen' })).toBeVisible();
await expect(page.getByTestId('read-aloud-inline-controls')).toBeVisible();
await expect(page.getByTestId('read-aloud-inline-controls')).not.toHaveClass(/(?:^|\s)fixed(?:\s|$)/);
await expect(page.getByTestId('read-aloud-inline-controls')).not.toHaveClass(/(?:^|\s)bottom-/);
await expect(page.getByText('Reset')).toBeVisible();
await expect(page.getByText('Read Aloud Mode')).toBeVisible();
await expect(page.getByRole('heading', { name: 'Your Speech' })).toHaveCount(0);
Expand Down
4 changes: 2 additions & 2 deletions e2e/wordbook-practice.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -226,7 +226,7 @@ test.describe('WordBook Practice – Write', () => {
await waitForPracticeCard(page);

// Get the text to type from the content area
const textContent = await page.locator('.bg-indigo-50\\/50 p').textContent();
const textContent = await page.getByTestId('listen-book-sentence').textContent();
expect(textContent).toBeTruthy();

// Type the text
Expand Down Expand Up @@ -302,7 +302,7 @@ test.describe('WordBook Practice – Write', () => {
await page.getByRole('button', { name: 'Check' }).click();

await expect(page.getByText('Session Complete!')).toBeVisible({ timeout: 3000 });
await expect(page.getByText('1', { exact: true })).toBeVisible();
await expect(page.locator('.bg-green-50').filter({ hasText: 'Completed' }).getByText('1', { exact: true })).toBeVisible();
});
});

Expand Down
6 changes: 6 additions & 0 deletions src/__tests__/wordbook-practice-progress.test.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { describe, expect, it } from 'vitest';
import {
canFinishWordBookPractice,
resolveWordBookPracticeItems,
type WordBookPracticeProgressSnapshot,
} from '@/lib/wordbook-practice-progress';
Expand Down Expand Up @@ -76,4 +77,9 @@ describe('wordbook-practice-progress', () => {
expect(resolved.items.map((item) => item.id).sort()).toEqual(['word-4', 'word-5']);
expect(resolved.restoredCompletedItemIds).toEqual([]);
});

it('allows finishing only after every item in the current practice set is completed', () => {
expect(canFinishWordBookPractice({ total: 3, completedCount: 2 })).toBe(false);
expect(canFinishWordBookPractice({ total: 3, completedCount: 3 })).toBe(true);
});
});
11 changes: 10 additions & 1 deletion src/app/(app)/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -50,7 +50,16 @@ function getNativeHostSearchParam(): string | null {
return new URLSearchParams(window.location.search).get('nativeHost');
}

const PRIMARY_APP_ROUTES = ['/dashboard', '/listen', '/speak', '/read', '/write', '/review/today', '/journal'];
const PRIMARY_APP_ROUTES = [
'/dashboard',
'/listen',
'/speak',
'/pronunciation',
'/read',
'/write',
'/review/today',
'/journal',
];

export default function AppLayout({ children }: { children: React.ReactNode }) {
const [seeded, setSeeded] = useState(false);
Expand Down
Loading
Loading