Compare commits

...

3 Commits

Author SHA1 Message Date
Steve White 41f95cdee3 feat(frontend): inline notifications and loading states
- Add .notice styles and variants in frontend/css/style.css
- Add showNotice, hideNotice, confirmAction in frontend/js/app.js
- Replace all alert and confirm with inline notices
- Add loading states to Add Speaker and Generate Dialog
- Verified container IDs in index.html, grep clean, tests passing
2025-08-12 15:46:23 -05:00
Steve White b62eb0211f feat(frontend): Phase 1 – normalize speakers endpoints, fix API docs and JSON parsing, consolidate state in app.js, tweak CSS border color, align jest/babel-jest + add jest.config.cjs, add dev scripts, sanitize repo URL 2025-08-12 12:16:23 -05:00
Steve White 948712bb3f current workign version using chatterbox. 2025-08-12 11:31:00 -05:00
14 changed files with 430 additions and 97 deletions

138
.note/review-20250812.md Normal file
View File

@ -0,0 +1,138 @@
# Frontend Review and Recommendations
Date: 2025-08-12T11:32:16-05:00
Scope: `frontend/` of `chatterbox-test` monorepo
---
## Summary
- Static vanilla JS frontend served by `frontend/start_dev_server.py` interacting with FastAPI backend under `/api`.
- Solid feature set (speaker management, dialog editor, per-line generation, full dialog generation, save/load) with robust error handling.
- Key issues: inconsistent API trailing slashes, Jest/babel-jest version/config mismatch, minor state duplication, alert/confirm UX, overly dark border color, token in `package.json` repo URL.
---
## Findings
- **Framework/structure**
- `frontend/` is static vanilla JS. Main files:
- `index.html`, `js/app.js`, `js/api.js`, `js/config.js`, `css/style.css`.
- Dev server: `frontend/start_dev_server.py` (CORS, env-based port/host).
- **API client vs backend routes (trailing slashes)**
- Frontend `frontend/js/api.js` currently uses:
- `getSpeakers()`: `${API_BASE_URL}/speakers/` (trailing).
- `addSpeaker()`: `${API_BASE_URL}/speakers/` (trailing).
- `deleteSpeaker()`: `${API_BASE_URL}/speakers/${speakerId}/` (trailing).
- `generateLine()`: `${API_BASE_URL}/dialog/generate_line`.
- `generateDialog()`: `${API_BASE_URL}/dialog/generate`.
- Backend routes:
- `backend/app/routers/speakers.py`: `GET/POST /` and `DELETE /{speaker_id}` (no trailing slash on delete when prefixed under `/api/speakers`).
- `backend/app/routers/dialog.py`: `/generate_line` and `/generate` (match frontend).
- Tests in `frontend/tests/api.test.js` expect no trailing slashes for `/speakers` and `/speakers/{id}`.
- Implication: Inconsistent trailing slashes can cause test failures and possible 404s for delete.
- **Payload schema inconsistencies**
- `generateDialog()` JSDoc shows `silence` as `{ duration_ms: 500 }` but backend expects `duration` (seconds). UI also uses `duration` seconds.
- **Form fields alignment**
- Speaker add uses `name` and `audio_file` which match backend (`Form` and `File`).
- **State management duplication in `frontend/js/app.js`**
- `dialogItems` and `availableSpeakersCache` defined at module scope and again inside `initializeDialogEditor()`, creating shadowing risk. Consolidate to a single source of truth.
- **UX considerations**
- Heavy use of `alert()`/`confirm()`. Prefer inline notifications/banners and per-row error chips (you already render `item.error`).
- Add global loading/disabled states for long actions (e.g., full dialog generation, speaker add/delete).
- **CSS theme issue**
- `--border-light` is `#1b0404` (dark red); semantically a light gray fits better and improves contrast harmony.
- **Testing/Jest/Babel config**
- Root `package.json` uses `jest@^29.7.0` with `babel-jest@^30.0.0-beta.3` (major mismatch). Align versions.
- No `jest.config.cjs` to configure `transform` via `babel-jest` for ESM modules.
- **Security**
- `package.json` `repository.url` embeds a token. Remove secrets from VCS immediately.
- **Dev scripts**
- Only `"test": "jest"` present. Add scripts to run the frontend dev server and test config explicitly.
- **Response handling consistency**
- `generateLine()` parses via `response.text()` then `JSON.parse()`. Others use `response.json()`. Standardize for consistency.
---
## Recommended Actions (Phase 1: Quick wins)
- **Normalize API paths in `frontend/js/api.js`**
- Use no trailing slashes:
- `GET/POST`: `${API_BASE_URL}/speakers`
- `DELETE`: `${API_BASE_URL}/speakers/${speakerId}`
- Keep dialog endpoints unchanged.
- **Fix JSDoc for `generateDialog()`**
- Use `silence: { duration: number }` (seconds), not `duration_ms`.
- **Refactor `frontend/js/app.js` state**
- Remove duplicate `dialogItems`/`availableSpeakersCache` declarations. Choose module-scope or function-scope, and pass references.
- **Improve UX**
- Replace `alert/confirm` with inline banners near `#results-display` and per-row error chips (extend existing `.line-error-msg`).
- Add disabled/loading states for global generate and speaker actions.
- **CSS tweak**
- Set `--border-light: #e5e7eb;` (or similar) to reflect a light border.
- **Harden tests/Jest config**
- Align versions: either Jest 29 + `babel-jest` 29, or upgrade both to 30 stable together.
- Add `jest.config.cjs` with `transform` using `babel-jest` and suitable `testEnvironment`.
- Ensure tests expect normalized API paths (recommended to change code to match tests).
- **Dev scripts**
- Add to root `package.json`:
- `"frontend:dev": "python3 frontend/start_dev_server.py"`
- `"test:frontend": "jest --config ./jest.config.cjs"`
- **Sanitize repository URL**
- Remove embedded token from `package.json`.
- **Standardize response parsing**
- Switch `generateLine()` to `response.json()` unless backend returns `text/plain`.
---
## Backend Endpoint Confirmation
- `speakers` router (`backend/app/routers/speakers.py`):
- List/Create: `GET /`, `POST /` (when mounted under `/api/speakers``/api/speakers/`).
- Delete: `DELETE /{speaker_id}` (→ `/api/speakers/{speaker_id}`), no trailing slash.
- `dialog` router (`backend/app/routers/dialog.py`):
- `POST /generate_line`, `POST /generate` (mounted under `/api/dialog`).
---
## Proposed Implementation Plan
- **Phase 1 (12 hours)**
- Normalize API paths in `api.js`.
- Fix JSDoc for `generateDialog`.
- Consolidate dialog state in `app.js`.
- Adjust `--border-light` to light gray.
- Add `jest.config.cjs`, align Jest/babel-jest versions.
- Add dev/test scripts.
- Remove token from `package.json`.
- **Phase 2 (24 hours)**
- Inline notifications and comprehensive loading/disabled states.
- **Phase 3 (optional)**
- ESLint + Prettier.
- Consider Vite migration (HMR, proxy to backend, improved DX).
---
## Notes
- Current local time captured for this review: 2025-08-12T11:32:16-05:00.
- Frontend config (`frontend/js/config.js`) supports env overrides for API base and dev server port.
- Tests (`frontend/tests/api.test.js`) currently assume endpoints without trailing slashes.

