UI - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: вкладки enterprise-настроек Voice Tasker
This commit is contained in:
parent
b796a21852
commit
5e57786d39
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in New Issue