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) {
const { showHeading = true, workspaceSlug } = props;
const [activeTab, setActiveTab] = useState<TVoiceTaskerSettingsTab>("access");
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
@ -325,177 +339,198 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
/>
}
/>
</section>
<AccessScopeSection
accessMode={formState.access_mode}
enabledMemberIds={formState.enabled_member_ids}
enabledProjectIds={formState.enabled_project_ids}
members={workspaceMembers}
projects={projects}
onAccessModeChange={(value) => updateFormValue("access_mode", value)}
onToggleMember={(memberId) => toggleListValue("enabled_member_ids", memberId)}
onToggleProject={(projectId) => toggleListValue("enabled_project_ids", projectId)}
/>
<VoiceTaskerSettingsTabs activeTab={activeTab} onChange={setActiveTab} />
<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"
{activeTab === "access" && (
<section className="nodedc-settings-card overflow-hidden">
<AccessScopeSection
accessMode={formState.access_mode}
enabledMemberIds={formState.enabled_member_ids}
enabledProjectIds={formState.enabled_project_ids}
members={workspaceMembers}
projects={projects}
onAccessModeChange={(value) => updateFormValue("access_mode", value)}
onToggleMember={(memberId) => toggleListValue("enabled_member_ids", memberId)}
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">
<Field label="Default project fallback">
<select
value={formState.default_project_id}
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
className="nodedc-settings-select text-sm w-full px-4"
>
<option value="">None</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</Field>
<FeatureScopeSummary
accessMode={formState.access_mode}
enabledMemberIds={formState.enabled_member_ids}
enabledProjectIds={formState.enabled_project_ids}
/>
</Field>
<Field label="Default project fallback">
<select
value={formState.default_project_id}
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
className="nodedc-settings-select text-sm w-full px-4"
</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">
<NumberInput
value={formState.max_audio_duration_seconds}
min={10}
max={600}
suffix="seconds"
onChange={(value) => updateFormValue("max_audio_duration_seconds", value)}
/>
</Field>
<Field label="Лимит пользователя за час">
<NumberInput
value={formState.per_user_hourly_limit}
min={1}
max={1000}
suffix="за час"
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
/>
</Field>
<Field label="Лимит workspace за час">
<NumberInput
value={formState.workspace_hourly_limit}
min={1}
max={10000}
suffix="за час"
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
/>
</Field>
<Field label="Суточная квота пользователя">
<NumberInput
value={formState.per_user_daily_limit}
min={1}
max={10000}
suffix="за сутки"
onChange={(value) => updateFormValue("per_user_daily_limit", value)}
/>
</Field>
<Field label="Суточная квота workspace">
<NumberInput
value={formState.workspace_daily_limit}
min={1}
max={100000}
suffix="за сутки"
onChange={(value) => updateFormValue("workspace_daily_limit", value)}
/>
</Field>
<Field label="Суточная квота контура">
<NumberInput
value={formState.project_daily_limit}
min={1}
max={50000}
suffix="за сутки"
onChange={(value) => updateFormValue("project_daily_limit", value)}
/>
</Field>
<Field label="Одновременные обработки workspace">
<NumberInput
value={formState.workspace_concurrency_limit}
min={1}
max={50}
suffix="job"
onChange={(value) => updateFormValue("workspace_concurrency_limit", value)}
/>
</Field>
<Field label="Хранение чувствительных данных">
<NumberInput
value={formState.sensitive_data_retention_days}
min={1}
max={365}
suffix="дней"
onChange={(value) => updateFormValue("sensitive_data_retention_days", value)}
/>
</Field>
</div>
</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}
>
<option value="">None</option>
{projects.map((project) => (
<option key={project.id} value={project.id}>
{project.name}
</option>
))}
</select>
</Field>
<Field label="Max audio duration">
<NumberInput
value={formState.max_audio_duration_seconds}
min={10}
max={600}
suffix="seconds"
onChange={(value) => updateFormValue("max_audio_duration_seconds", value)}
/>
</Field>
</div>
</section>
Test connection
</Button>
</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} />}
{activeTab === "monitor" && (
<VoiceTaskMonitorSection
isCleaning={isCleaningStaleSessions}
isLoading={isMonitorLoading}
isRunningRetention={isRunningRetention}
monitor={monitor}
onCleanupStale={handleCleanupStaleSessions}
onRunRetention={handleRunRetention}
/>
<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="Лимит пользователя за час">
<NumberInput
value={formState.per_user_hourly_limit}
min={1}
max={1000}
suffix="за час"
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
/>
</Field>
<Field label="Лимит workspace за час">
<NumberInput
value={formState.workspace_hourly_limit}
min={1}
max={10000}
suffix="за час"
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
/>
</Field>
<Field label="Суточная квота пользователя">
<NumberInput
value={formState.per_user_daily_limit}
min={1}
max={10000}
suffix="за сутки"
onChange={(value) => updateFormValue("per_user_daily_limit", value)}
/>
</Field>
<Field label="Суточная квота workspace">
<NumberInput
value={formState.workspace_daily_limit}
min={1}
max={100000}
suffix="за сутки"
onChange={(value) => updateFormValue("workspace_daily_limit", value)}
/>
</Field>
<Field label="Суточная квота контура">
<NumberInput
value={formState.project_daily_limit}
min={1}
max={50000}
suffix="за сутки"
onChange={(value) => updateFormValue("project_daily_limit", value)}
/>
</Field>
<Field label="Одновременные обработки workspace">
<NumberInput
value={formState.workspace_concurrency_limit}
min={1}
max={50}
suffix="job"
onChange={(value) => updateFormValue("workspace_concurrency_limit", value)}
/>
</Field>
<Field label="Хранение чувствительных данных">
<NumberInput
value={formState.sensitive_data_retention_days}
min={1}
max={365}
suffix="дней"
onChange={(value) => updateFormValue("sensitive_data_retention_days", value)}
/>
</Field>
</div>
</section>
<VoiceTaskMonitorSection
isCleaning={isCleaningStaleSessions}
isLoading={isMonitorLoading}
isRunningRetention={isRunningRetention}
monitor={monitor}
onCleanupStale={handleCleanupStaleSessions}
onRunRetention={handleRunRetention}
/>
)}
<div className="flex items-center justify-end gap-3">
<Button
@ -531,6 +566,128 @@ type TAccessScopeSectionProps = {
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({
accessMode,
enabledMemberIds,
@ -545,7 +702,7 @@ function AccessScopeSection({
const selectedMembersCount = enabledMemberIds.length;
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">
<h4 className="text-13 font-semibold text-primary">Доступ к функции</h4>
<p className="max-w-3xl text-12 leading-5 text-tertiary">