b41c1d2501
CI / Architecture Guardrails (push) Successful in 2m38s
CI / Assistant Split Regression (push) Successful in 3m33s
CI / Typecheck (push) Successful in 3m51s
CI / Lint (push) Successful in 5m2s
CI / E2E Tests (push) Has been cancelled
CI / Fresh-Linux Docker Deploy (push) Has been cancelled
CI / Release Images (push) Has been cancelled
CI / Build (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
rename(phase 1): CapaKraken → Nexus across code, UI, docs, CI (#61) Co-authored-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com> Co-committed-by: Hartmut Nörenberg <hn@hartmut-noerenberg.com>
399 lines
14 KiB
TypeScript
399 lines
14 KiB
TypeScript
import { DEFAULT_OPENAI_MODEL } from "@nexus/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" ? "nexus-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>
|
|
);
|
|
}
|