View File

@ -359,7 +359,7 @@ The API uses the following directory structure (configurable in `app/config.py`)
- **Temporary Files**: `{PROJECT_ROOT}/tts_temp_outputs/` - **Temporary Files**: `{PROJECT_ROOT}/tts_temp_outputs/`
### CORS Settings ### CORS Settings
- Allowed Origins: `http://localhost:8001`, `http://127.0.0.1:8001` - Allowed Origins: `http://localhost:8001`, `http://127.0.0.1:8001` (plus any `FRONTEND_HOST:FRONTEND_PORT` when using `start_servers.py`)
- Allowed Methods: All - Allowed Methods: All
- Allowed Headers: All - Allowed Headers: All
- Credentials: Enabled - Credentials: Enabled

View File

@ -58,7 +58,7 @@ The application uses environment variables for configuration. Three `.env` files
- `VITE_DEV_SERVER_HOST`: Frontend development server host - `VITE_DEV_SERVER_HOST`: Frontend development server host
#### CORS Configuration #### CORS Configuration
- `CORS_ORIGINS`: Comma-separated list of allowed origins - `CORS_ORIGINS`: Comma-separated list of allowed origins. When using `start_servers.py` with the default `FRONTEND_HOST=0.0.0.0` and no explicit `CORS_ORIGINS`, CORS will allow all origins (wildcard) to simplify development.
#### Device Configuration #### Device Configuration
- `DEVICE`: Device for TTS model (auto, cpu, cuda, mps) - `DEVICE`: Device for TTS model (auto, cpu, cuda, mps)
@ -101,7 +101,7 @@ CORS_ORIGINS=http://localhost:3000
### Common Issues ### Common Issues
1. **Permission Errors**: Ensure the `PROJECT_ROOT` directory is writable 1. **Permission Errors**: Ensure the `PROJECT_ROOT` directory is writable
2. **CORS Errors**: Check that your frontend URL is in `CORS_ORIGINS` 2. **CORS Errors**: Check that your frontend URL is in `CORS_ORIGINS`. (When using `start_servers.py`, your specified `FRONTEND_HOST:FRONTEND_PORT` will be autoincluded.)
3. **Model Loading Errors**: Verify `DEVICE` setting matches your hardware 3. **Model Loading Errors**: Verify `DEVICE` setting matches your hardware
4. **Path Errors**: Ensure all path variables point to existing, accessible directories 4. **Path Errors**: Ensure all path variables point to existing, accessible directories

View File

@ -149,5 +149,5 @@ The application automatically:
- **"Skipping unknown speaker"**: Configure speaker in `speaker_data/speakers.yaml` - **"Skipping unknown speaker"**: Configure speaker in `speaker_data/speakers.yaml`
- **"Sample file not found"**: Verify audio files exist in `speaker_data/speaker_samples/` - **"Sample file not found"**: Verify audio files exist in `speaker_data/speaker_samples/`
- **Memory issues**: Use model reinitialization options for long content - **Memory issues**: Use model reinitialization options for long content
- **CORS errors**: Check frontend/backend port configuration - **CORS errors**: Check frontend/backend port configuration (frontend origin is auto-included when using `start_servers.py`)
- **Import errors**: Run `python import_helper.py` to check dependencies - **Import errors**: Run `python import_helper.py` to check dependencies

View File

