ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: ручное назначение Voice Tasker
This commit is contained in:
parent
9243924f78
commit
02d79da6f9
|
|
@ -4,7 +4,7 @@
|
||||||
|
|
||||||
from rest_framework import serializers
|
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 plane.license.utils.encryption import encrypt_data
|
||||||
|
|
||||||
from .base import BaseSerializer
|
from .base import BaseSerializer
|
||||||
|
|
@ -12,6 +12,8 @@ from .base import BaseSerializer
|
||||||
|
|
||||||
class WorkspaceAISettingsSerializer(BaseSerializer):
|
class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
default_project_id = serializers.UUIDField(required=False, allow_null=True)
|
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)
|
openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False)
|
||||||
credential = serializers.SerializerMethodField(read_only=True)
|
credential = serializers.SerializerMethodField(read_only=True)
|
||||||
|
|
||||||
|
|
@ -26,6 +28,8 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
"structuring_model",
|
"structuring_model",
|
||||||
"default_project_id",
|
"default_project_id",
|
||||||
"access_mode",
|
"access_mode",
|
||||||
|
"enabled_project_ids",
|
||||||
|
"enabled_member_ids",
|
||||||
"max_audio_duration_seconds",
|
"max_audio_duration_seconds",
|
||||||
"per_user_hourly_limit",
|
"per_user_hourly_limit",
|
||||||
"workspace_hourly_limit",
|
"workspace_hourly_limit",
|
||||||
|
|
@ -45,6 +49,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
"updated_at": credential.updated_at if credential else None,
|
"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):
|
def validate_default_project_id(self, value):
|
||||||
if value is None:
|
if value is None:
|
||||||
return None
|
return None
|
||||||
|
|
@ -54,6 +64,32 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
raise serializers.ValidationError("Default project must belong to this workspace.")
|
raise serializers.ValidationError("Default project must belong to this workspace.")
|
||||||
return value
|
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):
|
def validate_max_audio_duration_seconds(self, value):
|
||||||
if value < 10 or value > 600:
|
if value < 10 or value > 600:
|
||||||
raise serializers.ValidationError("Max audio duration must be between 10 and 600 seconds.")
|
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):
|
def update(self, instance, validated_data):
|
||||||
api_key = validated_data.pop("openai_api_key", None)
|
api_key = validated_data.pop("openai_api_key", None)
|
||||||
default_project_id = validated_data.pop("default_project_id", serializers.empty)
|
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:
|
if default_project_id is not serializers.empty:
|
||||||
instance.default_project_id = default_project_id
|
instance.default_project_id = default_project_id
|
||||||
|
|
@ -81,6 +119,12 @@ class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||||
|
|
||||||
instance.save()
|
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:
|
if api_key:
|
||||||
cleaned_api_key = api_key.strip()
|
cleaned_api_key = api_key.strip()
|
||||||
credential, _ = WorkspaceAICredential.objects.get_or_create(
|
credential, _ = WorkspaceAICredential.objects.get_or_create(
|
||||||
|
|
|
||||||
|
|
@ -227,7 +227,30 @@ def normalize_audio_content_type(content_type):
|
||||||
return content_type.split(";")[0].strip().lower()
|
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()
|
ai_settings = WorkspaceAISettings.objects.filter(workspace=workspace).first()
|
||||||
workspace_member = WorkspaceMember.objects.filter(workspace=workspace, member=user, is_active=True).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,
|
"max_audio_duration_seconds": 120,
|
||||||
"accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES,
|
"accepted_mime_types": VOICE_TASK_ACCEPTED_AUDIO_TYPES,
|
||||||
"access_mode": "all_workspace_members",
|
"access_mode": "all_workspace_members",
|
||||||
|
"enabled_project_ids": [],
|
||||||
|
"enabled_member_ids": [],
|
||||||
}
|
}
|
||||||
|
|
||||||
if not ai_settings:
|
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["max_audio_duration_seconds"] = ai_settings.max_audio_duration_seconds
|
||||||
response["access_mode"] = ai_settings.access_mode
|
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:
|
if not ai_settings.voice_tasker_enabled:
|
||||||
response["reason"] = "disabled"
|
response["reason"] = "disabled"
|
||||||
|
|
@ -263,6 +290,19 @@ def get_voice_task_preflight(workspace, user):
|
||||||
response["reason"] = "role_denied"
|
response["reason"] = "role_denied"
|
||||||
return response
|
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["available"] = True
|
||||||
response["reason"] = None
|
response["reason"] = None
|
||||||
return response
|
return response
|
||||||
|
|
@ -2332,7 +2372,10 @@ class VoiceTaskPreflightEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def get(self, request, slug):
|
def get(self, request, slug):
|
||||||
workspace = Workspace.objects.get(slug=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):
|
class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
|
|
@ -2341,10 +2384,14 @@ class VoiceTaskParseEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
workspace = Workspace.objects.get(slug=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"]:
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
@ -2512,9 +2559,13 @@ class VoiceTaskCommitEndpoint(BaseAPIView):
|
||||||
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
@allow_permission(allowed_roles=[ROLE.ADMIN, ROLE.MEMBER, ROLE.GUEST], level="WORKSPACE")
|
||||||
def post(self, request, slug):
|
def post(self, request, slug):
|
||||||
workspace = Workspace.objects.get(slug=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"]:
|
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(
|
return Response(
|
||||||
{
|
{
|
||||||
"ok": False,
|
"ok": False,
|
||||||
|
|
|
||||||
|
|
@ -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",
|
||||||
|
),
|
||||||
|
),
|
||||||
|
]
|
||||||
|
|
@ -15,6 +15,8 @@ class WorkspaceAISettings(BaseModel):
|
||||||
class AccessMode(models.TextChoices):
|
class AccessMode(models.TextChoices):
|
||||||
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
|
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
|
||||||
ADMINS_ONLY = "admins_only", "Admins only"
|
ADMINS_ONLY = "admins_only", "Admins only"
|
||||||
|
SELECTED_PROJECTS = "selected_projects", "Selected projects"
|
||||||
|
SELECTED_MEMBERS = "selected_members", "Selected members"
|
||||||
|
|
||||||
workspace = models.OneToOneField(
|
workspace = models.OneToOneField(
|
||||||
"db.Workspace",
|
"db.Workspace",
|
||||||
|
|
@ -37,6 +39,16 @@ class WorkspaceAISettings(BaseModel):
|
||||||
choices=AccessMode.choices,
|
choices=AccessMode.choices,
|
||||||
default=AccessMode.ALL_WORKSPACE_MEMBERS,
|
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)
|
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
|
||||||
per_user_hourly_limit = models.PositiveIntegerField(default=30)
|
per_user_hourly_limit = models.PositiveIntegerField(default=30)
|
||||||
workspace_hourly_limit = models.PositiveIntegerField(default=300)
|
workspace_hourly_limit = models.PositiveIntegerField(default=300)
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,7 @@ const UNAVAILABLE_LABELS = {
|
||||||
missing_api_key: "OpenAI key не сохранен для этого workspace",
|
missing_api_key: "OpenAI key не сохранен для этого workspace",
|
||||||
not_configured: "AI-функции не настроены для этого workspace",
|
not_configured: "AI-функции не настроены для этого workspace",
|
||||||
role_denied: "Voice Task недоступен для вашей роли",
|
role_denied: "Voice Task недоступен для вашей роли",
|
||||||
|
scope_denied: "Voice Task недоступен для текущего проекта или пользователя",
|
||||||
} as const;
|
} as const;
|
||||||
|
|
||||||
const PRIORITY_LABELS: Record<Exclude<TVoiceTaskPriority, null>, string> = {
|
const PRIORITY_LABELS: Record<Exclude<TVoiceTaskPriority, null>, string> = {
|
||||||
|
|
@ -411,7 +412,10 @@ function VoiceTaskWaveform({ mediaRecorder }: { mediaRecorder: MediaRecorder | n
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const root = document.documentElement;
|
const root = document.documentElement;
|
||||||
const accentRgb = getComputedStyle(root).getPropertyValue("--nodedc-accent-rgb").trim();
|
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(", ")})`);
|
if (channels.length === 3) setAccentColor(`rgb(${channels.join(", ")})`);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
@ -762,6 +766,7 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const [hasDraftChanges, setHasDraftChanges] = useState(false);
|
const [hasDraftChanges, setHasDraftChanges] = useState(false);
|
||||||
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
const [selectedTargetIssue, setSelectedTargetIssue] = useState<TVoiceTaskTargetOption | null>(null);
|
||||||
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
||||||
|
const preflightProjectId = activeProjectId ?? getCurrentProjectId();
|
||||||
|
|
||||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
const discardedRecorderRef = useRef<MediaRecorder | null>(null);
|
const discardedRecorderRef = useRef<MediaRecorder | null>(null);
|
||||||
|
|
@ -772,8 +777,8 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
const startedAtRef = useRef(0);
|
const startedAtRef = useRef(0);
|
||||||
|
|
||||||
const { data: preflight } = useSWR(
|
const { data: preflight } = useSWR(
|
||||||
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}` : null,
|
workspaceSlug ? `VOICE_TASK_PREFLIGHT_${workspaceSlug}_${preflightProjectId ?? "workspace"}` : null,
|
||||||
workspaceSlug ? () => workspaceAIService.retrieveVoiceTaskPreflight(workspaceSlug) : null,
|
workspaceSlug ? () => workspaceAIService.retrieveVoiceTaskPreflight(workspaceSlug, preflightProjectId) : null,
|
||||||
{ refreshInterval: 30000 }
|
{ refreshInterval: 30000 }
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|
@ -1191,18 +1196,18 @@ export function VoiceTaskerGlobalControl({ workspaceSlug }: Props) {
|
||||||
<>
|
<>
|
||||||
{isAvailable && dockSlot
|
{isAvailable && dockSlot
|
||||||
? createPortal(
|
? createPortal(
|
||||||
<Tooltip tooltipContent={tooltipContent} position="top">
|
<Tooltip tooltipContent={tooltipContent} position="top">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="nodedc-bottom-dock-voice-button"
|
className="nodedc-bottom-dock-voice-button"
|
||||||
onClick={openVoiceTasker}
|
onClick={openVoiceTasker}
|
||||||
aria-label="Voice Tasker"
|
aria-label="Voice Tasker"
|
||||||
>
|
>
|
||||||
<Mic className="size-4" />
|
<Mic className="size-4" />
|
||||||
</button>
|
</button>
|
||||||
</Tooltip>,
|
</Tooltip>,
|
||||||
dockSlot
|
dockSlot
|
||||||
)
|
)
|
||||||
: null}
|
: null}
|
||||||
|
|
||||||
<ModalCore
|
<ModalCore
|
||||||
|
|
|
||||||
|
|
@ -8,12 +8,18 @@ import { useEffect, useMemo, useState } from "react";
|
||||||
import type { ElementType, ReactNode } from "react";
|
import type { ElementType, ReactNode } from "react";
|
||||||
import { observer } from "mobx-react";
|
import { observer } from "mobx-react";
|
||||||
import useSWR, { mutate } from "swr";
|
import useSWR, { mutate } from "swr";
|
||||||
import { BrainCircuit, KeyRound, Mic, ShieldCheck } from "lucide-react";
|
import { BrainCircuit, Check, FolderKanban, KeyRound, Mic, ShieldCheck, UsersRound } from "lucide-react";
|
||||||
// plane imports
|
// plane imports
|
||||||
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
import { EUserPermissions, EUserPermissionsLevel } from "@plane/constants";
|
||||||
import { Button } from "@plane/propel/button";
|
import { Button } from "@plane/propel/button";
|
||||||
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
import { TOAST_TYPE, setToast } from "@plane/propel/toast";
|
||||||
import type { TWorkspaceAIAccessMode, TWorkspaceAISettings, TWorkspaceAISettingsPayload } from "@plane/types";
|
import type {
|
||||||
|
IProject,
|
||||||
|
IWorkspaceMember,
|
||||||
|
TWorkspaceAIAccessMode,
|
||||||
|
TWorkspaceAISettings,
|
||||||
|
TWorkspaceAISettingsPayload,
|
||||||
|
} from "@plane/types";
|
||||||
import { Input, ToggleSwitch } from "@plane/ui";
|
import { Input, ToggleSwitch } from "@plane/ui";
|
||||||
import { cn } from "@plane/utils";
|
import { cn } from "@plane/utils";
|
||||||
// components
|
// components
|
||||||
|
|
@ -21,6 +27,7 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view
|
||||||
import { SettingsHeading } from "@/components/settings/heading";
|
import { SettingsHeading } from "@/components/settings/heading";
|
||||||
// hooks
|
// hooks
|
||||||
import { useProject } from "@/hooks/store/use-project";
|
import { useProject } from "@/hooks/store/use-project";
|
||||||
|
import { useMember } from "@/hooks/store/use-member";
|
||||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||||
import { useUserPermissions } from "@/hooks/store/user";
|
import { useUserPermissions } from "@/hooks/store/user";
|
||||||
// services
|
// services
|
||||||
|
|
@ -34,6 +41,8 @@ type TFormState = {
|
||||||
structuring_model: string;
|
structuring_model: string;
|
||||||
default_project_id: string;
|
default_project_id: string;
|
||||||
access_mode: TWorkspaceAIAccessMode;
|
access_mode: TWorkspaceAIAccessMode;
|
||||||
|
enabled_project_ids: string[];
|
||||||
|
enabled_member_ids: string[];
|
||||||
max_audio_duration_seconds: number;
|
max_audio_duration_seconds: number;
|
||||||
per_user_hourly_limit: number;
|
per_user_hourly_limit: number;
|
||||||
workspace_hourly_limit: number;
|
workspace_hourly_limit: number;
|
||||||
|
|
@ -51,12 +60,46 @@ const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({
|
||||||
structuring_model: settings?.structuring_model ?? "gpt-4o-mini",
|
structuring_model: settings?.structuring_model ?? "gpt-4o-mini",
|
||||||
default_project_id: settings?.default_project_id ?? "",
|
default_project_id: settings?.default_project_id ?? "",
|
||||||
access_mode: settings?.access_mode ?? "all_workspace_members",
|
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,
|
max_audio_duration_seconds: settings?.max_audio_duration_seconds ?? 120,
|
||||||
per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30,
|
per_user_hourly_limit: settings?.per_user_hourly_limit ?? 30,
|
||||||
workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300,
|
workspace_hourly_limit: settings?.workspace_hourly_limit ?? 300,
|
||||||
openai_api_key: "",
|
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) {
|
export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSettingsContent(props: TProps) {
|
||||||
const { showHeading = true, workspaceSlug } = props;
|
const { showHeading = true, workspaceSlug } = props;
|
||||||
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
|
const [formState, setFormState] = useState<TFormState>(getInitialFormState());
|
||||||
|
|
@ -65,6 +108,9 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
// store hooks
|
// store hooks
|
||||||
const { currentWorkspace } = useWorkspace();
|
const { currentWorkspace } = useWorkspace();
|
||||||
const { fetchProjects, projectMap } = useProject();
|
const { fetchProjects, projectMap } = useProject();
|
||||||
|
const {
|
||||||
|
workspace: { fetchWorkspaceMembers, getWorkspaceMemberDetails, getWorkspaceMemberIds },
|
||||||
|
} = useMember();
|
||||||
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
const { workspaceUserInfo, allowPermissions } = useUserPermissions();
|
||||||
// derived values
|
// derived values
|
||||||
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
const canPerformWorkspaceAdminActions = allowPermissions([EUserPermissions.ADMIN], EUserPermissionsLevel.WORKSPACE);
|
||||||
|
|
@ -79,6 +125,11 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null
|
canPerformWorkspaceAdminActions ? () => fetchProjects(workspaceSlug) : null
|
||||||
);
|
);
|
||||||
|
|
||||||
|
useSWR(
|
||||||
|
canPerformWorkspaceAdminActions ? `WORKSPACE_AI_SETTINGS_MEMBERS_${workspaceSlug}` : null,
|
||||||
|
canPerformWorkspaceAdminActions ? () => fetchWorkspaceMembers(workspaceSlug) : null
|
||||||
|
);
|
||||||
|
|
||||||
const projects = useMemo(
|
const projects = useMemo(
|
||||||
() =>
|
() =>
|
||||||
Object.values(projectMap)
|
Object.values(projectMap)
|
||||||
|
|
@ -87,6 +138,20 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
[currentWorkspace?.id, projectMap]
|
[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(() => {
|
useEffect(() => {
|
||||||
if (settings) setFormState(getInitialFormState(settings));
|
if (settings) setFormState(getInitialFormState(settings));
|
||||||
}, [settings]);
|
}, [settings]);
|
||||||
|
|
@ -95,6 +160,17 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
setFormState((prev) => ({ ...prev, [key]: value }));
|
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 () => {
|
const handleSave = async () => {
|
||||||
setIsSaving(true);
|
setIsSaving(true);
|
||||||
const payload: TWorkspaceAISettingsPayload = {
|
const payload: TWorkspaceAISettingsPayload = {
|
||||||
|
|
@ -103,6 +179,8 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
structuring_model: formState.structuring_model.trim(),
|
structuring_model: formState.structuring_model.trim(),
|
||||||
default_project_id: formState.default_project_id || null,
|
default_project_id: formState.default_project_id || null,
|
||||||
access_mode: formState.access_mode,
|
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,
|
max_audio_duration_seconds: formState.max_audio_duration_seconds,
|
||||||
per_user_hourly_limit: formState.per_user_hourly_limit,
|
per_user_hourly_limit: formState.per_user_hourly_limit,
|
||||||
workspace_hourly_limit: formState.workspace_hourly_limit,
|
workspace_hourly_limit: formState.workspace_hourly_limit,
|
||||||
|
|
@ -113,6 +191,11 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
try {
|
try {
|
||||||
const response = await workspaceAIService.updateSettings(workspaceSlug, payload);
|
const response = await workspaceAIService.updateSettings(workspaceSlug, payload);
|
||||||
await mutate(`WORKSPACE_AI_SETTINGS_${workspaceSlug}`, response, false);
|
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));
|
setFormState(getInitialFormState(response));
|
||||||
setToast({
|
setToast({
|
||||||
type: TOAST_TYPE.SUCCESS,
|
type: TOAST_TYPE.SUCCESS,
|
||||||
|
|
@ -160,7 +243,7 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{isLoading || !settings ? (
|
{isLoading || !settings ? (
|
||||||
<div className="nodedc-settings-card px-5 py-5 text-sm text-secondary">Загрузка настроек...</div>
|
<div className="nodedc-settings-card text-sm px-5 py-5 text-secondary">Загрузка настроек...</div>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
<section className="nodedc-settings-card overflow-hidden">
|
<section className="nodedc-settings-card overflow-hidden">
|
||||||
|
|
@ -177,25 +260,30 @@ export const AIVoiceTaskerSettingsContent = observer(function AIVoiceTaskerSetti
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<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 px-5 py-5 md:grid-cols-2">
|
<div className="grid gap-5 px-5 py-5 md:grid-cols-2">
|
||||||
<Field label="Provider">
|
<Field label="Provider">
|
||||||
<Input value="OpenAI" disabled className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4" />
|
<Input
|
||||||
</Field>
|
value="OpenAI"
|
||||||
<Field label="Access mode">
|
disabled
|
||||||
<select
|
className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4"
|
||||||
value={formState.access_mode}
|
/>
|
||||||
onChange={(event) => updateFormValue("access_mode", event.target.value as TWorkspaceAIAccessMode)}
|
|
||||||
className="nodedc-settings-select w-full px-4 text-sm"
|
|
||||||
>
|
|
||||||
<option value="all_workspace_members">All workspace members</option>
|
|
||||||
<option value="admins_only">Admins only</option>
|
|
||||||
</select>
|
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Default project fallback">
|
<Field label="Default project fallback">
|
||||||
<select
|
<select
|
||||||
value={formState.default_project_id}
|
value={formState.default_project_id}
|
||||||
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
|
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
|
||||||
className="nodedc-settings-select w-full px-4 text-sm"
|
className="nodedc-settings-select text-sm w-full px-4"
|
||||||
>
|
>
|
||||||
<option value="">None</option>
|
<option value="">None</option>
|
||||||
{projects.map((project) => (
|
{projects.map((project) => (
|
||||||
|
|
@ -312,6 +400,167 @@ type TFieldProps = {
|
||||||
label: string;
|
label: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
type TAccessScopeSectionProps = {
|
||||||
|
accessMode: TWorkspaceAIAccessMode;
|
||||||
|
enabledMemberIds: string[];
|
||||||
|
enabledProjectIds: string[];
|
||||||
|
members: IWorkspaceMember[];
|
||||||
|
onAccessModeChange: (value: TWorkspaceAIAccessMode) => void;
|
||||||
|
onToggleMember: (memberId: string) => void;
|
||||||
|
onToggleProject: (projectId: string) => void;
|
||||||
|
projects: IProject[];
|
||||||
|
};
|
||||||
|
|
||||||
|
function AccessScopeSection({
|
||||||
|
accessMode,
|
||||||
|
enabledMemberIds,
|
||||||
|
enabledProjectIds,
|
||||||
|
members,
|
||||||
|
onAccessModeChange,
|
||||||
|
onToggleMember,
|
||||||
|
onToggleProject,
|
||||||
|
projects,
|
||||||
|
}: TAccessScopeSectionProps) {
|
||||||
|
const selectedProjectsCount = enabledProjectIds.length;
|
||||||
|
const selectedMembersCount = enabledMemberIds.length;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="border-t border-white/5 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">
|
||||||
|
Настройте, где и кому будет доступна кнопка Voice Tasker. Проверка выполняется на backend перед записью,
|
||||||
|
разбором и публикацией задачи.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-4">
|
||||||
|
{ACCESS_MODE_OPTIONS.map((option) => {
|
||||||
|
const isSelected = accessMode === option.value;
|
||||||
|
const Icon = option.icon;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
key={option.value}
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAccessModeChange(option.value)}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-[7rem] flex-col items-start justify-between rounded-[1.35rem] bg-white/[0.045] px-4 py-3 text-left transition-colors",
|
||||||
|
isSelected
|
||||||
|
? "bg-[rgba(var(--nodedc-accent-rgb),0.16)] text-primary"
|
||||||
|
: "text-secondary hover:bg-white/[0.07]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid size-9 place-items-center rounded-full",
|
||||||
|
isSelected
|
||||||
|
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
|
||||||
|
: "bg-white/6 text-tertiary"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<Icon className="size-4" />
|
||||||
|
</span>
|
||||||
|
<span>
|
||||||
|
<span className="block text-13 font-semibold">{option.label}</span>
|
||||||
|
<span className="mt-1 block text-11 leading-4 text-tertiary">{option.description}</span>
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{accessMode === "selected_projects" && (
|
||||||
|
<div className="mt-4 rounded-[1.35rem] bg-white/[0.035] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-12 font-semibold tracking-[0.18em] text-tertiary uppercase">Контуры</span>
|
||||||
|
<span className="inline-flex h-11 min-w-[5.75rem] items-center justify-center rounded-full bg-white/[0.06] px-4 text-center text-11 text-secondary">
|
||||||
|
выбрано: {selectedProjectsCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-64 gap-2 overflow-auto pr-1 md:grid-cols-2">
|
||||||
|
{projects.map((project) => (
|
||||||
|
<ScopeToggleRow
|
||||||
|
key={project.id}
|
||||||
|
checked={enabledProjectIds.includes(project.id)}
|
||||||
|
label={project.name}
|
||||||
|
meta={project.identifier}
|
||||||
|
onClick={() => onToggleProject(project.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{projects.length === 0 && <div className="text-12 text-tertiary">Доступных контуров пока нет.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{accessMode === "selected_members" && (
|
||||||
|
<div className="mt-4 rounded-[1.35rem] bg-white/[0.035] p-4">
|
||||||
|
<div className="mb-3 flex items-center justify-between gap-3">
|
||||||
|
<span className="text-12 font-semibold tracking-[0.18em] text-tertiary uppercase">Пользователи</span>
|
||||||
|
<span className="inline-flex h-11 min-w-[5.75rem] items-center justify-center rounded-full bg-white/[0.06] px-4 text-center text-11 text-secondary">
|
||||||
|
выбрано: {selectedMembersCount}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="grid max-h-64 gap-2 overflow-auto pr-1 md:grid-cols-2">
|
||||||
|
{members.map((member) => (
|
||||||
|
<ScopeToggleRow
|
||||||
|
key={member.member.id}
|
||||||
|
checked={enabledMemberIds.includes(member.member.id)}
|
||||||
|
label={member.member.display_name || member.member.email || "Пользователь"}
|
||||||
|
meta={getWorkspaceRoleLabel(member.role)}
|
||||||
|
onClick={() => onToggleMember(member.member.id)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
{members.length === 0 && <div className="text-12 text-tertiary">Активные участники не найдены.</div>}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
type TScopeToggleRowProps = {
|
||||||
|
checked: boolean;
|
||||||
|
label: string;
|
||||||
|
meta?: string | number;
|
||||||
|
onClick: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
function ScopeToggleRow({ checked, label, meta, onClick }: TScopeToggleRowProps) {
|
||||||
|
return (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={onClick}
|
||||||
|
className={cn(
|
||||||
|
"flex min-h-12 items-center gap-3 rounded-2xl bg-white/[0.045] px-3 text-left text-13 transition-colors",
|
||||||
|
checked ? "text-primary" : "text-secondary hover:bg-white/[0.07]"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={cn(
|
||||||
|
"grid size-5 shrink-0 place-items-center rounded-full",
|
||||||
|
checked
|
||||||
|
? "bg-[rgb(var(--nodedc-accent-rgb))] text-[rgb(var(--nodedc-on-accent-rgb))]"
|
||||||
|
: "bg-white/8 text-transparent"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{checked && <Check className="size-3.5" />}
|
||||||
|
</span>
|
||||||
|
<span className="min-w-0 flex-1">
|
||||||
|
<span className="block truncate font-medium">{label}</span>
|
||||||
|
{meta && <span className="block truncate text-11 text-tertiary">{meta}</span>}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getWorkspaceRoleLabel(role: IWorkspaceMember["role"]) {
|
||||||
|
if (Number(role) === EUserPermissions.ADMIN) return "Админ";
|
||||||
|
if (Number(role) === EUserPermissions.MEMBER) return "Участник";
|
||||||
|
if (Number(role) === EUserPermissions.GUEST) return "Гость";
|
||||||
|
return "Участник";
|
||||||
|
}
|
||||||
|
|
||||||
function Field({ children, label }: TFieldProps) {
|
function Field({ children, label }: TFieldProps) {
|
||||||
return (
|
return (
|
||||||
<label className="flex flex-col gap-2.5">
|
<label className="flex flex-col gap-2.5">
|
||||||
|
|
@ -338,9 +587,9 @@ function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) {
|
||||||
max={max}
|
max={max}
|
||||||
value={value}
|
value={value}
|
||||||
onChange={(event) => onChange(Number(event.target.value))}
|
onChange={(event) => onChange(Number(event.target.value))}
|
||||||
className="min-h-12 min-w-0 flex-1 bg-transparent px-4 text-sm text-primary outline-none"
|
className="text-sm min-h-12 min-w-0 flex-1 bg-transparent px-4 text-primary outline-none"
|
||||||
/>
|
/>
|
||||||
<span className="shrink-0 px-4 text-xs text-tertiary">{suffix}</span>
|
<span className="text-xs shrink-0 px-4 text-tertiary">{suffix}</span>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -45,11 +45,30 @@ export class WorkspaceAIService extends APIService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
async retrieveVoiceTaskPreflight(workspaceSlug: string): Promise<TVoiceTaskPreflight> {
|
async retrieveVoiceTaskPreflight(workspaceSlug: string, projectId?: string | null): Promise<TVoiceTaskPreflight> {
|
||||||
return this.get(`/api/workspaces/${workspaceSlug}/voice-task/preflight/`)
|
const params = projectId ? `?project_id=${projectId}` : "";
|
||||||
.then((response) => response?.data)
|
return this.get(
|
||||||
|
`/api/workspaces/${workspaceSlug}/voice-task/preflight/${params}`,
|
||||||
|
{},
|
||||||
|
{ validateStatus: () => true }
|
||||||
|
)
|
||||||
|
.then((response) => {
|
||||||
|
if (response?.status === 200) return response?.data;
|
||||||
|
if (response?.status === 401 || response?.status === 403)
|
||||||
|
return {
|
||||||
|
available: false,
|
||||||
|
reason: "scope_denied",
|
||||||
|
max_audio_duration_seconds: 120,
|
||||||
|
accepted_mime_types: ["audio/webm", "audio/mp4", "audio/mpeg", "audio/wav"],
|
||||||
|
access_mode: "all_workspace_members",
|
||||||
|
enabled_project_ids: [],
|
||||||
|
enabled_member_ids: [],
|
||||||
|
} satisfies TVoiceTaskPreflight;
|
||||||
|
|
||||||
|
throw response?.data;
|
||||||
|
})
|
||||||
.catch((error) => {
|
.catch((error) => {
|
||||||
throw error?.response?.data;
|
throw error?.response?.data ?? error;
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,11 @@ export interface IGptResponse {
|
||||||
}
|
}
|
||||||
|
|
||||||
export type TWorkspaceAIProvider = "openai";
|
export type TWorkspaceAIProvider = "openai";
|
||||||
export type TWorkspaceAIAccessMode = "all_workspace_members" | "admins_only";
|
export type TWorkspaceAIAccessMode =
|
||||||
|
| "all_workspace_members"
|
||||||
|
| "admins_only"
|
||||||
|
| "selected_projects"
|
||||||
|
| "selected_members";
|
||||||
|
|
||||||
export type TWorkspaceAICredentialStatus = {
|
export type TWorkspaceAICredentialStatus = {
|
||||||
provider: TWorkspaceAIProvider;
|
provider: TWorkspaceAIProvider;
|
||||||
|
|
@ -34,6 +38,8 @@ export type TWorkspaceAISettings = {
|
||||||
structuring_model: string;
|
structuring_model: string;
|
||||||
default_project_id: string | null;
|
default_project_id: string | null;
|
||||||
access_mode: TWorkspaceAIAccessMode;
|
access_mode: TWorkspaceAIAccessMode;
|
||||||
|
enabled_project_ids: string[];
|
||||||
|
enabled_member_ids: string[];
|
||||||
max_audio_duration_seconds: number;
|
max_audio_duration_seconds: number;
|
||||||
per_user_hourly_limit: number;
|
per_user_hourly_limit: number;
|
||||||
workspace_hourly_limit: number;
|
workspace_hourly_limit: number;
|
||||||
|
|
@ -50,6 +56,8 @@ export type TWorkspaceAISettingsPayload = Partial<
|
||||||
| "structuring_model"
|
| "structuring_model"
|
||||||
| "default_project_id"
|
| "default_project_id"
|
||||||
| "access_mode"
|
| "access_mode"
|
||||||
|
| "enabled_project_ids"
|
||||||
|
| "enabled_member_ids"
|
||||||
| "max_audio_duration_seconds"
|
| "max_audio_duration_seconds"
|
||||||
| "per_user_hourly_limit"
|
| "per_user_hourly_limit"
|
||||||
| "workspace_hourly_limit"
|
| "workspace_hourly_limit"
|
||||||
|
|
@ -66,7 +74,13 @@ export type TWorkspaceAIConnectionTestResult = {
|
||||||
error?: string;
|
error?: string;
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TVoiceTaskPreflightReason = "not_configured" | "disabled" | "missing_api_key" | "role_denied" | null;
|
export type TVoiceTaskPreflightReason =
|
||||||
|
| "not_configured"
|
||||||
|
| "disabled"
|
||||||
|
| "missing_api_key"
|
||||||
|
| "role_denied"
|
||||||
|
| "scope_denied"
|
||||||
|
| null;
|
||||||
|
|
||||||
export type TVoiceTaskPreflight = {
|
export type TVoiceTaskPreflight = {
|
||||||
available: boolean;
|
available: boolean;
|
||||||
|
|
@ -74,6 +88,8 @@ export type TVoiceTaskPreflight = {
|
||||||
max_audio_duration_seconds: number;
|
max_audio_duration_seconds: number;
|
||||||
accepted_mime_types: string[];
|
accepted_mime_types: string[];
|
||||||
access_mode: TWorkspaceAIAccessMode;
|
access_mode: TWorkspaceAIAccessMode;
|
||||||
|
enabled_project_ids: string[];
|
||||||
|
enabled_member_ids: string[];
|
||||||
};
|
};
|
||||||
|
|
||||||
export type TVoiceTaskIntent = "create_task" | "update_task" | "delete_task" | "unknown";
|
export type TVoiceTaskIntent = "create_task" | "update_task" | "delete_task" | "unknown";
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue