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:
+65
-3
@@ -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 };
|
||||
|
||||
Reference in New Issue
Block a user