UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: вкладки enterprise-настроек Voice Tasker

This commit is contained in:
DCCONSTRUCTIONS 2026-04-28 18:33:26 +03:00
parent b796a21852
commit 5e57786d39
1 changed files with 323 additions and 166 deletions

View File

@ -112,8 +112,22 @@ const ACCESS_MODE_OPTIONS: {
}, },
]; ];
type TVoiceTaskerSettingsTab = "access" | "limits" | "models" | "monitor";
const VOICE_TASKER_SETTINGS_TABS: {
icon: ElementType;
label: string;
value: TVoiceTaskerSettingsTab;
}[] = [
{ value: "access", label: "Доступ", icon: ShieldCheck },
{ value: "limits", label: "Лимиты", icon: Activity },
{ value: "models", label: "Модели и ключ", icon: KeyRound },
{ value: "monitor", label: "Очередь и журнал", icon: BrainCircuit },
];
export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSettingsContent(props: TProps) { export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSettingsContent(props: TProps) {
const { showHeading = true, workspaceSlug } = props; const { showHeading = true, workspaceSlug } = props;
const [activeTab, setActiveTab] = useState<TVoiceTaskerSettingsTab>("access");
const [formState, setFormState] = useState<TFormState>(getInitialFormState()); const [formState, setFormState] = useState<TFormState>(getInitialFormState());
const [isSaving, setIsSaving] = useState(false); const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false); const [isTesting, setIsTesting] = useState(false);
@ -325,7 +339,12 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
/> />
} }
/> />
</section>
<VoiceTaskerSettingsTabs activeTab={activeTab} onChange={setActiveTab} />
{activeTab === "access" && (
<section className="nodedc-settings-card overflow-hidden">
<AccessScopeSection <AccessScopeSection
accessMode={formState.access_mode} accessMode={formState.access_mode}
enabledMemberIds={formState.enabled_member_ids} enabledMemberIds={formState.enabled_member_ids}
@ -336,15 +355,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
onToggleMember={(memberId) => toggleListValue("enabled_member_ids", memberId)} onToggleMember={(memberId) => toggleListValue("enabled_member_ids", memberId)}
onToggleProject={(projectId) => toggleListValue("enabled_project_ids", projectId)} onToggleProject={(projectId) => toggleListValue("enabled_project_ids", projectId)}
/> />
<div className="grid gap-5 border-t border-white/5 px-5 py-5 md:grid-cols-2">
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
<Field label="Provider">
<Input
value="OpenAI"
disabled
className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4"
/>
</Field>
<Field label="Default project fallback"> <Field label="Default project fallback">
<select <select
value={formState.default_project_id} value={formState.default_project_id}
@ -359,6 +370,24 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
))} ))}
</select> </select>
</Field> </Field>
<FeatureScopeSummary
accessMode={formState.access_mode}
enabledMemberIds={formState.enabled_member_ids}
enabledProjectIds={formState.enabled_project_ids}
/>
</div>
</section>
)}
{activeTab === "limits" && (
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={Activity}
title="Лимиты и retention"
description="Ограничения применяются до отправки аудио в OpenAI; retention очищает transcript и parser payload без удаления метрик."
/>
<LimitUsageOverview formState={formState} monitor={monitor} />
<div className="grid gap-5 border-t border-white/5 px-5 py-5 md:grid-cols-2">
<Field label="Max audio duration"> <Field label="Max audio duration">
<NumberInput <NumberInput
value={formState.max_audio_duration_seconds} value={formState.max_audio_duration_seconds}
@ -368,60 +397,6 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
onChange={(value) => updateFormValue("max_audio_duration_seconds", value)} onChange={(value) => updateFormValue("max_audio_duration_seconds", value)}
/> />
</Field> </Field>
</div>
</section>
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={KeyRound}
title="OpenAI credential"
description="Key заменяется только если ввести новый. В API response возвращается только last4."
right={<CredentialStatus hasKey={settings.credential.has_key} keyLast4={settings.credential.key_last4} />}
/>
<div className="grid gap-5 px-5 py-5 md:grid-cols-[1fr_auto] md:items-end">
<Field label="OpenAI API Key">
<Input
type="password"
value={formState.openai_api_key}
onChange={(event) => updateFormValue("openai_api_key", event.target.value)}
placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."}
className="nodedc-settings-input w-full"
/>
</Field>
<Button
variant="secondary"
size="lg"
className="nodedc-settings-chip min-w-[10rem]"
loading={isTesting}
disabled={!settings.credential.has_key || isSaving}
onClick={handleTestConnection}
>
Test connection
</Button>
</div>
</section>
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={BrainCircuit}
title="Models and limits"
description="Один workspace key обслуживает всех пользователей. Лимиты срабатывают до отправки аудио в OpenAI."
/>
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
<Field label="Transcription model">
<Input
value={formState.transcription_model}
onChange={(event) => updateFormValue("transcription_model", event.target.value)}
className="nodedc-settings-input w-full"
/>
</Field>
<Field label="Structuring model">
<Input
value={formState.structuring_model}
onChange={(event) => updateFormValue("structuring_model", event.target.value)}
className="nodedc-settings-input w-full"
/>
</Field>
<Field label="Лимит пользователя за час"> <Field label="Лимит пользователя за час">
<NumberInput <NumberInput
value={formState.per_user_hourly_limit} value={formState.per_user_hourly_limit}
@ -487,7 +462,66 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
</Field> </Field>
</div> </div>
</section> </section>
)}
{activeTab === "models" && (
<section className="nodedc-settings-card overflow-hidden">
<SectionHeader
icon={KeyRound}
title="Модели и OpenAI key"
description="Key заменяется только если ввести новый. В API response возвращается только last4."
right={
<CredentialStatus hasKey={settings.credential.has_key} keyLast4={settings.credential.key_last4} />
}
/>
<div className="grid gap-5 border-t border-white/5 px-5 py-5 md:grid-cols-2">
<Field label="Provider">
<Input
value="OpenAI"
disabled
className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4"
/>
</Field>
<Field label="Transcription model">
<Input
value={formState.transcription_model}
onChange={(event) => updateFormValue("transcription_model", event.target.value)}
className="nodedc-settings-input w-full"
/>
</Field>
<Field label="Structuring model">
<Input
value={formState.structuring_model}
onChange={(event) => updateFormValue("structuring_model", event.target.value)}
className="nodedc-settings-input w-full"
/>
</Field>
<Field label="OpenAI API Key">
<Input
type="password"
value={formState.openai_api_key}
onChange={(event) => updateFormValue("openai_api_key", event.target.value)}
placeholder={settings.credential.has_key ? "sk-... не изменять" : "sk-..."}
className="nodedc-settings-input w-full"
/>
</Field>
</div>
<div className="flex justify-end px-5 pb-5">
<Button
variant="secondary"
size="lg"
className="nodedc-settings-chip min-w-[10rem]"
loading={isTesting}
disabled={!settings.credential.has_key || isSaving}
onClick={handleTestConnection}
>
Test connection
</Button>
</div>
</section>
)}
{activeTab === "monitor" && (
<VoiceTaskMonitorSection <VoiceTaskMonitorSection
isCleaning={isCleaningStaleSessions} isCleaning={isCleaningStaleSessions}
isLoading={isMonitorLoading} isLoading={isMonitorLoading}
@ -496,6 +530,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
onCleanupStale={handleCleanupStaleSessions} onCleanupStale={handleCleanupStaleSessions}
onRunRetention={handleRunRetention} onRunRetention={handleRunRetention}
/> />
)}
<div className="flex items-center justify-end gap-3"> <div className="flex items-center justify-end gap-3">
<Button <Button
@ -531,6 +566,128 @@ type TAccessScopeSectionProps = {
projects: IProject[]; projects: IProject[];
}; };
type TVoiceTaskerSettingsTabsProps = {
activeTab: TVoiceTaskerSettingsTab;
onChange: (value: TVoiceTaskerSettingsTab) => void;
};
function VoiceTaskerSettingsTabs({ activeTab, onChange }: TVoiceTaskerSettingsTabsProps) {
return (
<div className="nodedc-settings-card flex flex-wrap gap-2 p-2">
{VOICE_TASKER_SETTINGS_TABS.map((tab) => {
const Icon = tab.icon;
const isActive = activeTab === tab.value;
return (
<button
key={tab.value}
type="button"
onClick={() => onChange(tab.value)}
className={cn(
"inline-flex min-h-10 flex-1 items-center justify-center gap-2 rounded-full px-4 text-13 font-semibold transition-colors md:flex-none",
isActive
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
: "bg-white/[0.045] text-secondary hover:bg-white/[0.07] hover:text-primary"
)}
>
<Icon className="size-4" />
{tab.label}
</button>
);
})}
</div>
);
}
type TFeatureScopeSummaryProps = {
accessMode: TWorkspaceAIAccessMode;
enabledMemberIds: string[];
enabledProjectIds: string[];
};
function FeatureScopeSummary({ accessMode, enabledMemberIds, enabledProjectIds }: TFeatureScopeSummaryProps) {
const summaryByMode: Record<TWorkspaceAIAccessMode, { label: string; value: string }> = {
all_workspace_members: { label: "Активный scope", value: "Весь workspace" },
admins_only: { label: "Активный scope", value: "Только админы" },
selected_projects: { label: "Выбранные контуры", value: formatMonitorNumber(enabledProjectIds.length) },
selected_members: { label: "Выбранные пользователи", value: formatMonitorNumber(enabledMemberIds.length) },
};
const summary = summaryByMode[accessMode];
return (
<div className="rounded-[1.35rem] bg-white/[0.045] px-4 py-3">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">{summary.label}</div>
<div className="mt-2 text-2xl font-semibold text-[rgb(var(--nodedc-accent-rgb))]">{summary.value}</div>
<div className="mt-1 text-11 text-tertiary">backend проверка перед записью и публикацией</div>
</div>
);
}
type TLimitUsageOverviewProps = {
formState: TFormState;
monitor?: TVoiceTaskMonitor;
};
function LimitUsageOverview({ formState, monitor }: TLimitUsageOverviewProps) {
const totalSessions = monitor?.summary.total ?? 0;
const activeSessions = monitor?.summary.active ?? 0;
const workspaceDailyRatio = getLimitRatio(totalSessions, formState.workspace_daily_limit);
const concurrencyRatio = getLimitRatio(activeSessions, formState.workspace_concurrency_limit);
return (
<div className="grid gap-3 border-t border-white/5 px-5 py-5 md:grid-cols-4">
<LimitUsageCard
label="Workspace / сутки"
value={`${formatMonitorNumber(totalSessions)} / ${formatMonitorNumber(formState.workspace_daily_limit)}`}
ratio={workspaceDailyRatio}
/>
<LimitUsageCard
label="Очередь сейчас"
value={`${formatMonitorNumber(activeSessions)} / ${formatMonitorNumber(formState.workspace_concurrency_limit)}`}
ratio={concurrencyRatio}
/>
<LimitUsageCard
label="Project / сутки"
value={formatMonitorNumber(formState.project_daily_limit)}
ratio={0}
/>
<LimitUsageCard
label="Retention"
value={`${formatMonitorNumber(formState.sensitive_data_retention_days)} дней`}
ratio={0}
/>
</div>
);
}
type TLimitUsageCardProps = {
label: string;
ratio: number;
value: string;
};
function LimitUsageCard({ label, ratio, value }: TLimitUsageCardProps) {
const boundedRatio = Math.min(Math.max(ratio, 0), 1);
return (
<div className="rounded-[1.35rem] bg-white/[0.045] px-4 py-3">
<div className="text-11 font-semibold tracking-[0.18em] text-tertiary uppercase">{label}</div>
<div className="mt-2 text-xl font-semibold text-primary">{value}</div>
<div className="mt-3 h-2 overflow-hidden rounded-full bg-white/8">
<div
className="h-full rounded-full bg-[rgb(var(--nodedc-accent-rgb))]"
style={{ width: `${Math.round(boundedRatio * 100)}%` }}
/>
</div>
</div>
);
}
function getLimitRatio(value: number, limit: number) {
if (!limit || limit <= 0) return 0;
return value / limit;
}
function AccessScopeSection({ function AccessScopeSection({
accessMode, accessMode,
enabledMemberIds, enabledMemberIds,
@ -545,7 +702,7 @@ function AccessScopeSection({
const selectedMembersCount = enabledMemberIds.length; const selectedMembersCount = enabledMemberIds.length;
return ( return (
<div className="border-t border-white/5 px-5 py-5"> <div className="px-5 py-5">
<div className="mb-4 flex flex-col gap-1"> <div className="mb-4 flex flex-col gap-1">
<h4 className="text-13 font-semibold text-primary">Доступ к функции</h4> <h4 className="text-13 font-semibold text-primary">Доступ к функции</h4>
<p className="max-w-3xl text-12 leading-5 text-tertiary"> <p className="max-w-3xl text-12 leading-5 text-tertiary">