Files
helix-engage/src/pages/ai-settings.tsx
saridsa2 a7b2fd7fbe feat(onboarding/phase-4): telephony, AI, widget config CRUD pages
Replaces the three remaining Pattern B placeholder routes
(/settings/telephony, /settings/ai, /settings/widget) with real forms
backed by the sidecar config endpoints introduced in Phase 1. Each page
loads the current config on mount, round-trips edits via PUT, and
supports reset-to-defaults. Changes take effect immediately since the
TelephonyConfigService / AiConfigService / WidgetConfigService all keep
in-memory caches that all consumers read through.

- src/components/forms/telephony-form.tsx — Ozonetel + SIP + Exotel
  sections; honours the '***masked***' sentinel for secrets
- src/components/forms/ai-form.tsx — provider/model/temperature/prompt
  with per-provider model suggestions
- src/components/forms/widget-form.tsx — enabled/url/embed toggles plus
  an allowedOrigins chip list
- src/pages/telephony-settings.tsx — loads masked config, marks the
  telephony wizard step complete when all required Ozonetel fields
  are filled
- src/pages/ai-settings.tsx — clamps temperature to 0..2 on save,
  marks the ai wizard step complete on successful save
- src/pages/widget-settings.tsx — uses the admin endpoint
  (/api/config/widget/admin), exposes rotate-key + copy-to-clipboard
  for the site key, and separates the read-only key card from the
  editable config card
- src/main.tsx — swaps the three placeholder routes for the real pages
- src/pages/settings-placeholder.tsx — removed; no longer referenced

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 07:49:32 +05:30

160 lines
6.5 KiB
TypeScript

import { useEffect, useState } from 'react';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { faRobot, faRotateLeft } from '@fortawesome/pro-duotone-svg-icons';
import { Button } from '@/components/base/buttons/button';
import { TopBar } from '@/components/layout/top-bar';
import {
AiForm,
emptyAiFormValues,
type AiFormValues,
type AiProvider,
} from '@/components/forms/ai-form';
import { apiClient } from '@/lib/api-client';
import { notify } from '@/lib/toast';
import { markSetupStepComplete } from '@/lib/setup-state';
// /settings/ai — Pattern B page for the AI assistant config. Backed by
// /api/config/ai which is file-backed (data/ai.json) and hot-reloaded through
// AiConfigService — no restart needed.
//
// Temperature is a string in the form for input UX (so users can partially
// type '0.', '0.5', etc) then clamped to 0..2 on save.
type ServerAiConfig = {
provider?: AiProvider;
model?: string;
temperature?: number;
systemPromptAddendum?: string;
};
const clampTemperature = (raw: string): number => {
const n = Number(raw);
if (Number.isNaN(n)) return 0.7;
return Math.min(2, Math.max(0, n));
};
export const AiSettingsPage = () => {
const [values, setValues] = useState<AiFormValues>(emptyAiFormValues);
const [loading, setLoading] = useState(true);
const [isSaving, setIsSaving] = useState(false);
const [isResetting, setIsResetting] = useState(false);
const loadConfig = async () => {
try {
const data = await apiClient.get<ServerAiConfig>('/api/config/ai');
setValues({
provider: data.provider ?? 'openai',
model: data.model ?? 'gpt-4o-mini',
temperature: data.temperature != null ? String(data.temperature) : '0.7',
systemPromptAddendum: data.systemPromptAddendum ?? '',
});
} catch {
// toast already shown
} finally {
setLoading(false);
}
};
useEffect(() => {
loadConfig();
}, []);
const handleSave = async () => {
if (!values.model.trim()) {
notify.error('Model is required');
return;
}
setIsSaving(true);
try {
await apiClient.put('/api/config/ai', {
provider: values.provider,
model: values.model.trim(),
temperature: clampTemperature(values.temperature),
systemPromptAddendum: values.systemPromptAddendum,
});
notify.success('AI settings updated', 'Changes are live for new conversations.');
markSetupStepComplete('ai').catch(() => {});
await loadConfig();
} catch (err) {
console.error('[ai] save failed', err);
} finally {
setIsSaving(false);
}
};
const handleReset = async () => {
if (!confirm('Reset AI settings to defaults? The system prompt addendum will be cleared.')) {
return;
}
setIsResetting(true);
try {
await apiClient.post('/api/config/ai/reset');
notify.info('AI reset', 'Provider, model, and prompt have been restored to defaults.');
await loadConfig();
} catch (err) {
console.error('[ai] reset failed', err);
} finally {
setIsResetting(false);
}
};
return (
<div className="flex flex-1 flex-col overflow-hidden">
<TopBar title="AI Assistant" subtitle="Choose provider, model, and conversational guidelines" />
<div className="flex-1 overflow-y-auto p-6">
<div className="mx-auto max-w-2xl">
<div className="mb-6 flex items-center gap-3 rounded-lg border border-secondary bg-primary p-4">
<div className="flex size-10 items-center justify-center rounded-lg bg-brand-secondary">
<FontAwesomeIcon icon={faRobot} className="size-5 text-fg-brand-primary" />
</div>
<div className="flex-1">
<p className="text-sm font-semibold text-primary">API keys live in environment variables</p>
<p className="text-xs text-tertiary">
The actual OPENAI_API_KEY and ANTHROPIC_API_KEY are set at deploy time and
can't be edited here. If you change the provider, make sure the matching key
is configured on the sidecar or the assistant will silently fall back to the
other provider.
</p>
</div>
</div>
{loading ? (
<div className="flex items-center justify-center py-16">
<p className="text-sm text-tertiary">Loading AI settings...</p>
</div>
) : (
<div className="rounded-xl border border-secondary bg-primary p-6 shadow-xs">
<AiForm value={values} onChange={setValues} />
<div className="mt-8 flex items-center justify-between border-t border-secondary pt-4">
<Button
size="md"
color="secondary"
iconLeading={({ className }: { className?: string }) => (
<FontAwesomeIcon icon={faRotateLeft} className={className} />
)}
onClick={handleReset}
isLoading={isResetting}
showTextWhileLoading
>
Reset to defaults
</Button>
<Button
size="md"
color="primary"
onClick={handleSave}
isLoading={isSaving}
showTextWhileLoading
>
{isSaving ? 'Saving...' : 'Save changes'}
</Button>
</div>
</div>
)}
</div>
</div>
</div>
);
};