АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас Voice Tasker settings

This commit is contained in:
DCCONSTRUCTIONS
2026-04-24 16:47:16 +03:00
parent b3c6b37399
commit 237c7964cd
20 changed files with 2009 additions and 3 deletions
@@ -0,0 +1,40 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { observer } from "mobx-react";
// plane imports
import { WORKSPACE_SETTINGS } from "@plane/constants";
import { useTranslation } from "@plane/i18n";
import { Breadcrumbs } from "@plane/ui";
// components
import { BreadcrumbLink } from "@/components/common/breadcrumb-link";
import { SettingsPageHeader } from "@/components/settings/page-header";
import { WORKSPACE_SETTINGS_ICONS } from "@/components/settings/workspace/sidebar/item-icon";
export const AIVoiceTaskerWorkspaceSettingsHeader = observer(function AIVoiceTaskerWorkspaceSettingsHeader() {
const { t } = useTranslation();
const settingsDetails = WORKSPACE_SETTINGS["ai-voice-tasker"];
const Icon = WORKSPACE_SETTINGS_ICONS["ai-voice-tasker"];
return (
<SettingsPageHeader
leftItem={
<div className="flex items-center gap-2">
<Breadcrumbs>
<Breadcrumbs.Item
component={
<BreadcrumbLink
label={t(settingsDetails.i18n_label)}
icon={<Icon className="size-4 text-tertiary" />}
/>
}
/>
</Breadcrumbs>
</div>
}
/>
);
});
@@ -0,0 +1,393 @@
/**
* Copyright (c) 2023-present Plane Software, Inc. and contributors
* SPDX-License-Identifier: AGPL-3.0-only
* See the LICENSE file for details.
*/
import { useEffect, useMemo, useState } from "react";
import { observer } from "mobx-react";
import useSWR, { mutate } from "swr";
import { BrainCircuit, KeyRound, Mic, ShieldCheck } from "lucide-react";
// plane imports
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
import { Button } from "@plane/propel/button";
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
import type { TWorkspaceAIAccessMode, TWorkspaceAISettings, TWorkspaceAISettingsPayload } from "@plane/types";
import { Input, ToggleSwitch } from "@plane/ui";
import { cn } from "@plane/utils";
// components
import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view";
import { PageHead } from "@/components/core/page-title";
import { SettingsContentWrapper } from "@/components/settings/content-wrapper";
import { SettingsHeading } from "@/components/settings/heading";
// hooks
import { useProject } from "@/hooks/store/use-project";
import { useWorkspace } from "@/hooks/store/use-workspace";
import { useUserPermissions } from "@/hooks/store/user";
// services
import { WorkspaceAIService } from "@/services/workspace-ai.service";
// local imports
import type { Route } from "./+types/page";
import { AIVoiceTaskerWorkspaceSettingsHeader } from "./header";
const workspaceAIService = new WorkspaceAIService();
type TFormState = {
voice_tasker_enabled: boolean;
transcription_model: string;
structuring_model: string;
default_project_id: string;
access_mode: TWorkspaceAIAccessMode;
max_audio_duration_seconds: number;
per_user_hourly_limit: number;
workspace_hourly_limit: number;
openai_api_key: string;
};
const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({
voice_tasker_enabled: settings?.voice_tasker_enabled ?? false,
transcription_model: settings?.transcription_model ?? "gpt-4o-mini-transcribe",
structuring_model: settings?.structuring_model ?? "gpt-4o-mini",
default_project_id: settings?.default_project_id ?? "",
access_mode: settings?.access_mode ?? "all_workspace_members",
max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120,
per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30,
workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300,
openai_api_key: "",
});
function AIVoiceTaskerSettingsPage({ params }: Route.ComponentProps) {
const { workspaceSlug } = params;
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
const [isSaving, setIsSaving] = useState(false);
const [isTesting, setIsTesting] = useState(false);
// store hooks
const { currentWorkspace } = useWorkspace();
const { fetchProjects, projectMap } = useProject();
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
// derived values
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
const pageTitle = currentWorkspace?.name ? `${currentWorkspace.name} - AI / Voice Tasker` : undefined;
const { data: settings, isLoading } = useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => workspaceAIService.retrieveSettings(workspaceSlug) : null
);
useSWR(
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_PROJECTS_${workspaceSlug}` : null,
canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null
);
const projects = useMemo(
() =>
Object.values(projectMap)
.filter((project) => project.workspace === currentWorkspace?.id && !project.archived_at)
.sort((a, b) => a.name.localeCompare(b.name)),
[currentWorkspace?.id, projectMap]
);
useEffect(() => {
if (settings) setFormState(getInitialFormState(settings));
}, [settings]);
const updateFormValue = <T extends keyof TFormState>(key: T, value: TFormState[T]) => {
setFormState((prev) => ({ ...prev, [key]: value }));
};
const handleSave = async () => {
setIsSaving(true);
const payload: TWorkspaceAISettingsPayload = {
voice_tasker_enabled: formState.voice_tasker_enabled,
transcription_model: formState.transcription_model.trim(),
structuring_model: formState.structuring_model.trim(),
default_project_id: formState.default_project_id || null,
access_mode: formState.access_mode,
max_audio_duration_seconds: formState.max_audio_duration_seconds,
per_user_hourly_limit: formState.per_user_hourly_limit,
workspace_hourly_limit: formState.workspace_hourly_limit,
};
if (formState.openai_api_key.trim()) payload.openai_api_key = formState.openai_api_key.trim();
try {
const response = await workspaceAIService.updateSettings(workspaceSlug, payload);
await mutate(`WORKSPACE_AI_SETTINGS_${workspaceSlug}`, response, false);
setFormState(getInitialFormState(response));
setToast({
type: TOAST_TYPE.SUCCESS,
title: "Настройки Voice Tasker сохранены",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "Не удалось сохранить настройки Voice Tasker",
});
} finally {
setIsSaving(false);
}
};
const handleTestConnection = async () => {
setIsTesting(true);
try {
await workspaceAIService.testConnection(workspaceSlug);
setToast({
type: TOAST_TYPE.SUCCESS,
title: "OpenAI connection OK",
});
} catch {
setToast({
type: TOAST_TYPE.ERROR,
title: "OpenAI connection failed",
});
} finally {
setIsTesting(false);
}
};
if (workspaceUserInfo && !canPerformWorkspaceAdminActions) {
return <NotAuthorizedView section="settings" className="h-auto" />;
}
return (
<SettingsContentWrapper header={<AIVoiceTaskerWorkspaceSettingsHeader />}>
<PageHead title={pageTitle} />
<div className="flex w-full flex-col gap-7">
<SettingsHeading
title="AI / Voice Tasker"
description="Workspace-level настройки голосовой постановки задач. OpenAI key хранится только на backend и не отдается пользователям."
/>
{isLoading || !settings ? (
<div className="rounded-md border-[0.5px] border-subtle bg-layer-1 p-5 text-sm text-secondary">
Загрузка настроек...
</div>
) : (
<>
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
<div className="flex items-start justify-between gap-4 border-b-[0.5px] border-subtle px-5 py-4">
<div className="flex items-start gap-3">
<Mic className="mt-0.5 size-4 text-tertiary" />
<div>
<h3 className="text-sm font-medium text-primary">Voice Tasker</h3>
<p className="mt-1 max-w-2xl text-xs text-tertiary">
Глобальная voice-кнопка будет доступна только после включения функции и сохраненного OpenAI key.
</p>
</div>
</div>
<ToggleSwitch
value={formState.voice_tasker_enabled}
onChange={() => updateFormValue("voice_tasker_enabled", !formState.voice_tasker_enabled)}
size="sm"
/>
</div>
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
<Field label="Provider">
<Input value="OpenAI" disabled className="w-full" />
</Field>
<Field label="Access mode">
<select
value={formState.access_mode}
onChange={(event) => updateFormValue("access_mode", event.target.value as TWorkspaceAIAccessMode)}
className="h-9 w-full rounded-md border-[0.5px] border-subtle bg-layer-2 px-3 text-sm text-primary outline-none"
>
<option value="all_workspace_members">All workspace members</option>
<option value="admins_only">Admins only</option>
</select>
</Field>
<Field label="Default project fallback">
<select
value={formState.default_project_id}
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
className="h-9 w-full rounded-md border-[0.5px] border-subtle bg-layer-2 px-3 text-sm text-primary outline-none"
>
<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>
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
<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="w-full"
/>
</Field>
<Button
variant="secondary"
size="lg"
loading={isTesting}
disabled={!settings.credential.has_key || isSaving}
onClick={handleTestConnection}
>
Test connection
</Button>
</div>
</section>
<section className="rounded-md border-[0.5px] border-subtle bg-layer-1">
<SectionHeader
icon={BrainCircuit}
title="Models and limits"
description="MVP использует один workspace key для транскрибации и структурирования."
/>
<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="w-full"
/>
</Field>
<Field label="Structuring model">
<Input
value={formState.structuring_model}
onChange={(event) => updateFormValue("structuring_model", event.target.value)}
className="w-full"
/>
</Field>
<Field label="Per-user limit">
<NumberInput
value={formState.per_user_hourly_limit}
min={1}
max={1000}
suffix="tasks/hour"
onChange={(value) => updateFormValue("per_user_hourly_limit", value)}
/>
</Field>
<Field label="Workspace limit">
<NumberInput
value={formState.workspace_hourly_limit}
min={1}
max={10000}
suffix="tasks/hour"
onChange={(value) => updateFormValue("workspace_hourly_limit", value)}
/>
</Field>
</div>
</section>
<div className="flex items-center justify-end gap-3">
<Button variant="primary" size="lg" loading={isSaving} disabled={isTesting} onClick={handleSave}>
Сохранить настройки
</Button>
</div>
</>
)}
</div>
</SettingsContentWrapper>
);
}
type TFieldProps = {
children: React.ReactNode;
label: string;
};
function Field({ children, label }: TFieldProps) {
return (
<label className="flex flex-col gap-1.5">
<span className="text-xs font-medium text-secondary">{label}</span>
{children}
</label>
);
}
type TNumberInputProps = {
max: number;
min: number;
onChange: (value: number) => void;
suffix: string;
value: number;
};
function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) {
return (
<div className="flex items-center rounded-md border-[0.5px] border-subtle bg-layer-2">
<input
type="number"
min={min}
max={max}
value={value}
onChange={(event) => onChange(Number(event.target.value))}
className="h-9 min-w-0 flex-1 rounded-md bg-transparent px-3 text-sm text-primary outline-none"
/>
<span className="shrink-0 border-l-[0.5px] border-subtle px-3 text-xs text-tertiary">{suffix}</span>
</div>
);
}
type TSectionHeaderProps = {
description: string;
icon: React.ElementType;
right?: React.ReactNode;
title: string;
};
function SectionHeader({ description, icon: Icon, right, title }: TSectionHeaderProps) {
return (
<div className="flex items-start justify-between gap-4 border-b-[0.5px] border-subtle px-5 py-4">
<div className="flex items-start gap-3">
<Icon className="mt-0.5 size-4 text-tertiary" />
<div>
<h3 className="text-sm font-medium text-primary">{title}</h3>
<p className="mt-1 max-w-2xl text-xs text-tertiary">{description}</p>
</div>
</div>
{right}
</div>
);
}
type TCredentialStatusProps = {
hasKey: boolean;
keyLast4: string;
};
function CredentialStatus({ hasKey, keyLast4 }: TCredentialStatusProps) {
return (
<div
className={cn(
"flex shrink-0 items-center gap-1.5 rounded-md border-[0.5px] px-2.5 py-1 text-xs",
hasKey ? "border-green-500/30 bg-green-500/10 text-green-600" : "border-subtle bg-layer-2 text-tertiary"
)}
>
<ShieldCheck className="size-3.5" />
{hasKey ? `sk-...${keyLast4}` : "No key"}
</div>
);
}
export default observer(AIVoiceTaskerSettingsPage);