Files
Nexus/apps/web/src/components/admin/system-settings/AiSettingsPanels.tsx
T

407 lines
14 KiB
TypeScript

import { DEFAULT_OPENAI_MODEL } from "@capakraken/shared";
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" ? "capakraken-gpt-5-4" : DEFAULT_OPENAI_MODEL}
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 ${DEFAULT_OPENAI_MODEL} or gpt-5.4-pro.`}
</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>
);
}