evOo-local/apps/web/src/components/MediaSettings.tsx
2026-10-03 13:53:47 +00:00

279 lines
9.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { Trans, useLingui } from "@lingui/react/macro";
import type { MediaSettingsUpdate, MediaSettingsView } from "@rakazo/contracts";
import { Button, Field, FieldLabel, Input, Textarea } from "@rakazo/ui-web";
import { useEffect, useId, useState } from "react";
import { rpc } from "../lib/rpc";
import { SuccessPop } from "./ai/primitives";
type KeyDraft = { value: string; remove: boolean };
const emptyKey: KeyDraft = { value: "", remove: false };
/** Omitted keeps the saved key, empty removes it, anything else replaces it. */
function keyUpdate(draft: KeyDraft): string | undefined {
if (draft.value.trim()) return draft.value.trim();
return draft.remove ? "" : undefined;
}
function ApiKeyField({
saved,
draft,
disabled,
onChange,
}: {
saved: boolean;
draft: KeyDraft;
disabled: boolean;
onChange: (draft: KeyDraft) => void;
}) {
const { t } = useLingui();
const id = useId();
const showSaved = saved && !draft.remove;
return (
<Field className="mt-4">
<div className="flex items-center justify-between gap-2">
<FieldLabel htmlFor={id}>
<Trans>API key</Trans>
</FieldLabel>
{showSaved && !draft.value ? (
<Button
type="button"
variant="ghost"
size="xs"
disabled={disabled}
onClick={() => onChange({ value: "", remove: true })}
>
<Trans>Remove</Trans>
</Button>
) : null}
</div>
<Input
id={id}
type="password"
autoComplete="new-password"
value={draft.value}
disabled={disabled}
placeholder={showSaved ? t`Saved` : t`Optional`}
onChange={(event) => onChange({ ...draft, value: event.target.value })}
/>
</Field>
);
}
/** The deployment owner's media servers: ComfyUI for generation, a transcription server. */
export function MediaSettings() {
const { t } = useLingui();
const ids = useId();
const [view, setView] = useState<MediaSettingsView | null>(null);
const [comfyUrl, setComfyUrl] = useState("");
const [comfyKey, setComfyKey] = useState<KeyDraft>(emptyKey);
const [imageModel, setImageModel] = useState("");
const [imageWorkflow, setImageWorkflow] = useState("");
const [videoWorkflow, setVideoWorkflow] = useState("");
const [transcriptionUrl, setTranscriptionUrl] = useState("");
const [transcriptionModel, setTranscriptionModel] = useState("");
const [transcriptionKey, setTranscriptionKey] = useState<KeyDraft>(emptyKey);
const [busy, setBusy] = useState(false);
const [saved, setSaved] = useState(false);
const [error, setError] = useState<string | null>(null);
function apply(next: MediaSettingsView) {
setView(next);
setComfyUrl(next.comfyui?.baseUrl ?? "");
setImageModel(next.comfyui?.imageModel ?? "");
setImageWorkflow(next.comfyui?.imageWorkflow ?? "");
setVideoWorkflow(next.comfyui?.videoWorkflow ?? "");
setTranscriptionUrl(next.transcription?.baseUrl ?? "");
setTranscriptionModel(next.transcription?.model ?? "");
setComfyKey(emptyKey);
setTranscriptionKey(emptyKey);
}
useEffect(() => {
let cancelled = false;
rpc.media.settings
.get()
.then((next) => {
if (!cancelled) apply(next);
})
.catch((err: unknown) => {
if (!cancelled) setError(err instanceof Error ? err.message : t`Couldn't load`);
});
return () => {
cancelled = true;
};
}, [t]);
async function save() {
if (busy) return;
setBusy(true);
setSaved(false);
setError(null);
const update: MediaSettingsUpdate = {
comfyui: comfyUrl.trim()
? {
baseUrl: comfyUrl.trim(),
apiKey: keyUpdate(comfyKey),
imageModel,
imageWorkflow,
videoWorkflow,
}
: null,
transcription: transcriptionUrl.trim()
? {
baseUrl: transcriptionUrl.trim(),
model: transcriptionModel,
apiKey: keyUpdate(transcriptionKey),
}
: null,
};
try {
apply(await rpc.media.settings.update(update));
setSaved(true);
} catch (err) {
setError(err instanceof Error ? err.message : t`Couldn't save`);
} finally {
setBusy(false);
}
}
const disabled = busy || view === null;
const advancedSaved = Boolean(
view?.comfyui?.imageModel || view?.comfyui?.imageWorkflow || view?.comfyui?.videoWorkflow,
);
const workflowClass = "max-h-48 overflow-auto font-mono text-xs";
return (
<div data-testid="media-settings" className="space-y-5">
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">ComfyUI</h3>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-comfy-url`}>
<Trans>Server URL</Trans>
</FieldLabel>
<Input
id={`${ids}-comfy-url`}
value={comfyUrl}
disabled={disabled}
placeholder="http://127.0.0.1:8188"
autoComplete="off"
onChange={(event) => setComfyUrl(event.target.value)}
/>
</Field>
<ApiKeyField
saved={Boolean(view?.comfyui?.hasApiKey)}
draft={comfyKey}
disabled={disabled}
onChange={setComfyKey}
/>
<details
key={advancedSaved ? "saved" : "empty"}
open={advancedSaved}
data-testid="media-settings-advanced"
className="group mt-4"
>
<summary className="flex cursor-pointer list-none items-center justify-between gap-3 text-ui-body text-muted-foreground">
<span>
<Trans>Advanced</Trans>
</span>
<span aria-hidden="true" className="transition-transform group-open:rotate-90">
›
</span>
</summary>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-image-model`}>
<Trans>Image model</Trans>
</FieldLabel>
<Input
id={`${ids}-image-model`}
value={imageModel}
disabled={disabled}
placeholder={t`Optional`}
autoComplete="off"
onChange={(event) => setImageModel(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-image-workflow`}>
<Trans>Image workflow</Trans>
</FieldLabel>
<Textarea
id={`${ids}-image-workflow`}
className={workflowClass}
value={imageWorkflow}
disabled={disabled}
placeholder={t`Optional`}
spellCheck={false}
onChange={(event) => setImageWorkflow(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-video-workflow`}>
<Trans>Video workflow</Trans>
</FieldLabel>
<Textarea
id={`${ids}-video-workflow`}
className={workflowClass}
value={videoWorkflow}
disabled={disabled}
placeholder={t`Optional`}
spellCheck={false}
onChange={(event) => setVideoWorkflow(event.target.value)}
/>
</Field>
</details>
</section>
<section className="rounded-xl border border-border px-4 py-4">
<h3 className="text-ui-body font-medium text-foreground">
<Trans>Transcription</Trans>
</h3>
<Field className="mt-3">
<FieldLabel htmlFor={`${ids}-transcription-url`}>
<Trans>Server URL</Trans>
</FieldLabel>
<Input
id={`${ids}-transcription-url`}
value={transcriptionUrl}
disabled={disabled}
placeholder="http://127.0.0.1:8080/v1"
autoComplete="off"
onChange={(event) => setTranscriptionUrl(event.target.value)}
/>
</Field>
<Field className="mt-4">
<FieldLabel htmlFor={`${ids}-transcription-model`}>
<Trans>Model</Trans>
</FieldLabel>
<Input
id={`${ids}-transcription-model`}
value={transcriptionModel}
disabled={disabled}
placeholder={t`Optional`}
autoComplete="off"
onChange={(event) => setTranscriptionModel(event.target.value)}
/>
</Field>
<ApiKeyField
saved={Boolean(view?.transcription?.hasApiKey)}
draft={transcriptionKey}
disabled={disabled}
onChange={setTranscriptionKey}
/>
</section>
{error ? (
<p role="alert" className="text-ui-body-sm text-destructive">
{error}
</p>
) : null}
<div className="flex items-center gap-3">
<Button className="rounded-full" disabled={disabled} onClick={() => void save()}>
{busy ? <Trans>Saving…</Trans> : <Trans>Save</Trans>}
</Button>
{saved ? <SuccessPop label={t`Saved`} /> : null}
</div>
</div>
);
}