Compare commits
3 Commits
aeb0f7b638
...
41f95cdee3
Author | SHA1 | Date |
---|---|---|
|
41f95cdee3 | |
|
b62eb0211f | |
|
948712bb3f |
|
@ -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 (1–2 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 (2–4 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.
|
|
@ -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
|
||||||
|
|
|
@ -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 auto‑included.)
|
||||||
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
|
||||||
|
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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")
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -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">×</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">
|
||||||
|
|
|
@ -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?" }
|
||||||
* ]
|
* ]
|
||||||
* }
|
* }
|
||||||
|
|
|
@ -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);
|
||||||
|
|
|
@ -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;
|
||||||
|
|
|
@ -0,0 +1,9 @@
|
||||||
|
// jest.config.cjs
|
||||||
|
module.exports = {
|
||||||
|
testEnvironment: 'node',
|
||||||
|
transform: {
|
||||||
|
'^.+\\.js$': 'babel-jest',
|
||||||
|
},
|
||||||
|
moduleFileExtensions: ['js', 'json'],
|
||||||
|
roots: ['<rootDir>/frontend/tests', '<rootDir>'],
|
||||||
|
};
|
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
|
||||||
|
|
|
@ -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()
|
||||||
|
|
Loading…
Reference in New Issue