refactor(admin): split system settings into section modules
This commit is contained in:
@@ -0,0 +1,405 @@
|
||||
import { InfoTooltip } from "~/components/ui/InfoTooltip.js";
|
||||
import {
|
||||
INPUT_CLASS,
|
||||
LABEL_CLASS,
|
||||
PANEL_CLASS,
|
||||
PRIMARY_BUTTON_CLASS,
|
||||
SECONDARY_BUTTON_CLASS,
|
||||
RuntimeSecretCard,
|
||||
type Provider,
|
||||
type RuntimeSecretStatus,
|
||||
type SaveResult,
|
||||
type UrlParsedType,
|
||||
} from "./shared.js";
|
||||
|
||||
type AiProviderPanelProps = {
|
||||
provider: Provider;
|
||||
endpoint: string;
|
||||
model: string;
|
||||
apiVersion: string;
|
||||
urlPasteValue: string;
|
||||
urlParseError: boolean;
|
||||
urlParsedType: UrlParsedType;
|
||||
runtimeSecret: RuntimeSecretStatus;
|
||||
testResult: SaveResult | null;
|
||||
isSaving: boolean;
|
||||
isTesting: boolean;
|
||||
saved: boolean;
|
||||
onProviderChange: (provider: Provider) => void;
|
||||
onEndpointChange: (value: string) => void;
|
||||
onModelChange: (value: string) => void;
|
||||
onApiVersionChange: (value: string) => void;
|
||||
onUrlPaste: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onTest: () => void;
|
||||
};
|
||||
|
||||
export function AiProviderPanel({
|
||||
provider,
|
||||
endpoint,
|
||||
model,
|
||||
apiVersion,
|
||||
urlPasteValue,
|
||||
urlParseError,
|
||||
urlParsedType,
|
||||
runtimeSecret,
|
||||
testResult,
|
||||
isSaving,
|
||||
isTesting,
|
||||
saved,
|
||||
onProviderChange,
|
||||
onEndpointChange,
|
||||
onModelChange,
|
||||
onApiVersionChange,
|
||||
onUrlPaste,
|
||||
onSave,
|
||||
onTest,
|
||||
}: AiProviderPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
AI Provider{" "}
|
||||
<InfoTooltip content="Configure the AI service used for generating resource skill profile summaries. Either OpenAI directly or Azure OpenAI Service." />
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS}>Provider</label>
|
||||
<div className="flex w-fit overflow-hidden rounded-xl border border-gray-200 dark:border-gray-600">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onProviderChange("openai")}
|
||||
className={`px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
provider === "openai"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
OpenAI
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onProviderChange("azure")}
|
||||
className={`border-l border-gray-200 px-4 py-2 text-sm font-semibold transition-colors dark:border-gray-600 ${
|
||||
provider === "azure"
|
||||
? "bg-brand-600 text-white"
|
||||
: "bg-white text-gray-600 hover:bg-gray-50 dark:bg-gray-700 dark:text-gray-300 dark:hover:bg-gray-600"
|
||||
}`}
|
||||
>
|
||||
Azure OpenAI
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1.5 text-xs text-gray-400 dark:text-gray-500">
|
||||
{provider === "openai"
|
||||
? "Use a standard OpenAI API key from platform.openai.com."
|
||||
: "Use a deployment on your own Azure OpenAI resource."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider === "azure" ? (
|
||||
<>
|
||||
<div className="space-y-2 rounded-2xl border border-blue-200 bg-blue-50 px-4 py-3 dark:border-blue-700 dark:bg-blue-900/20">
|
||||
<p className="text-xs font-medium text-blue-800 dark:text-blue-300">
|
||||
Paste a full completion URL to auto-fill all fields below:
|
||||
</p>
|
||||
<input
|
||||
type="url"
|
||||
className={`${INPUT_CLASS} border-blue-300 dark:border-blue-600`}
|
||||
placeholder="https://…cognitiveservices.azure.com/openai/deployments/gpt-5/chat/completions?api-version=2025-01-01-preview"
|
||||
value={urlPasteValue}
|
||||
onChange={(event) => onUrlPaste(event.target.value)}
|
||||
/>
|
||||
{urlParseError ? (
|
||||
<p className="text-xs text-red-600 dark:text-red-400">
|
||||
Could not parse URL; expected either a Chat Completions URL (
|
||||
<code className="font-mono">/openai/deployments/…/chat/completions</code>) or a
|
||||
Responses API URL (<code className="font-mono">/openai/responses</code>).
|
||||
</p>
|
||||
) : null}
|
||||
{urlParsedType === "responses" ? (
|
||||
<p className="text-xs text-amber-700 dark:text-amber-400">
|
||||
Responses API URL detected. Endpoint and api-version were filled in, but the
|
||||
deployment or model name still has to be entered manually below.
|
||||
</p>
|
||||
) : null}
|
||||
{urlParsedType === "completions" ? (
|
||||
<p className="text-xs text-green-700 dark:text-green-400">All fields filled from URL.</p>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-endpoint">
|
||||
Endpoint (base URL)
|
||||
</label>
|
||||
<input
|
||||
id="ai-endpoint"
|
||||
type="url"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="https://myinstance.cognitiveservices.azure.com"
|
||||
value={endpoint}
|
||||
onChange={(event) => onEndpointChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Everything up to (not including) <code className="font-mono">/openai/…</code>
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-model">
|
||||
{provider === "azure" ? "Deployment Name" : "Model Name"}
|
||||
</label>
|
||||
<input
|
||||
id="ai-model"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder={provider === "azure" ? "my-gpt4o-deployment" : "gpt-4o-mini"}
|
||||
value={model}
|
||||
onChange={(event) => onModelChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
{provider === "azure"
|
||||
? "The deployment name chosen when deploying the model in Azure."
|
||||
: "The model identifier, for example gpt-4o-mini or gpt-4o."}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{provider === "azure" ? (
|
||||
<div>
|
||||
<label className={LABEL_CLASS} htmlFor="ai-api-version">
|
||||
API Version
|
||||
</label>
|
||||
<input
|
||||
id="ai-api-version"
|
||||
type="text"
|
||||
className={INPUT_CLASS}
|
||||
placeholder="2025-01-01-preview"
|
||||
value={apiVersion}
|
||||
onChange={(event) => onApiVersionChange(event.target.value)}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
The <code className="font-mono">api-version</code> query parameter from the endpoint
|
||||
URL. Default: <code className="font-mono">2025-01-01-preview</code>
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<RuntimeSecretCard
|
||||
title="Primary AI API Key"
|
||||
description={
|
||||
provider === "openai"
|
||||
? "The runtime reads the OpenAI key directly from deployment secrets. Saving this form does not store or rotate secrets."
|
||||
: "The runtime reads the Azure OpenAI key directly from deployment secrets. Saving this form only updates non-secret metadata."
|
||||
}
|
||||
secret={runtimeSecret}
|
||||
optionalNote={
|
||||
provider === "openai"
|
||||
? "Expected source: OPENAI_API_KEY. AZURE_OPENAI_API_KEY is also accepted as a fallback."
|
||||
: "Expected source: AZURE_OPENAI_API_KEY. OPENAI_API_KEY is also accepted as a fallback."
|
||||
}
|
||||
/>
|
||||
|
||||
{testResult ? (
|
||||
<div
|
||||
className={`rounded-2xl border px-4 py-3 text-sm ${
|
||||
testResult.ok
|
||||
? "border-green-200 bg-green-50 text-green-700 dark:border-green-700 dark:bg-green-900/30 dark:text-green-300"
|
||||
: "border-red-200 bg-red-50 text-red-700 dark:border-red-700 dark:bg-red-900/30 dark:text-red-300"
|
||||
}`}
|
||||
>
|
||||
{testResult.ok ? (
|
||||
<span className="font-medium">
|
||||
Connection successful. AI summaries are ready to use.
|
||||
</span>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
<p>
|
||||
<span className="font-medium">Connection failed:</span> {testResult.error}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex items-center gap-3 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={onTest}
|
||||
disabled={isTesting}
|
||||
className={SECONDARY_BUTTON_CLASS}
|
||||
>
|
||||
{isTesting ? "Testing…" : "Test Connection"}
|
||||
</button>
|
||||
{saved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
type GenerationSettingsPanelProps = {
|
||||
maxTokens: number;
|
||||
temperature: number;
|
||||
summaryPrompt: string;
|
||||
defaultSummaryPrompt: string;
|
||||
isSaving: boolean;
|
||||
saved: boolean;
|
||||
onMaxTokensChange: (value: number) => void;
|
||||
onTemperatureChange: (value: number) => void;
|
||||
onSummaryPromptChange: (value: string) => void;
|
||||
onSave: () => void;
|
||||
onResetSummaryPrompt: () => void;
|
||||
};
|
||||
|
||||
export function GenerationSettingsPanel({
|
||||
maxTokens,
|
||||
temperature,
|
||||
summaryPrompt,
|
||||
defaultSummaryPrompt,
|
||||
isSaving,
|
||||
saved,
|
||||
onMaxTokensChange,
|
||||
onTemperatureChange,
|
||||
onSummaryPromptChange,
|
||||
onSave,
|
||||
onResetSummaryPrompt,
|
||||
}: GenerationSettingsPanelProps) {
|
||||
return (
|
||||
<div className={PANEL_CLASS}>
|
||||
<h2 className="flex items-center text-sm font-semibold uppercase tracking-[0.18em] text-gray-700 dark:text-gray-200">
|
||||
Generation Settings{" "}
|
||||
<InfoTooltip content="Fine-tune how the AI generates skill profile summaries. These settings affect output length, creativity, and the prompt template." />
|
||||
</h2>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-max-tokens">
|
||||
Max Completion Tokens
|
||||
</label>
|
||||
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">{maxTokens}</span>
|
||||
</div>
|
||||
<input
|
||||
id="ai-max-tokens"
|
||||
type="range"
|
||||
min={50}
|
||||
max={2000}
|
||||
step={50}
|
||||
value={maxTokens}
|
||||
onChange={(event) => onMaxTokensChange(Number(event.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<span>500</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{maxTokens < 1000
|
||||
? "⚠ May be empty for reasoning models (GPT-5, o1, o3)"
|
||||
: maxTokens <= 2000
|
||||
? "Recommended for reasoning models ✓"
|
||||
: maxTokens <= 4000
|
||||
? "High; allows longer bios"
|
||||
: "Very high"}
|
||||
</span>
|
||||
<span>16000</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-amber-600 dark:text-amber-400">
|
||||
Reasoning models consume tokens internally before writing output. Keep this at 2000 or
|
||||
above to avoid empty responses.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-temperature">
|
||||
Temperature
|
||||
</label>
|
||||
<span className="text-sm font-mono text-brand-600 dark:text-brand-400">
|
||||
{temperature.toFixed(1)}
|
||||
</span>
|
||||
</div>
|
||||
<input
|
||||
id="ai-temperature"
|
||||
type="range"
|
||||
min={0}
|
||||
max={2}
|
||||
step={0.1}
|
||||
value={temperature}
|
||||
onChange={(event) => onTemperatureChange(Number(event.target.value))}
|
||||
className="w-full accent-brand-600"
|
||||
/>
|
||||
<div className="mt-1 flex justify-between text-xs text-gray-400">
|
||||
<span>0 — deterministic</span>
|
||||
<span className="text-gray-500 dark:text-gray-400">
|
||||
{temperature <= 0.3
|
||||
? "Factual & consistent"
|
||||
: temperature <= 0.9
|
||||
? "Balanced"
|
||||
: temperature <= 1.0
|
||||
? "Default (1) — recommended ✓"
|
||||
: temperature <= 1.2
|
||||
? "Creative"
|
||||
: "Very creative / unpredictable"}
|
||||
</span>
|
||||
<span>2 — creative</span>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Some models only accept the default value of 1. If generation fails with a temperature
|
||||
error, the system retries automatically without it.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="mb-1 flex items-center justify-between">
|
||||
<label className={LABEL_CLASS} htmlFor="ai-prompt">
|
||||
Profile Summary Prompt
|
||||
</label>
|
||||
{summaryPrompt ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onResetSummaryPrompt}
|
||||
className="text-xs text-brand-600 hover:underline"
|
||||
>
|
||||
Reset to default
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
<textarea
|
||||
id="ai-prompt"
|
||||
rows={10}
|
||||
value={summaryPrompt || defaultSummaryPrompt}
|
||||
onChange={(event) => onSummaryPromptChange(event.target.value)}
|
||||
className={`${INPUT_CLASS} resize-y font-mono text-xs leading-relaxed`}
|
||||
spellCheck={false}
|
||||
/>
|
||||
<p className="mt-1 text-xs text-gray-400 dark:text-gray-500">
|
||||
Available placeholders: <code className="font-mono">{"{role}"}</code>{" "}
|
||||
<code className="font-mono">{"{chapter}"}</code>{" "}
|
||||
<code className="font-mono">{"{mainSkills}"}</code>{" "}
|
||||
<code className="font-mono">{"{topSkills}"}</code>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 pt-1">
|
||||
<button
|
||||
type="button"
|
||||
onClick={onSave}
|
||||
disabled={isSaving}
|
||||
className={PRIMARY_BUTTON_CLASS}
|
||||
>
|
||||
{isSaving ? "Saving…" : "Save Settings"}
|
||||
</button>
|
||||
{saved ? (
|
||||
<span className="text-sm font-medium text-green-600 dark:text-green-400">Saved!</span>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user