@ -6,20 +6,34 @@ from dotenv import load_dotenv
load_dotenv() load_dotenv()
# Project root - can be overridden by environment variable # Project root - can be overridden by environment variable
PROJECT_ROOT = Path(os.getenv("PROJECT_ROOT", Path(__file__).parent.parent.parent)).resolve() PROJECT_ROOT = Path(
os.getenv("PROJECT_ROOT", Path(__file__).parent.parent.parent)
).resolve()
# Directory paths # Directory paths
SPEAKER_DATA_BASE_DIR = Path(os.getenv("SPEAKER_DATA_BASE_DIR", str(PROJECT_ROOT / "speaker_data"))) SPEAKER_DATA_BASE_DIR = Path(
SPEAKER_SAMPLES_DIR = Path(os.getenv("SPEAKER_SAMPLES_DIR", str(SPEAKER_DATA_BASE_DIR / "speaker_samples"))) os.getenv("SPEAKER_DATA_BASE_DIR", str(PROJECT_ROOT / "speaker_data"))
SPEAKERS_YAML_FILE = Path(os.getenv("SPEAKERS_YAML_FILE", str(SPEAKER_DATA_BASE_DIR / "speakers.yaml"))) )
SPEAKER_SAMPLES_DIR = Path(
os.getenv("SPEAKER_SAMPLES_DIR", str(SPEAKER_DATA_BASE_DIR / "speaker_samples"))
)
SPEAKERS_YAML_FILE = Path(
os.getenv("SPEAKERS_YAML_FILE", str(SPEAKER_DATA_BASE_DIR / "speakers.yaml"))
)
# TTS temporary output path (used by DialogProcessorService) # TTS temporary output path (used by DialogProcessorService)
TTS_TEMP_OUTPUT_DIR = Path(os.getenv("TTS_TEMP_OUTPUT_DIR", str(PROJECT_ROOT / "tts_temp_outputs"))) TTS_TEMP_OUTPUT_DIR = Path(
os.getenv("TTS_TEMP_OUTPUT_DIR", str(PROJECT_ROOT / "tts_temp_outputs"))
)
# Final dialog output path (used by Dialog router and served by main app) # Final dialog output path (used by Dialog router and served by main app)
# These are stored within the 'backend' directory to be easily servable. # These are stored within the 'backend' directory to be easily servable.
DIALOG_OUTPUT_PARENT_DIR = PROJECT_ROOT / "backend" DIALOG_OUTPUT_PARENT_DIR = PROJECT_ROOT / "backend"
DIALOG_GENERATED_DIR = Path(os.getenv("DIALOG_GENERATED_DIR", str(DIALOG_OUTPUT_PARENT_DIR / "tts_generated_dialogs"))) DIALOG_GENERATED_DIR = Path(
os.getenv(
"DIALOG_GENERATED_DIR", str(DIALOG_OUTPUT_PARENT_DIR / "tts_generated_dialogs")
)
)
# Alias for clarity and backward compatibility # Alias for clarity and backward compatibility
DIALOG_OUTPUT_DIR = DIALOG_GENERATED_DIR DIALOG_OUTPUT_DIR = DIALOG_GENERATED_DIR
@ -29,8 +43,26 @@ HOST = os.getenv("HOST", "0.0.0.0")
PORT = int(os.getenv("PORT", "8000")) PORT = int(os.getenv("PORT", "8000"))
RELOAD = os.getenv("RELOAD", "true").lower() == "true" RELOAD = os.getenv("RELOAD", "true").lower() == "true"
# CORS configuration # CORS configuration: determine allowed origins based on env & frontend binding
CORS_ORIGINS = [origin.strip() for origin in os.getenv("CORS_ORIGINS", "http://localhost:8001,http://127.0.0.1:8001").split(",")] _cors_env = os.getenv("CORS_ORIGINS", "")
_frontend_host = os.getenv("FRONTEND_HOST")
_frontend_port = os.getenv("FRONTEND_PORT")
# If the dev server is bound to 0.0.0.0 (all interfaces), allow all origins
if _frontend_host == "0.0.0.0": # dev convenience when binding wildcard
CORS_ORIGINS = ["*"]
elif _cors_env:
# parse comma-separated origins, strip whitespace
CORS_ORIGINS = [origin.strip() for origin in _cors_env.split(",") if origin.strip()]
else:
# default to allow all origins in development
CORS_ORIGINS = ["*"]
# Auto-include specific frontend origin when not using wildcard CORS
if CORS_ORIGINS != ["*"] and _frontend_host and _frontend_port:
_frontend_origin = f"http://{_frontend_host.strip()}:{_frontend_port.strip()}"
if _frontend_origin not in CORS_ORIGINS:
CORS_ORIGINS.append(_frontend_origin)
# Device configuration # Device configuration
DEVICE = os.getenv("DEVICE", "auto") DEVICE = os.getenv("DEVICE", "auto")

View File

