From 02d79da6f9c775a5bd7663bb107286a517551f92 Mon Sep 17 00:00:00 2001 From: DCCONSTRUCTIONS Date: Tue, 28 Apr 2026 11:04:38 +0300 Subject: [PATCH] =?UTF-8?q?=D0=A4=D0=A3=D0=9D=D0=9A=D0=A6=D0=98=D0=98=20-?= =?UTF-8?q?=20=D0=9C=D0=95=D0=96=D0=9F=D0=A0=D0=9E=D0=95=D0=9A=D0=A2=D0=9D?= =?UTF-8?q?=D0=90=D0=AF=20=D0=9A=D0=9E=D0=9C=D0=9C=D0=A3=D0=9D=D0=98=D0=9A?= =?UTF-8?q?=D0=90=D0=A6=D0=98=D0=AF:=20=D1=80=D1=83=D1=87=D0=BD=D0=BE?= =?UTF-8?q?=D0=B5=20=D0=BD=D0=B0=D0=B7=D0=BD=D0=B0=D1=87=D0=B5=D0=BD=D0=B8?= =?UTF-8?q?=D0=B5=20Voice=20Tasker?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../api/plane/app/serializers/voice_tasker.py | 46 ++- .../apps/api/plane/app/views/voice_tasker.py | 63 +++- .../0129_workspace_ai_access_scope.py | 47 +++ .../apps/api/plane/db/models/voice_tasker.py | 12 + .../voice-tasker/global-control.tsx | 35 ++- .../settings/ai-voice-tasker-settings.tsx | 283 ++++++++++++++++-- .../web/core/services/workspace-ai.service.ts | 27 +- plane-src/packages/types/src/ai.ts | 20 +- 8 files changed, 488 insertions(+), 45 deletions(-) create mode 100644 plane-src/apps/api/plane/db/migrations/0129_workspace_ai_access_scope.py diff --git a/plane-src/apps/api/plane/app/serializers/voice_tasker.py b/plane-src/apps/api/plane/app/serializers/voice_tasker.py index 9406143..461fb1f 100644 --- a/plane-src/apps/api/plane/app/serializers/voice_tasker.py +++ b/plane-src/apps/api/plane/app/serializers/voice_tasker.py @@ -4,7 +4,7 @@ from rest_framework import serializers -from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings +from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings, WorkspaceMember from plane.license.utils.encryption import encrypt_data from .base import BaseSerializer @@ -12,6 +12,8 @@ from .base import BaseSerializer class WorkspaceAISettingsSerializer(BaseSerializer): default_project_id = serializers.UUIDField(required=False, allow_null=True) + enabled_project_ids = serializers.ListField(child=serializers.UUIDField(), required=False, write_only=True) + enabled_member_ids = serializers.ListField(child=serializers.UUIDField(), required=False, write_only=True) openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False) credential = serializers.SerializerMethodField(read_only=True) @@ -26,6 +28,8 @@ class WorkspaceAISettingsSerializer(BaseSerializer): "structuring_model", "default_project_id", "access_mode", + "enabled_project_ids", + "enabled_member_ids", "max_audio_duration_seconds", "per_user_hourly_limit", "workspace_hourly_limit", @@ -45,6 +49,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer): "updated_at": credential.updated_at if credential else None, } + def to_representation(self, instance): + data = super().to_representation(instance) + data["enabled_project_ids"] = [str(project_id) for project_id in instance.enabled_projects.values_list("id", flat=True)] + data["enabled_member_ids"] = [str(member_id) for member_id in instance.enabled_members.values_list("id", flat=True)] + return data + def validate_default_project_id(self, value): if value is None: return None @@ -54,6 +64,32 @@ class WorkspaceAISettingsSerializer(BaseSerializer): raise serializers.ValidationError("Default project must belong to this workspace.") return value + def validate_enabled_project_ids(self, value): + workspace = self.context["workspace"] + project_ids = list(dict.fromkeys(value)) + existing_ids = set( + Project.objects.filter(workspace=workspace, id__in=project_ids, archived_at__isnull=True).values_list( + "id", flat=True + ) + ) + if len(existing_ids) != len(project_ids): + raise serializers.ValidationError("All selected projects must belong to this workspace.") + return project_ids + + def validate_enabled_member_ids(self, value): + workspace = self.context["workspace"] + member_ids = list(dict.fromkeys(value)) + existing_ids = set( + WorkspaceMember.objects.filter( + workspace=workspace, + member_id__in=member_ids, + is_active=True, + ).values_list("member_id", flat=True) + ) + if len(existing_ids) != len(member_ids): + raise serializers.ValidationError("All selected members must be active workspace members.") + return member_ids + def validate_max_audio_duration_seconds(self, value): if value < 10 or value > 600: raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.") @@ -72,6 +108,8 @@ class WorkspaceAISettingsSerializer(BaseSerializer): def update(self, instance, validated_data): api_key = validated_data.pop("openai_api_key", None) default_project_id = validated_data.pop("default_project_id", serializers.empty) + enabled_project_ids = validated_data.pop("enabled_project_ids", serializers.empty) + enabled_member_ids = validated_data.pop("enabled_member_ids", serializers.empty) if default_project_id is not serializers.empty: instance.default_project_id = default_project_id @@ -81,6 +119,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer): instance.save() + if enabled_project_ids is not serializers.empty: + instance.enabled_projects.set(enabled_project_ids) + + if enabled_member_ids is not serializers.empty: + instance.enabled_members.set(enabled_member_ids) + if api_key: cleaned_api_key = api_key.strip() credential, _ = WorkspaceAICredential.objects.get_or_create( diff --git a/plane-src/apps/api/plane/app/views/voice_tasker.py b/plane-src/apps/api/plane/app/views/voice_tasker.py index b4dd1e6..c34da2f 100644 --- a/plane-src/apps/api/plane/app/views/voice_tasker.py +++ b/plane-src/apps/api/plane/app/views/voice_tasker.py @@ -227,7 +227,30 @@ def normalize_audio_content_type(content_type): return content_type.split(";")[0].strip().lower() -def get_voice_task_preflight(workspace, user): +def get_request_project_id(request): + project_id = request.query_params.get("project_id") + if project_id: + return project_id + + draft = request.data.get("draft") + if isinstance(draft, dict) and draft.get("project_id"): + return draft.get("project_id") + + client_context = request.data.get("client_context") + if not client_context: + return None + + try: + parsed_context = json.loads(client_context) if isinstance(client_context, str) else client_context + except (TypeError, json.JSONDecodeError): + return None + + if isinstance(parsed_context, dict): + return parsed_context.get("current_project_id") + return None + + +def get_voice_task_preflight(workspace, user, project_id=None): ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first() workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).first() @@ -237,6 +260,8 @@ def get_voice_task_preflight(workspace, user): "max_audio_duration_seconds": 120, "accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES, "access_mode": "all_workspace_members", + "enabled_project_ids": [], + "enabled_member_ids": [], } if not ai_settings: @@ -244,6 +269,8 @@ def get_voice_task_preflight(workspace, user): response["max_audio_duration_seconds"] = ai_settings.max_audio_duration_seconds response["access_mode"] = ai_settings.access_mode + response["enabled_project_ids"] = [str(project_id) for project_id in ai_settings.enabled_projects.values_list("id", flat=True)] + response["enabled_member_ids"] = [str(member_id) for member_id in ai_settings.enabled_members.values_list("id", flat=True)] if not ai_settings.voice_tasker_enabled: response["reason"] = "disabled" @@ -263,6 +290,19 @@ def get_voice_task_preflight(workspace, user): response["reason"] = "role_denied" return response + if ai_settings.access_mode == WorkspaceAISettings.AccessMode.SELECTED_MEMBERS: + if not ai_settings.enabled_members.filter(id=user.id).exists(): + response["reason"] = "scope_denied" + return response + + if ai_settings.access_mode == WorkspaceAISettings.AccessMode.SELECTED_PROJECTS: + if not project_id: + response["reason"] = "scope_denied" + return response + if not ai_settings.enabled_projects.filter(id=project_id, workspace=workspace, archived_at__isnull=True).exists(): + response["reason"] = "scope_denied" + return response + response["available"] = True response["reason"] = None return response @@ -2332,7 +2372,10 @@ class VoiceTaskPreflightEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def get(self, request, slug): workspace = Workspace.objects.get(slug=slug) - return Response(get_voice_task_preflight(workspace, request.user), status=status.HTTP_200_OK) + return Response( + get_voice_task_preflight(workspace, request.user, project_id=get_request_project_id(request)), + status=status.HTTP_200_OK, + ) class VoiceTaskParseEndpoint(BaseAPIView): @@ -2341,10 +2384,14 @@ class VoiceTaskParseEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) - preflight = get_voice_task_preflight(workspace, request.user) + preflight = get_voice_task_preflight(workspace, request.user, project_id=get_request_project_id(request)) if not preflight["available"]: - response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST + response_status = ( + status.HTTP_403_FORBIDDEN + if preflight["reason"] in {"role_denied", "scope_denied"} + else status.HTTP_400_BAD_REQUEST + ) return Response( { "ok": False, @@ -2512,9 +2559,13 @@ class VoiceTaskCommitEndpoint(BaseAPIView): @allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE") def post(self, request, slug): workspace = Workspace.objects.get(slug=slug) - preflight = get_voice_task_preflight(workspace, request.user) + preflight = get_voice_task_preflight(workspace, request.user, project_id=get_request_project_id(request)) if not preflight["available"]: - response_status = status.HTTP_403_FORBIDDEN if preflight["reason"] == "role_denied" else status.HTTP_400_BAD_REQUEST + response_status = ( + status.HTTP_403_FORBIDDEN + if preflight["reason"] in {"role_denied", "scope_denied"} + else status.HTTP_400_BAD_REQUEST + ) return Response( { "ok": False, diff --git a/plane-src/apps/api/plane/db/migrations/0129_workspace_ai_access_scope.py b/plane-src/apps/api/plane/db/migrations/0129_workspace_ai_access_scope.py new file mode 100644 index 0000000..edeb6a4 --- /dev/null +++ b/plane-src/apps/api/plane/db/migrations/0129_workspace_ai_access_scope.py @@ -0,0 +1,47 @@ +# Generated by Codex on 2026-04-28 + +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ("db", "0128_stored_blob_dedup"), + ] + + operations = [ + migrations.AlterField( + model_name="workspaceaisettings", + name="access_mode", + field=models.CharField( + choices=[ + ("all_workspace_members", "All workspace members"), + ("admins_only", "Admins only"), + ("selected_projects", "Selected projects"), + ("selected_members", "Selected members"), + ], + default="all_workspace_members", + max_length=40, + ), + ), + migrations.AddField( + model_name="workspaceaisettings", + name="enabled_members", + field=models.ManyToManyField( + blank=True, + related_name="workspace_ai_feature_settings", + to=settings.AUTH_USER_MODEL, + ), + ), + migrations.AddField( + model_name="workspaceaisettings", + name="enabled_projects", + field=models.ManyToManyField( + blank=True, + related_name="workspace_ai_feature_settings", + to="db.project", + ), + ), + ] diff --git a/plane-src/apps/api/plane/db/models/voice_tasker.py b/plane-src/apps/api/plane/db/models/voice_tasker.py index dde60a7..872547c 100644 --- a/plane-src/apps/api/plane/db/models/voice_tasker.py +++ b/plane-src/apps/api/plane/db/models/voice_tasker.py @@ -15,6 +15,8 @@ class WorkspaceAISettings(BaseModel): class AccessMode(models.TextChoices): ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members" ADMINS_ONLY = "admins_only", "Admins only" + SELECTED_PROJECTS = "selected_projects", "Selected projects" + SELECTED_MEMBERS = "selected_members", "Selected members" workspace = models.OneToOneField( "db.Workspace", @@ -37,6 +39,16 @@ class WorkspaceAISettings(BaseModel): choices=AccessMode.choices, default=AccessMode.ALL_WORKSPACE_MEMBERS, ) + enabled_projects = models.ManyToManyField( + "db.Project", + blank=True, + related_name="workspace_ai_feature_settings", + ) + enabled_members = models.ManyToManyField( + settings.AUTH_USER_MODEL, + blank=True, + related_name="workspace_ai_feature_settings", + ) max_audio_duration_seconds = models.PositiveIntegerField(default=120) per_user_hourly_limit = models.PositiveIntegerField(default=30) workspace_hourly_limit = models.PositiveIntegerField(default=300) diff --git a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx index f340fe8..79fc10d 100644 --- a/plane-src/apps/web/core/components/voice-tasker/global-control.tsx +++ b/plane-src/apps/web/core/components/voice-tasker/global-control.tsx @@ -70,6 +70,7 @@ const UNAVAILABLE_LABELS = { missing_api_key: "OpenAI key не сохранен для этого workspace", not_configured: "AI-функции не настроены для этого workspace", role_denied: "Voice Task недоступен для вашей роли", + scope_denied: "Voice Task недоступен для текущего проекта или пользователя", } as const; const PRIORITY_LABELS: Record, string> = { @@ -411,7 +412,10 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n useEffect(() => { const root = document.documentElement; const accentRgb = getComputedStyle(root).getPropertyValue("--nodedc-accent-rgb").trim(); - const channels = accentRgb.split(/[,\s]+/).filter(Boolean).slice(0, 3); + const channels = accentRgb + .split(/[,\s]+/) + .filter(Boolean) + .slice(0, 3); if (channels.length === 3) setAccentColor(`rgb(${channels.join(", ")})`); }, []); @@ -762,6 +766,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const [hasDraftChanges, setHasDraftChanges] = useState(false); const [selectedTargetIssue, setSelectedTargetIssue] = useState(null); const [dockSlot, setDockSlot] = useState(null); + const preflightProjectId = activeProjectId ?? getCurrentProjectId(); const mediaRecorderRef = useRef(null); const discardedRecorderRef = useRef(null); @@ -772,8 +777,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { const startedAtRef = useRef(0); const { data: preflight } = useSWR( - workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null, - workspaceSlug ? () => workspaceAIService.retrieveVoiceTaskPreflight(workspaceSlug) : null, + workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}_${preflightProjectId ?? "workspace"}` : null, + workspaceSlug ? () => workspaceAIService.retrieveVoiceTaskPreflight(workspaceSlug, preflightProjectId) : null, { refreshInterval: 30000 } ); @@ -1191,18 +1196,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) { <> {isAvailable && dockSlot ? createPortal( - - - , - dockSlot - ) + + + , + dockSlot + ) : null} ({ structuring_model: settings?.structuring_model ?? "gpt-4o-mini", default_project_id: settings?.default_project_id ?? "", access_mode: settings?.access_mode ?? "all_workspace_members", + enabled_project_ids: settings?.enabled_project_ids ?? [], + enabled_member_ids: settings?.enabled_member_ids ?? [], 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: "", }); +const ACCESS_MODE_OPTIONS: { + description: string; + icon: ElementType; + label: string; + value: TWorkspaceAIAccessMode; +}[] = [ + { + value: "all_workspace_members", + label: "Весь workspace", + description: "Доступна всем активным участникам пространства.", + icon: UsersRound, + }, + { + value: "admins_only", + label: "Только админы", + description: "Доступна только администраторам workspace.", + icon: ShieldCheck, + }, + { + value: "selected_projects", + label: "По контурам", + description: "Доступна только внутри выбранных рабочих проектов.", + icon: FolderKanban, + }, + { + value: "selected_members", + label: "По людям", + description: "Доступна только выбранным участникам workspace.", + icon: UsersRound, + }, +]; + export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSettingsContent(props: TProps) { const { showHeading = true, workspaceSlug } = props; const [formState, setFormState] = useState(getInitialFormState()); @@ -65,6 +108,9 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti // store hooks const { currentWorkspace } = useWorkspace(); const { fetchProjects, projectMap } = useProject(); + const { + workspace: { fetchWorkspaceMembers, getWorkspaceMemberDetails, getWorkspaceMemberIds }, + } = useMember(); const { workspaceUserInfo, allowPermissions } = useUserPermissions(); // derived values const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE); @@ -79,6 +125,11 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null ); + useSWR( + canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_MEMBERS_${workspaceSlug}` : null, + canPerformWorkspaceAdminActions ? () => fetchWorkspaceMembers(workspaceSlug) : null + ); + const projects = useMemo( () => Object.values(projectMap) @@ -87,6 +138,20 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti [currentWorkspace?.id, projectMap] ); + const workspaceMembers = useMemo( + () => + getWorkspaceMemberIds(workspaceSlug) + .flatMap((memberId) => { + const member = getWorkspaceMemberDetails(memberId); + if (!member || member.is_active === false || member.member?.is_bot) return []; + return [member]; + }) + .sort((a, b) => + (a.member.display_name || a.member.email || "").localeCompare(b.member.display_name || b.member.email || "") + ), + [getWorkspaceMemberDetails, getWorkspaceMemberIds, workspaceSlug] + ); + useEffect(() => { if (settings) setFormState(getInitialFormState(settings)); }, [settings]); @@ -95,6 +160,17 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti setFormState((prev) => ({ ...prev, [key]: value })); }; + const toggleListValue = (key: "enabled_project_ids" | "enabled_member_ids", value: string) => { + setFormState((prev) => { + const currentValues = prev[key]; + const nextValues = currentValues.includes(value) + ? currentValues.filter((currentValue) => currentValue !== value) + : [...currentValues, value]; + + return { ...prev, [key]: nextValues }; + }); + }; + const handleSave = async () => { setIsSaving(true); const payload: TWorkspaceAISettingsPayload = { @@ -103,6 +179,8 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti structuring_model: formState.structuring_model.trim(), default_project_id: formState.default_project_id || null, access_mode: formState.access_mode, + enabled_project_ids: formState.enabled_project_ids, + enabled_member_ids: formState.enabled_member_ids, 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, @@ -113,6 +191,11 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti try { const response = await workspaceAIService.updateSettings(workspaceSlug, payload); await mutate(`WORKSPACE_AI_SETTINGS_${workspaceSlug}`, response, false); + await mutate( + (key) => typeof key === "string" && key.startsWith(`VOICE_TASK_PREFLIGHT_${workspaceSlug}`), + undefined, + { revalidate: true } + ); setFormState(getInitialFormState(response)); setToast({ type: TOAST_TYPE.SUCCESS, @@ -160,7 +243,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti )} {isLoading || !settings ? ( -
Загрузка настроек...
+
Загрузка настроек...
) : ( <>
@@ -177,25 +260,30 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti } /> + updateFormValue("access_mode", value)} + onToggleMember={(memberId) => toggleListValue("enabled_member_ids", memberId)} + onToggleProject={(projectId) => toggleListValue("enabled_project_ids", projectId)} + /> +
- - - - +