Add cancel, history, revert, and quality-of-life features

UX

- Add elapsed-time counter and Cancel button to in-flight generations,
  wired through AbortController so partial responses are discarded
  cleanly. The cancel action is available from both the input panel
  and the results header.
- Add a Recent generations panel below the input. The last 6 successful
  generations are saved to localStorage; one click reloads both the
  input fields and the generated assets.
- Add a Revert button to every editable card. It appears the moment
  the current value diverges from the last generated value and restores
  the field with a single click.
- Add an Empty state to the right panel with a friendly hint pointing
  at the Generate button and the Settings page.
- Add a 'Try an example' button that fills the input with a random
  starter idea (idea + mood + vocals).
- Add Cmd/Ctrl+Enter as a keyboard shortcut to generate.
- Add a Footer with project info and a privacy reminder.
- Add a 'Clear saved key' button to the Settings page so the user can
  remove the API key without overwriting it.

Bug fix

- Settings > Test Connection used to save the in-progress form values
  to localStorage before testing. It now uses the in-memory candidate
  config, so failed tests don't pollute the saved config.

Code quality

- Extract the duplicated useAutoHeight hook to src/lib/useAutoHeight.ts.
- Extract a useElapsed hook for the loading timer.
- Move InputValues into src/lib/types.ts (was duplicated in
  InputPanel.tsx) and add SECTION_LABELS, replacing the humanizeSection
  switch in HomePage.
- Centralize the filename sanitization: HomePage now calls
  sanitizeFilename from zip.ts instead of duplicating the regex.
- Add previewConfig / testConnectionWithConfig helpers to llm.ts to
  support in-memory connection tests.
This commit is contained in:
2026-06-03 01:47:11 +02:00
parent d42410560f
commit b945417773
16 changed files with 991 additions and 282 deletions
+65 -3
View File
@@ -13,12 +13,13 @@ import { buildSystemPrompt, buildUserMessage } from "./prompts";
import type {
ConfigDisplay,
GenerateRequest,
InputValues,
SongAssets,
TestConnectionResult,
} from "./types";
const STORAGE_KEY = "melodymuse-config";
const DEFAULT_MODEL = "MiniMax-M3";
export const DEFAULT_MODEL = "MiniMax-M3";
export interface StoredConfig {
api_endpoint: string;
@@ -97,6 +98,32 @@ export function setConfig(patch: Partial<StoredConfig>): ConfigDisplay {
return getConfigDisplay();
}
/**
* Persist *only* the fields the caller passed that are non-empty. Unlike
* `setConfig`, this overwrites the endpoint/model and replaces the API key if
* a new one is provided. Used by /settings's Test Connection path when the
* user is in the middle of editing and wants to try values before committing.
*/
export function previewConfig(patch: Partial<StoredConfig>): StoredConfig {
const current = loadConfig();
const candidate: StoredConfig = {
api_endpoint:
typeof patch.api_endpoint === "string" &&
patch.api_endpoint.trim().length > 0
? patch.api_endpoint.trim()
: current.api_endpoint,
api_key:
typeof patch.api_key === "string" && patch.api_key.length > 0
? patch.api_key
: current.api_key,
model_name:
typeof patch.model_name === "string" && patch.model_name.trim().length > 0
? patch.model_name.trim()
: current.model_name,
};
return candidate;
}
// ──────────────────────────────────────────────────────────────────────────────
// JSON extraction + shape validation
// ──────────────────────────────────────────────────────────────────────────────
@@ -183,6 +210,7 @@ interface ProviderRequest {
async function callProvider(
body: ProviderRequest,
cfg: StoredConfig,
signal?: AbortSignal,
): Promise<string> {
const base = cfg.api_endpoint.replace(/\/+$/, "");
const url = `${base}/chat/completions`;
@@ -196,8 +224,13 @@ async function callProvider(
Authorization: `Bearer ${cfg.api_key}`,
},
body: JSON.stringify(body),
signal,
});
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
// Re-throw so the caller can recognize a cancellation distinctly.
throw err;
}
// fetch() throws TypeError for network/CORS failures. Surface a helpful
// message — the user is running the app standalone, so CORS is the most
// likely culprit.
@@ -242,15 +275,25 @@ type PartialRequest = Omit<GenerateRequest, "section"> & {
section: Exclude<GenerateRequest["section"], "all">;
};
export async function generateSong(req: AllRequest): Promise<SongAssets>;
export interface GenerateOptions {
signal?: AbortSignal;
}
export async function generateSong(
req: AllRequest,
options?: GenerateOptions,
): Promise<SongAssets>;
export async function generateSong(
req: PartialRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>>;
export async function generateSong(
req: GenerateRequest,
options?: GenerateOptions,
): Promise<Partial<SongAssets>> {
const cfg = loadConfig();
if (!isConfigured(cfg)) {
@@ -267,6 +310,7 @@ export async function generateSong(
],
},
cfg,
options?.signal,
);
let parsed: unknown;
@@ -291,8 +335,20 @@ export async function generateSong(
return parsed as Partial<SongAssets>;
}
/**
* Test the connection using the *currently saved* configuration.
*/
export async function testConnection(): Promise<TestConnectionResult> {
const cfg = loadConfig();
return testConnectionWithConfig(loadConfig());
}
/**
* Test the connection using an arbitrary (in-memory) configuration. Used by
* the Settings page to try out a candidate key/endpoint before persisting it.
*/
export async function testConnectionWithConfig(
cfg: StoredConfig,
): Promise<TestConnectionResult> {
if (!isConfigured(cfg)) {
return {
success: false,
@@ -311,9 +367,15 @@ export async function testConnection(): Promise<TestConnectionResult> {
);
return { success: true, message: "Connection successful" };
} catch (err) {
if (err instanceof DOMException && err.name === "AbortError") {
return { success: false, message: "Cancelled" };
}
return {
success: false,
message: err instanceof Error ? err.message : "Connection test failed",
};
}
}
// Re-export so consumers can import everything from one place.
export type { InputValues };