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
5 changes: 5 additions & 0 deletions .dev.vars.example
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
# Copy to `.dev.vars` for local `wrangler dev`. Never commit `.dev.vars`.
# Generate with: openssl rand -base64 32
API_KEY_COOKIE_SECRET=
# Optional, for secret rotation:
# API_KEY_COOKIE_SECRET_PREVIOUS=
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ node_modules
dist
dist-ssr
*.local
.dev.vars
.wrangler/

# Editor directories and files
.vscode/*
Expand Down
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -849,12 +849,15 @@ Close the session when done: `pw close` (optionally `pw delete-data`).
- Hide `ConversationHint` during TEF Ad Persuasion and TEF Ad Questioning practice only; keep hints for role-play scenario practice and free conversation.
- TEF in-session practice guide: per-topic accordions; on start/restart auto-attach the latest topic archive for the current ad (`latest_auto`).
- In LLM system prompts, prefer short behavioral rules over hardcoded lists of French verbs or phrases that may be appropriate in other conversational contexts.
- When rebuilding conversation context for the LLM (including regenerating an AI reply), send every user turn as the original audio recording, not transcript text—transcripts are often inaccurate. Match the TEF/review audio-first pattern.
- When rebuilding conversation context for the LLM (including regenerating an AI reply and BFF `POST /api/chat`), send every user turn as the original audio recording, not transcript text—transcripts are often inaccurate. Existing text-history reconstruction may remain for text-only paths; do not add new transcript-only substitutes for audio-history flows (chat, reviews).

## Learned Workspace Facts

- Continual-learning transcript processing for this project uses an index file under the main checkout: `01-projects/parle/.cursor/hooks/state/continual-learning-index.json`. `AGENTS.md` may be edited from a Cursor worktree (e.g. `worktrees/parle/<branch>/AGENTS.md`), so hook state and agent memory paths are not always the same directory.
- Continual-learning indexes can live in the main checkout (`01-projects/parle/.cursor/hooks/state/continual-learning-index.json`) or a worktree (`.cursor/hooks/state/continual-learning-index.json`); `AGENTS.md` may be edited from either, so hook state and memory paths are not always the same directory.
- Approved UI reference mockups for Parle may be extracted under `.mockup-ref/` (e.g. `TopicHistoryV2Demo.tsx`); treat as implementation reference only, not production dependencies.
- API keys are stored in an HttpOnly cookie sealed by a Cloudflare Worker BFF (`worker/`); `/api/*` runs Worker-first. Instantiate `@google/genai` per request in the Worker; do not bundle LangChain there—OpenAI planning uses fetch plus shared Zod.
- Stateless chat and related AI routes validate model JSON fail-closed on the Worker with the shared Zod schemas in `shared/chatSchemas.ts`, even if the client omits the schema.
- `.wrangler/` is gitignored Miniflare local state; keep `wrangler.jsonc`, `worker/`, and `worker-configuration.d.ts` in git. Local full-stack is `npm run dev:full` (Vite :3000 + wrangler :8787).

---

Expand Down
61 changes: 47 additions & 14 deletions App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,12 @@ import { useConversationTimer } from './hooks/useConversationTimer';
import { initializeSession, sendVoiceMessage, resetSession, resetSessionWithUserAudioHistory, setScenario, transcribeAndCleanupAudio, generateCharacterSpeech, PIPELINE_MAX_MS } from './services/geminiService';
import { processScenarioDescriptionOpenAI } from './services/openaiService';
import { clearHistory, getConversationHistory, setHistory } from './services/conversationHistory';
import { hasApiKeyOrEnv } from './services/apiKeyService';
import { BffError } from './services/bffClient';
import {
clearLegacyLocalStorageKeys,
hasApiKeyOrEnv,
refreshSessionStatus,
} from './services/apiKeyService';
import { assignVoicesToCharacters } from './services/voiceService';
import {
generateId,
Expand Down Expand Up @@ -600,6 +605,8 @@ const App: React.FC = () => {

// API Key management state
const [showApiKeyModal, setShowApiKeyModal] = useState(false);
const [apiKeyModalError, setApiKeyModalError] = useState<string | null>(null);
const [sessionEpoch, setSessionEpoch] = useState(0);
const [apiKeyCheckDone, setApiKeyCheckDone] = useState(false);

// Ref to track if we're recording for scenario description
Expand Down Expand Up @@ -633,6 +640,7 @@ const App: React.FC = () => {
const [chatProcessingErrorMessage, setChatProcessingErrorMessage] = useState('');

const hasMessages = messages.length > 0;
void sessionEpoch;
const geminiKeyMissing = apiKeyCheckDone && !hasApiKeyOrEnv('gemini');

/**
Expand Down Expand Up @@ -669,6 +677,9 @@ const App: React.FC = () => {
} catch (error) {
console.error('Failed to initialize durable local data:', error);
}
clearLegacyLocalStorageKeys();
await refreshSessionStatus();
setSessionEpoch((value) => value + 1);
setApiKeyCheckDone(true);
if (hasApiKeyOrEnv('gemini')) {
try {
Expand All @@ -683,7 +694,7 @@ const App: React.FC = () => {

// Handle API key save - re-initialize services if needed
const handleApiKeySave = async () => {
// Re-initialize Gemini session if Gemini key is now available
setSessionEpoch((value) => value + 1);
if (hasApiKeyOrEnv('gemini')) {
try {
await initializeSession();
Expand All @@ -694,10 +705,15 @@ const App: React.FC = () => {
setApiKeyCheckDone(true);
};

const openApiKeyModal = (message?: string) => {
setApiKeyModalError(message ?? null);
setShowApiKeyModal(true);
};

// Handle API key modal close
const handleApiKeyModalClose = () => {
setShowApiKeyModal(false);
// Mark that user has been offered the chance to enter keys
setApiKeyModalError(null);
setApiKeyCheckDone(true);
};

Expand Down Expand Up @@ -761,7 +777,7 @@ const App: React.FC = () => {

const handleStartRecording = async () => {
if (!hasApiKeyOrEnv('gemini')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}
if (!hasStarted) await handleStartInteraction();
Expand Down Expand Up @@ -883,6 +899,13 @@ const App: React.FC = () => {

const { base64, mimeType } = audioData;

const priorMessages = isRegenerate
? (() => {
const turn = findLastAssistantTurn(messagesRef.current);
return turn ? messagesRef.current.slice(0, turn.lastUserIndex) : [];
})()
: messagesRef.current;

// Build phase-based per-turn context for TEF Ad practice
// Skip context injection for the very first message (greeting turn)
let phaseContextText: string | undefined;
Expand All @@ -904,7 +927,8 @@ const App: React.FC = () => {
base64,
mimeType,
pipelineSignal,
phaseContextText
phaseContextText,
priorMessages
);

// Check if user aborted or a newer request has started (stale response)
Expand Down Expand Up @@ -1194,6 +1218,14 @@ const App: React.FC = () => {
}

console.error("Interaction failed", error);
if (error instanceof BffError && error.code === 'UPSTREAM_AUTH_FAILED') {
openApiKeyModal(error.message);
setChatProcessingErrorMessage(error.message);
setCanRetryChatAudio(true);
setAppState(AppState.ERROR);
showErrorFlash(error.message);
return;
}
setChatProcessingErrorMessage(defaultMsg);
setCanRetryChatAudio(true);
setAppState(AppState.ERROR);
Expand Down Expand Up @@ -1435,7 +1467,7 @@ const App: React.FC = () => {
const handleStartRecordingDescription = async () => {
// Scenario creation requires both Gemini (transcription) and OpenAI (planning)
if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}
try {
Expand Down Expand Up @@ -1686,7 +1718,7 @@ const App: React.FC = () => {
const handleSubmitScenarioDescription = async (description: string, name: string) => {
// Scenario creation requires both Gemini (transcription) and OpenAI (planning)
if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}
await processScenarioDescriptionAndPopulate(description);
Expand All @@ -1700,7 +1732,7 @@ const App: React.FC = () => {
// existing id so "Start Practice" from there updates it in place.
const handleRegenerateRoadmapForScenario = async (scenario: Scenario) => {
if (!hasApiKeyOrEnv('gemini') || !hasApiKeyOrEnv('openai')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}
setRegeneratingScenario(scenario);
Expand Down Expand Up @@ -1937,7 +1969,7 @@ const App: React.FC = () => {
existingAdId?: string
) => {
if (!hasApiKeyOrEnv('gemini')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}

Expand Down Expand Up @@ -2183,7 +2215,7 @@ const App: React.FC = () => {
existingAdId?: string
) => {
if (!hasApiKeyOrEnv('gemini')) {
setShowApiKeyModal(true);
openApiKeyModal();
return;
}

Expand Down Expand Up @@ -2516,7 +2548,7 @@ const App: React.FC = () => {
<TopBar
activeMode={activeMode}
onSelectMode={handleNavSelect}
onOpenSettings={() => setShowApiKeyModal(true)}
onOpenSettings={() => openApiKeyModal()}
disabledModes={navDisabledModes}
rightSlot={
<>
Expand Down Expand Up @@ -2781,7 +2813,7 @@ const App: React.FC = () => {
recentAdsRefreshToken={recentAdsRefreshToken}
onClose={handleCloseTefAdSetup}
geminiKeyMissing={geminiKeyMissing}
onOpenApiKeyModal={() => setShowApiKeyModal(true)}
onOpenApiKeyModal={() => openApiKeyModal()}
/>
)}

Expand All @@ -2795,7 +2827,7 @@ const App: React.FC = () => {
recentAdsRefreshToken={recentAdsRefreshToken}
onClose={() => setTefQuestioningMode('none')}
geminiKeyMissing={geminiKeyMissing}
onOpenApiKeyModal={() => setShowApiKeyModal(true)}
onOpenApiKeyModal={() => openApiKeyModal()}
/>
)}

Expand Down Expand Up @@ -2914,7 +2946,7 @@ const App: React.FC = () => {
{scenarioMode === 'setup' && (
<ScenarioSetup
onStartPractice={handleStartPractice}
onOpenApiKeyModal={() => setShowApiKeyModal(true)}
onOpenApiKeyModal={() => openApiKeyModal()}
onClose={handleCloseScenarioSetup}
isRecordingDescription={isRecordingDescription}
isTranscribingDescription={isTranscribingDescription}
Expand Down Expand Up @@ -2952,6 +2984,7 @@ const App: React.FC = () => {
onClose={handleApiKeyModalClose}
onSave={handleApiKeySave}
onImported={() => setRecentAdsRefreshToken((token) => token + 1)}
initialError={apiKeyModalError}
/>
)}

Expand Down
39 changes: 21 additions & 18 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@

## What is Parle?

Parle is a web app for practicing French conversation using your microphone and AI. You speak, the app transcribes and replies in French (with optional English), and you hear the reply via text-to-speech. No account required to try it; API keys are configured in the app or via environment variables.
Parle is a web app for practicing French conversation using your microphone and AI. You speak, the app transcribes and replies in French (with optional English), and you hear the reply via text-to-speech. No account required to try it; paste your API keys in Settings. The browser never stores readable keys — they are sealed in an HttpOnly cookie by the Cloudflare Worker BFF.

### Features

Expand All @@ -26,15 +26,16 @@ and (where applicable) timers and summaries are shown in the UI.
## Tech stack

- **Frontend:** React 19, Vite 7, TypeScript, Tailwind CSS (French-flag-inspired blue/white/red design tokens; responsive at `tablet` 760px / `desktop` 1200px breakpoints)
- **AI:** Google Gemini (transcription, chat, TTS); OpenAI optional for scenario creation from a description
- **BFF:** Cloudflare Worker (`worker/`) with static assets + `/api/*` routes; Gemini via `@google/genai` on the Worker; OpenAI scenario planning via `fetch`
- **Tests:** Vitest (unit), Playwright (e2e)

---

## Prerequisites

- **Node.js** (LTS recommended)
- **API keys:**
- **Wrangler** (installed with `npm install`) for the local BFF Worker
- **API keys** (pasted in Settings; sealed into an HttpOnly cookie):
- **Gemini** — required for voice conversation, scenario practice, and TEF modes (transcription, chat, TTS).
- **OpenAI** — optional; used only when creating a scenario from a spoken/typed description (scenario planning).

Expand All @@ -47,27 +48,27 @@ and (where applicable) timers and summaries are shown in the UI.
npm install
```

2. **Configure API keys** (pick one approach)
- **Option A — Environment (recommended for development)**
Create `.env.local` in the project root:
```env
GEMINI_API_KEY=your_gemini_key
OPENAI_API_KEY=your_openai_key # optional, for scenario-from-description
```
- **Option B — In-app**
Run the app; if no keys are found, you’ll be prompted to enter them. Keys are stored in the browser and override env vars.

3. **Start the app**
2. **Configure the Worker cookie secret**
```bash
npm run dev
cp .dev.vars.example .dev.vars
# Set API_KEY_COOKIE_SECRET to a high-entropy value, e.g. `openssl rand -base64 32`
```
Open the URL shown in the terminal (e.g. `http://localhost:5173`).

3. **Start Vite and the Worker together**
```bash
npm run dev:full
```
Or run `npm run dev` (http://localhost:3000) and `npm run dev:worker` (http://localhost:8787) in two terminals. Vite proxies `/api` to the Worker.

4. **Paste API keys in Settings** (gear icon). Empty fields leave a saved key unchanged. Use Remove to delete a key.

### Other commands

| Command | Purpose |
|---------|--------|
| `npm run build` | Production build |
| `npm run build` | Production frontend build |
| `npm run deploy` | Build + `wrangler deploy` |
| `npm run types` | Regenerate `worker-configuration.d.ts` from `wrangler.jsonc` |
| `npm run preview` | Preview production build locally |
| `npm test` | Run unit tests (Vitest) |
| `npm run test:e2e` | Run E2E tests (Playwright; run `npm run test:e2e:install` once to install browsers) |
Expand All @@ -80,7 +81,9 @@ and (where applicable) timers and summaries are shown in the UI.
|------|----------|
| `App.tsx` | Main UI and mode orchestration (free chat, scenario, TEF Ad persuasion/questioning) |
| `components/` | UI (Orb, Controls, conversation history, setup flows, timers, summaries); app shell (`NavRail`, `TopBar`) and `ScenarioRoadmap` (scenario step progress outline) |
| `services/` | Gemini (session, voice message, TTS), OpenAI (scenario planning), scenario/voice/API-key helpers, IndexedDB archives, `.parle` backup export/import |
| `services/` | Client BFF calls (`geminiService`, scenario planning, reviews); IndexedDB archives, `.parle` backup |
| `worker/` | Cloudflare Worker: session cookie seal/CSRF, typed `/api/*` AI routes |
| `shared/` | Prompts and Zod chat schemas used by Worker and client |
| `hooks/` | Audio, conversation timer, document head |
| `utils/` | Abort signal combiner, abort error helper, time helpers |
| `__tests__/` | Unit tests |
Expand Down
26 changes: 12 additions & 14 deletions __tests__/adPersuasionCredentials.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,52 +60,50 @@ function selectFile(file: File) {
// ---------------------------------------------------------------------------

describe('apiKeyService · hasApiKeyOrEnv', () => {
beforeEach(() => {
beforeEach(async () => {
localStorage.clear();
const { hydrateSessionStatus } = await import('../services/apiKeyService');
hydrateSessionStatus({ hasGemini: false, hasOpenai: false, hasApiKey: false });
});

afterEach(() => {
localStorage.clear();
vi.restoreAllMocks();
delete process.env.GEMINI_API_KEY;
});

it('is importable from the apiKeyService module', async () => {
const mod = await import('../services/apiKeyService');
expect(typeof mod.hasApiKeyOrEnv).toBe('function');
});

it('returns false for "gemini" when localStorage is empty and env var is absent', async () => {
it('returns false for "gemini" when no BFF session is cached', async () => {
const { hasApiKeyOrEnv } = await import('../services/apiKeyService');
delete process.env.GEMINI_API_KEY;
expect(hasApiKeyOrEnv('gemini')).toBe(false);
});

it('returns true for "gemini" when a key is stored in localStorage', async () => {
it('returns true for "gemini" after the session cache is hydrated', async () => {
const { hasApiKeyOrEnv, setApiKey } = await import('../services/apiKeyService');
setApiKey('gemini', 'test-gemini-key-123');
expect(hasApiKeyOrEnv('gemini')).toBe(true);
});

it('returns true for "gemini" when GEMINI_API_KEY env var is set', async () => {
const { hasApiKeyOrEnv } = await import('../services/apiKeyService');
it('does not treat a Vite/env GEMINI_API_KEY as a client-readable key', async () => {
const { hasApiKeyOrEnv, getApiKeyOrEnv } = await import('../services/apiKeyService');
process.env.GEMINI_API_KEY = 'env-gemini-key';
expect(hasApiKeyOrEnv('gemini')).toBe(true);
expect(hasApiKeyOrEnv('gemini')).toBe(false);
expect(getApiKeyOrEnv('gemini')).toBeNull();
delete process.env.GEMINI_API_KEY;
});

it('localStorage key takes precedence over env variable', async () => {
it('never returns the raw key to JavaScript', async () => {
const { hasApiKeyOrEnv, setApiKey, getApiKeyOrEnv } = await import('../services/apiKeyService');
process.env.GEMINI_API_KEY = 'env-key';
setApiKey('gemini', 'stored-key');
expect(getApiKeyOrEnv('gemini')).toBe('stored-key');
expect(getApiKeyOrEnv('gemini')).toBeNull();
expect(hasApiKeyOrEnv('gemini')).toBe(true);
delete process.env.GEMINI_API_KEY;
});

it('removing key from localStorage causes hasApiKeyOrEnv to return false (when no env var)', async () => {
it('clearing the cached Gemini flag makes hasApiKeyOrEnv return false', async () => {
const { hasApiKeyOrEnv, setApiKey } = await import('../services/apiKeyService');
delete process.env.GEMINI_API_KEY;
setApiKey('gemini', 'temp-key');
expect(hasApiKeyOrEnv('gemini')).toBe(true);
setApiKey('gemini', '');
Expand Down
Loading