@ -24,7 +24,7 @@
--text-blue-darker: #205081; --text-blue-darker: #205081;
/* Border Colors */ /* Border Colors */
--border-light: #1b0404; --border-light: #e5e7eb;
--border-medium: #cfd8dc; --border-medium: #cfd8dc;
--border-blue: #b5c6df; --border-blue: #b5c6df;
--border-gray: #e3e3e3; --border-gray: #e3e3e3;
@ -449,6 +449,72 @@ footer {
border-top: 3px solid var(--primary-blue); border-top: 3px solid var(--primary-blue);
} }
/* Inline Notification */
.notice {
max-width: 1100px;
margin: 16px auto 0;
padding: 12px 16px;
border-radius: 6px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
color: var(--text-primary);
display: flex;
align-items: center;
gap: 12px;
box-shadow: 0 1px 2px var(--shadow-light);
}
.notice--info {
border-color: var(--border-blue);
background: var(--bg-blue-light);
}
.notice--success {
border-color: #A7F3D0;
background: #ECFDF5;
}
.notice--warning {
border-color: var(--warning-border);
background: var(--warning-bg);
}
.notice--error {
border-color: var(--error-bg-dark);
background: #FEE2E2;
}
.notice__content {
flex: 1;
}
.notice__actions {
display: flex;
gap: 8px;
}
.notice__actions button {
padding: 6px 12px;
border-radius: 4px;
border: 1px solid var(--border-medium);
background: var(--bg-white);
cursor: pointer;
}
.notice__actions .btn-primary {
background: var(--primary-blue);
color: var(--text-white);
border: none;
}
.notice__close {
background: none;
border: none;
font-size: 18px;
cursor: pointer;
color: var(--text-secondary);
}
@media (max-width: 900px) { @media (max-width: 900px) {
.panel-grid { .panel-grid {
flex-direction: column; flex-direction: column;

View File

@ -13,6 +13,13 @@
</div> </div>
</header> </header>
<!-- Global inline notification area -->
<div id="global-notice" class="notice" role="status" aria-live="polite" style="display:none;">
<div class="notice__content" id="global-notice-content"></div>
<div class="notice__actions" id="global-notice-actions"></div>
<button class="notice__close" id="global-notice-close" aria-label="Close notification">&times;</button>
</div>
<main class="container" role="main"> <main class="container" role="main">
<div class="panel-grid"> <div class="panel-grid">
<section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title"> <section id="dialog-editor" class="panel full-width-panel" aria-labelledby="dialog-editor-title">

View File

@ -10,7 +10,7 @@ const API_BASE_URL = API_BASE_URL_WITH_PREFIX;
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function getSpeakers() { export async function getSpeakers() {
const response = await fetch(`${API_BASE_URL}/speakers/`); const response = await fetch(`${API_BASE_URL}/speakers`);
if (!response.ok) { if (!response.ok) {
const errorData = await response.json().catch(() => ({ message: response.statusText })); const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`); throw new Error(`Failed to fetch speakers: ${errorData.detail || errorData.message || response.statusText}`);
@ -26,12 +26,12 @@ export async function getSpeakers() {
* Adds a new speaker. * Adds a new speaker.
* @param {FormData} formData - The form data containing speaker name and audio file. * @param {FormData} formData - The form data containing speaker name and audio file.
* Example: formData.append('name', 'New Speaker'); * Example: formData.append('name', 'New Speaker');
* formData.append('audio_sample_file', fileInput.files[0]); * formData.append('audio_file', fileInput.files[0]);
* @returns {Promise<Object>} A promise that resolves to the new speaker object. * @returns {Promise<Object>} A promise that resolves to the new speaker object.
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function addSpeaker(formData) { export async function addSpeaker(formData) {
const response = await fetch(`${API_BASE_URL}/speakers/`, { const response = await fetch(`${API_BASE_URL}/speakers`, {
method: 'POST', method: 'POST',
body: formData, // FormData sets Content-Type to multipart/form-data automatically body: formData, // FormData sets Content-Type to multipart/form-data automatically
}); });
@ -86,7 +86,7 @@ export async function addSpeaker(formData) {
* @throws {Error} If the network response is not ok. * @throws {Error} If the network response is not ok.
*/ */
export async function deleteSpeaker(speakerId) { export async function deleteSpeaker(speakerId) {
const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}/`, { const response = await fetch(`${API_BASE_URL}/speakers/${speakerId}`, {
method: 'DELETE', method: 'DELETE',
}); });
if (!response.ok) { if (!response.ok) {
@ -124,18 +124,8 @@ export async function generateLine(line) {
const errorData = await response.json().catch(() => ({ message: response.statusText })); const errorData = await response.json().catch(() => ({ message: response.statusText }));
throw new Error(`Failed to generate line audio: ${errorData.detail || errorData.message || response.statusText}`); throw new Error(`Failed to generate line audio: ${errorData.detail || errorData.message || response.statusText}`);
} }
const data = await response.json();
const responseText = await response.text(); return data;
console.log('Raw response text:', responseText);
try {
const jsonData = JSON.parse(responseText);
console.log('Parsed JSON:', jsonData);
return jsonData;
} catch (parseError) {
console.error('JSON parse error:', parseError);
throw new Error(`Invalid JSON response: ${responseText}`);
}
} }
/** /**
@ -146,7 +136,7 @@ export async function generateLine(line) {
* output_base_name: "my_dialog", * output_base_name: "my_dialog",
* dialog_items: [ * dialog_items: [
* { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 }, * { type: "speech", speaker_id: "speaker1", text: "Hello world.", exaggeration: 1.0, cfg_weight: 2.0, temperature: 0.7 },
* { type: "silence", duration_ms: 500 }, * { type: "silence", duration: 0.5 },
* { type: "speech", speaker_id: "speaker2", text: "How are you?" } * { type: "speech", speaker_id: "speaker2", text: "How are you?" }
* ] * ]
* } * }

View File

@ -1,6 +1,64 @@
import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js'; import { getSpeakers, addSpeaker, deleteSpeaker, generateDialog } from './api.js';
import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js'; import { API_BASE_URL, API_BASE_URL_FOR_FILES } from './config.js';
// --- Global Inline Notification Helpers --- //
const noticeEl = document.getElementById('global-notice');
const noticeContentEl = document.getElementById('global-notice-content');
const noticeActionsEl = document.getElementById('global-notice-actions');
const noticeCloseBtn = document.getElementById('global-notice-close');
function hideNotice() {
if (!noticeEl) return;
noticeEl.style.display = 'none';
noticeEl.className = 'notice';
if (noticeContentEl) noticeContentEl.textContent = '';
if (noticeActionsEl) noticeActionsEl.innerHTML = '';
}
function showNotice(message, type = 'info', options = {}) {
if (!noticeEl || !noticeContentEl || !noticeActionsEl) {
console[type === 'error' ? 'error' : 'log']('[NOTICE]', message);
return () => {};
}
const { timeout = null, actions = [] } = options;
noticeEl.className = `notice notice--${type}`;
noticeContentEl.textContent = message;
noticeActionsEl.innerHTML = '';
actions.forEach(({ text, primary = false, onClick }) => {
const btn = document.createElement('button');
btn.textContent = text;
if (primary) btn.classList.add('btn-primary');
btn.onclick = () => {
try { onClick && onClick(); } finally { hideNotice(); }
};
noticeActionsEl.appendChild(btn);
});
if (noticeCloseBtn) noticeCloseBtn.onclick = hideNotice;
noticeEl.style.display = 'flex';
let timerId = null;
if (timeout && Number.isFinite(timeout)) {
timerId = window.setTimeout(hideNotice, timeout);
}
return () => {
if (timerId) window.clearTimeout(timerId);
hideNotice();
};
}
function confirmAction(message) {
return new Promise((resolve) => {
showNotice(message, 'warning', {
actions: [
{ text: 'Cancel', primary: false, onClick: () => resolve(false) },
{ text: 'Confirm', primary: true, onClick: () => resolve(true) },
],
});
});
}
document.addEventListener('DOMContentLoaded', async () => { document.addEventListener('DOMContentLoaded', async () => {
console.log('DOM fully loaded and parsed'); console.log('DOM fully loaded and parsed');
initializeSpeakerManagement(); initializeSpeakerManagement();
@ -23,18 +81,24 @@ function initializeSpeakerManagement() {
const audioFile = formData.get('audio_file'); const audioFile = formData.get('audio_file');
if (!speakerName || !audioFile || audioFile.size === 0) { if (!speakerName || !audioFile || audioFile.size === 0) {
alert('Please provide a speaker name and an audio file.'); showNotice('Please provide a speaker name and an audio file.', 'warning', { timeout: 4000 });
return; return;
} }
try { try {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
const prevText = submitBtn ? submitBtn.textContent : null;
if (submitBtn) { submitBtn.disabled = true; submitBtn.textContent = 'Adding…'; }
const newSpeaker = await addSpeaker(formData); const newSpeaker = await addSpeaker(formData);
alert(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`); showNotice(`Speaker added: ${newSpeaker.name} (ID: ${newSpeaker.id})`, 'success', { timeout: 3000 });
addSpeakerForm.reset(); addSpeakerForm.reset();
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error('Failed to add speaker:', error); console.error('Failed to add speaker:', error);
alert('Error adding speaker: ' + error.message); showNotice('Error adding speaker: ' + error.message, 'error');
} finally {
const submitBtn = addSpeakerForm.querySelector('button[type="submit"]');
if (submitBtn) { submitBtn.disabled = false; submitBtn.textContent = 'Add Speaker'; }
} }
}); });
} }
@ -79,23 +143,24 @@ async function loadSpeakers() {
} catch (error) { } catch (error) {
console.error('Failed to load speakers:', error); console.error('Failed to load speakers:', error);
speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>'; speakerListUL.innerHTML = '<li>Error loading speakers. See console for details.</li>';
alert('Error loading speakers: ' + error.message); showNotice('Error loading speakers: ' + error.message, 'error');
} }
} }
async function handleDeleteSpeaker(speakerId) { async function handleDeleteSpeaker(speakerId) {
if (!speakerId) { if (!speakerId) {
alert('Cannot delete speaker: Speaker ID is missing.'); showNotice('Cannot delete speaker: Speaker ID is missing.', 'warning', { timeout: 4000 });
return; return;
} }
if (!confirm(`Are you sure you want to delete speaker ${speakerId}?`)) return; const ok = await confirmAction(`Are you sure you want to delete speaker ${speakerId}?`);
if (!ok) return;
try { try {
await deleteSpeaker(speakerId); await deleteSpeaker(speakerId);
alert(`Speaker ${speakerId} deleted successfully.`); showNotice(`Speaker ${speakerId} deleted successfully.`, 'success', { timeout: 3000 });
loadSpeakers(); // Refresh speaker list loadSpeakers(); // Refresh speaker list
} catch (error) { } catch (error) {
console.error(`Failed to delete speaker ${speakerId}:`, error); console.error(`Failed to delete speaker ${speakerId}:`, error);
alert(`Error deleting speaker: ${error.message}`); showNotice(`Error deleting speaker: ${error.message}`, 'error');
} }
} }
@ -140,9 +205,6 @@ async function initializeDialogEditor() {
const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder'); const zipArchivePlaceholder = document.getElementById('zip-archive-placeholder');
const resultsDisplaySection = document.getElementById('results-display'); const resultsDisplaySection = document.getElementById('results-display');
let dialogItems = [];
let availableSpeakersCache = []; // Cache for speaker names and IDs
// Load speakers at startup // Load speakers at startup
try { try {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
@ -321,7 +383,7 @@ async function initializeDialogEditor() {
} catch (err) { } catch (err) {
console.error('Error in generateLine:', err); console.error('Error in generateLine:', err);
dialogItems[index].error = err.message || 'Failed to generate audio.'; dialogItems[index].error = err.message || 'Failed to generate audio.';
alert(dialogItems[index].error); showNotice(dialogItems[index].error, 'error');
} finally { } finally {
dialogItems[index].isGenerating = false; dialogItems[index].isGenerating = false;
renderDialogItems(); renderDialogItems();
@ -383,13 +445,13 @@ async function initializeDialogEditor() {
try { try {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
alert('Could not load speakers. Please try again.'); showNotice('Could not load speakers. Please try again.', 'error');
console.error('Error fetching speakers for dialog:', error); console.error('Error fetching speakers for dialog:', error);
return; return;
} }
} }
if (availableSpeakersCache.length === 0) { if (availableSpeakersCache.length === 0) {
alert('No speakers available. Please add a speaker first.'); showNotice('No speakers available. Please add a speaker first.', 'warning', { timeout: 4000 });
return; return;
} }
@ -419,7 +481,7 @@ async function initializeDialogEditor() {
const speakerId = speakerSelect.value; const speakerId = speakerSelect.value;
const text = textInput.value.trim(); const text = textInput.value.trim();
if (!speakerId || !text) { if (!speakerId || !text) {
alert('Please select a speaker and enter text.'); showNotice('Please select a speaker and enter text.', 'warning', { timeout: 4000 });
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text })); dialogItems.push(normalizeDialogItem({ type: 'speech', speaker_id: speakerId, text: text }));
@ -461,7 +523,7 @@ async function initializeDialogEditor() {
addButton.onclick = () => { addButton.onclick = () => {
const duration = parseFloat(durationInput.value); const duration = parseFloat(durationInput.value);
if (isNaN(duration) || duration <= 0) { if (isNaN(duration) || duration <= 0) {
alert('Invalid duration. Please enter a positive number.'); showNotice('Invalid duration. Please enter a positive number.', 'warning', { timeout: 4000 });
return; return;
} }
dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration })); dialogItems.push(normalizeDialogItem({ type: 'silence', duration: duration }));
@ -486,15 +548,18 @@ async function initializeDialogEditor() {
generateDialogBtn.addEventListener('click', async () => { generateDialogBtn.addEventListener('click', async () => {
const outputBaseName = outputBaseNameInput.value.trim(); const outputBaseName = outputBaseNameInput.value.trim();
if (!outputBaseName) { if (!outputBaseName) {
alert('Please enter an output base name.'); showNotice('Please enter an output base name.', 'warning', { timeout: 4000 });
outputBaseNameInput.focus(); outputBaseNameInput.focus();
return; return;
} }
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
alert('Please add at least one speech or silence line to the dialog.'); showNotice('Please add at least one speech or silence line to the dialog.', 'warning', { timeout: 4000 });
return; // Prevent further execution if no dialog items return; // Prevent further execution if no dialog items
} }
const prevText = generateDialogBtn.textContent;
generateDialogBtn.disabled = true;
generateDialogBtn.textContent = 'Generating…';
// Smart dialog-wide generation: use pre-generated audio where present // Smart dialog-wide generation: use pre-generated audio where present
const dialogItemsToGenerate = dialogItems.map(item => { const dialogItemsToGenerate = dialogItems.map(item => {
// Only send minimal fields for items that need generation // Only send minimal fields for items that need generation
@ -546,7 +611,11 @@ async function initializeDialogEditor() {
} catch (error) { } catch (error) {
console.error('Dialog generation failed:', error); console.error('Dialog generation failed:', error);
if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`; if (generationLogPre) generationLogPre.textContent = `Error generating dialog: ${error.message}`;
alert(`Error generating dialog: ${error.message}`); showNotice(`Error generating dialog: ${error.message}`, 'error');
}
finally {
generateDialogBtn.disabled = false;
generateDialogBtn.textContent = prevText;
} }
}); });
} }
@ -554,7 +623,7 @@ async function initializeDialogEditor() {
// --- Save/Load Script Functionality --- // --- Save/Load Script Functionality ---
function saveDialogScript() { function saveDialogScript() {
if (dialogItems.length === 0) { if (dialogItems.length === 0) {
alert('No dialog items to save. Please add some speech or silence lines first.'); showNotice('No dialog items to save. Please add some speech or silence lines first.', 'warning', { timeout: 4000 });
return; return;
} }
@ -599,11 +668,12 @@ async function initializeDialogEditor() {
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
console.log(`Dialog script saved as ${filename}`); console.log(`Dialog script saved as ${filename}`);
showNotice(`Dialog script saved as ${filename}`, 'success', { timeout: 3000 });
} }
function loadDialogScript(file) { function loadDialogScript(file) {
if (!file) { if (!file) {
alert('Please select a file to load.'); showNotice('Please select a file to load.', 'warning', { timeout: 4000 });
return; return;
} }
@ -626,19 +696,19 @@ async function initializeDialogEditor() {
} }
} catch (parseError) { } catch (parseError) {
console.error(`Error parsing line ${i + 1}:`, parseError); console.error(`Error parsing line ${i + 1}:`, parseError);
alert(`Error parsing line ${i + 1}: ${parseError.message}`); showNotice(`Error parsing line ${i + 1}: ${parseError.message}`, 'error');
return; return;
} }
} }
if (loadedItems.length === 0) { if (loadedItems.length === 0) {
alert('No valid dialog items found in the file.'); showNotice('No valid dialog items found in the file.', 'warning', { timeout: 4000 });
return; return;
} }
// Confirm replacement if existing items // Confirm replacement if existing items
if (dialogItems.length > 0) { if (dialogItems.length > 0) {
const confirmed = confirm( const confirmed = await confirmAction(
`This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?` `This will replace your current dialog (${dialogItems.length} items) with the loaded script (${loadedItems.length} items). Continue?`
); );
if (!confirmed) return; if (!confirmed) return;
@ -650,7 +720,7 @@ async function initializeDialogEditor() {
availableSpeakersCache = await getSpeakers(); availableSpeakersCache = await getSpeakers();
} catch (error) { } catch (error) {
console.error('Error fetching speakers:', error); console.error('Error fetching speakers:', error);
alert('Could not load speakers. Dialog loaded but speaker names may not display correctly.'); showNotice('Could not load speakers. Dialog loaded but speaker names may not display correctly.', 'warning', { timeout: 5000 });
} }
} }
@ -659,16 +729,16 @@ async function initializeDialogEditor() {
renderDialogItems(); renderDialogItems();
console.log(`Loaded ${loadedItems.length} dialog items from script`); console.log(`Loaded ${loadedItems.length} dialog items from script`);
alert(`Successfully loaded ${loadedItems.length} dialog items.`); showNotice(`Successfully loaded ${loadedItems.length} dialog items.`, 'success', { timeout: 3000 });
} catch (error) { } catch (error) {
console.error('Error loading dialog script:', error); console.error('Error loading dialog script:', error);
alert(`Error loading dialog script: ${error.message}`); showNotice(`Error loading dialog script: ${error.message}`, 'error');
} }
}; };
reader.onerror = function() { reader.onerror = function() {
alert('Error reading file. Please try again.'); showNotice('Error reading file. Please try again.', 'error');
}; };
reader.readAsText(file); reader.readAsText(file);

View File

@ -13,8 +13,15 @@ const getEnvVar = (name, defaultValue) => {
}; };
// API Configuration // API Configuration
export const API_BASE_URL = getEnvVar('VITE_API_BASE_URL', 'http://localhost:8000'); // Default to the same hostname as the frontend, on port 8000 (override via VITE_API_BASE_URL*)
export const API_BASE_URL_WITH_PREFIX = getEnvVar('VITE_API_BASE_URL_WITH_PREFIX', 'http://localhost:8000/api'); const _defaultHost = (typeof window !== 'undefined' && window.location?.hostname) || 'localhost';
const _defaultPort = getEnvVar('VITE_API_BASE_URL_PORT', '8000');
const _defaultBase = `http://${_defaultHost}:${_defaultPort}`;
export const API_BASE_URL = getEnvVar('VITE_API_BASE_URL', _defaultBase);
export const API_BASE_URL_WITH_PREFIX = getEnvVar(
'VITE_API_BASE_URL_WITH_PREFIX',
`${_defaultBase}/api`
);
// For file serving (same as API_BASE_URL since files are served from the same server) // For file serving (same as API_BASE_URL since files are served from the same server)
export const API_BASE_URL_FOR_FILES = API_BASE_URL; export const API_BASE_URL_FOR_FILES = API_BASE_URL;

9
jest.config.cjs Normal file
View File

@ -0,0 +1,9 @@
// jest.config.cjs
module.exports = {
testEnvironment: 'node',
transform: {
'^.+\\.js$': 'babel-jest',
},
moduleFileExtensions: ['js', 'json'],
roots: ['<rootDir>/frontend/tests', '<rootDir>'],
};

View File

@ -5,11 +5,13 @@
"main": "index.js", "main": "index.js",
"type": "module", "type": "module",
"scripts": { "scripts": {
"test": "jest" "test": "jest",
"test:frontend": "jest --config ./jest.config.cjs",
"frontend:dev": "python3 frontend/start_dev_server.py"
}, },
"repository": { "repository": {
"type": "git", "type": "git",
"url": "https://oauth2:78f77aaebb8fa1cd3efbd5b738177c127f7d7d0b@gitea.r8z.us/stwhite/chatterbox-ui.git" "url": "https://gitea.r8z.us/stwhite/chatterbox-ui.git"
}, },
"keywords": [], "keywords": [],
"author": "", "author": "",
@ -17,7 +19,7 @@
"devDependencies": { "devDependencies": {
"@babel/core": "^7.27.4", "@babel/core": "^7.27.4",
"@babel/preset-env": "^7.27.2", "@babel/preset-env": "^7.27.2",
"babel-jest": "^30.0.0-beta.3", "babel-jest": "^29.7.0",
"jest": "^29.7.0" "jest": "^29.7.0"
} }
} }

View File

@ -28,3 +28,6 @@ dd3552d9-f4e8-49ed-9892-f9e67afcf23c:
2cdd6d3d-c533-44bf-a5f6-cc83bd089d32: 2cdd6d3d-c533-44bf-a5f6-cc83bd089d32:
name: Grace name: Grace
sample_path: speaker_samples/2cdd6d3d-c533-44bf-a5f6-cc83bd089d32.wav sample_path: speaker_samples/2cdd6d3d-c533-44bf-a5f6-cc83bd089d32.wav
3d3e85db-3d67-4488-94b2-ffc189fbb287:
name: RCB
sample_path: speaker_samples/3d3e85db-3d67-4488-94b2-ffc189fbb287.wav

View File

@ -14,101 +14,109 @@ from pathlib import Path
# Try to load environment variables, but don't fail if dotenv is not available # Try to load environment variables, but don't fail if dotenv is not available
try: try:
from dotenv import load_dotenv from dotenv import load_dotenv
load_dotenv() load_dotenv()
except ImportError: except ImportError:
print("python-dotenv not installed, using system environment variables only") print("python-dotenv not installed, using system environment variables only")
# Configuration # Configuration
BACKEND_PORT = int(os.getenv('BACKEND_PORT', '8000')) BACKEND_PORT = int(os.getenv("BACKEND_PORT", "8000"))
BACKEND_HOST = os.getenv('BACKEND_HOST', '0.0.0.0') BACKEND_HOST = os.getenv("BACKEND_HOST", "0.0.0.0")
FRONTEND_PORT = int(os.getenv('FRONTEND_PORT', '8001')) # Frontend host/port (for dev server binding)
FRONTEND_HOST = os.getenv('FRONTEND_HOST', '127.0.0.1') FRONTEND_PORT = int(os.getenv("FRONTEND_PORT", "8001"))
FRONTEND_HOST = os.getenv("FRONTEND_HOST", "0.0.0.0")
# Export frontend host/port so backend CORS config can pick them up automatically
os.environ["FRONTEND_HOST"] = FRONTEND_HOST
os.environ["FRONTEND_PORT"] = str(FRONTEND_PORT)
# Get project root directory # Get project root directory
PROJECT_ROOT = Path(__file__).parent.absolute() PROJECT_ROOT = Path(__file__).parent.absolute()
def run_backend(): def run_backend():
"""Run the backend FastAPI server""" """Run the backend FastAPI server"""
os.chdir(PROJECT_ROOT / "backend") os.chdir(PROJECT_ROOT / "backend")
cmd = [ cmd = [
sys.executable, "-m", "uvicorn", sys.executable,
"app.main:app", "-m",
"--reload", "uvicorn",
f"--host={BACKEND_HOST}", "app.main:app",
f"--port={BACKEND_PORT}" "--reload",
f"--host={BACKEND_HOST}",
f"--port={BACKEND_PORT}",
] ]
print(f"\n{'='*50}") print(f"\n{'='*50}")
print(f"Starting Backend Server at http://{BACKEND_HOST}:{BACKEND_PORT}") print(f"Starting Backend Server at http://{BACKEND_HOST}:{BACKEND_PORT}")
print(f"API docs available at http://{BACKEND_HOST}:{BACKEND_PORT}/docs") print(f"API docs available at http://{BACKEND_HOST}:{BACKEND_PORT}/docs")
print(f"{'='*50}\n") print(f"{'='*50}\n")
return subprocess.Popen( return subprocess.Popen(
cmd, cmd,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
universal_newlines=True, universal_newlines=True,
bufsize=1 bufsize=1,
) )
def run_frontend(): def run_frontend():
"""Run the frontend development server""" """Run the frontend development server"""
frontend_dir = PROJECT_ROOT / "frontend" frontend_dir = PROJECT_ROOT / "frontend"
os.chdir(frontend_dir) os.chdir(frontend_dir)
cmd = [sys.executable, "start_dev_server.py"] cmd = [sys.executable, "start_dev_server.py"]
env = os.environ.copy() env = os.environ.copy()
env["VITE_DEV_SERVER_HOST"] = FRONTEND_HOST env["VITE_DEV_SERVER_HOST"] = FRONTEND_HOST
env["VITE_DEV_SERVER_PORT"] = str(FRONTEND_PORT) env["VITE_DEV_SERVER_PORT"] = str(FRONTEND_PORT)
print(f"\n{'='*50}") print(f"\n{'='*50}")
print(f"Starting Frontend Server at http://{FRONTEND_HOST}:{FRONTEND_PORT}") print(f"Starting Frontend Server at http://{FRONTEND_HOST}:{FRONTEND_PORT}")
print(f"{'='*50}\n") print(f"{'='*50}\n")
return subprocess.Popen( return subprocess.Popen(
cmd, cmd,
env=env, env=env,
stdout=subprocess.PIPE, stdout=subprocess.PIPE,
stderr=subprocess.STDOUT, stderr=subprocess.STDOUT,
universal_newlines=True, universal_newlines=True,
bufsize=1 bufsize=1,
) )
def print_process_output(process, prefix): def print_process_output(process, prefix):
"""Print process output with a prefix""" """Print process output with a prefix"""
for line in iter(process.stdout.readline, ''): for line in iter(process.stdout.readline, ""):
if not line: if not line:
break break
print(f"{prefix} | {line}", end='') print(f"{prefix} | {line}", end="")
def main(): def main():
"""Main function to start both servers""" """Main function to start both servers"""
print("\n🚀 Starting Chatterbox UI Development Environment") print("\n🚀 Starting Chatterbox UI Development Environment")
# Start the backend server # Start the backend server
backend_process = run_backend() backend_process = run_backend()
# Give the backend a moment to start # Give the backend a moment to start
time.sleep(2) time.sleep(2)
# Start the frontend server # Start the frontend server
frontend_process = run_frontend() frontend_process = run_frontend()
# Create threads to monitor and print output # Create threads to monitor and print output
backend_monitor = threading.Thread( backend_monitor = threading.Thread(
target=print_process_output, target=print_process_output, args=(backend_process, "BACKEND"), daemon=True
args=(backend_process, "BACKEND"),
daemon=True
) )
frontend_monitor = threading.Thread( frontend_monitor = threading.Thread(
target=print_process_output, target=print_process_output, args=(frontend_process, "FRONTEND"), daemon=True
args=(frontend_process, "FRONTEND"),
daemon=True
) )
backend_monitor.start() backend_monitor.start()
frontend_monitor.start() frontend_monitor.start()
# Setup signal handling for graceful shutdown # Setup signal handling for graceful shutdown
def signal_handler(sig, frame): def signal_handler(sig, frame):
print("\n\n🛑 Shutting down servers...") print("\n\n🛑 Shutting down servers...")
@ -117,16 +125,16 @@ def main():
# Threads are daemon, so they'll exit when the main thread exits # Threads are daemon, so they'll exit when the main thread exits
print("✅ Servers stopped successfully") print("✅ Servers stopped successfully")
sys.exit(0) sys.exit(0)
signal.signal(signal.SIGINT, signal_handler) signal.signal(signal.SIGINT, signal_handler)
# Print access information # Print access information
print("\n📋 Access Information:") print("\n📋 Access Information:")
print(f" • Frontend: http://{FRONTEND_HOST}:{FRONTEND_PORT}") print(f" • Frontend: http://{FRONTEND_HOST}:{FRONTEND_PORT}")
print(f" • Backend API: http://{BACKEND_HOST}:{BACKEND_PORT}/api") print(f" • Backend API: http://{BACKEND_HOST}:{BACKEND_PORT}/api")
print(f" • API Documentation: http://{BACKEND_HOST}:{BACKEND_PORT}/docs") print(f" • API Documentation: http://{BACKEND_HOST}:{BACKEND_PORT}/docs")
print("\n⚠️ Press Ctrl+C to stop both servers\n") print("\n⚠️ Press Ctrl+C to stop both servers\n")
# Keep the main process running # Keep the main process running
try: try:
while True: while True:
@ -134,5 +142,6 @@ def main():
except KeyboardInterrupt: except KeyboardInterrupt:
signal_handler(None, None) signal_handler(None, None)
if __name__ == "__main__": if __name__ == "__main__":
main() main()