АРХ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: каркас Voice Tasker settings
This commit is contained in:
@@ -125,6 +125,7 @@ from .notification import NotificationSerializer, UserNotificationPreferenceSeri
|
||||
from .exporter import ExporterHistorySerializer
|
||||
|
||||
from .webhook import WebhookSerializer, WebhookLogSerializer
|
||||
from .voice_tasker import WorkspaceAISettingsSerializer
|
||||
|
||||
from .favorite import UserFavoriteSerializer
|
||||
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from rest_framework import serializers
|
||||
|
||||
from plane.db.models import Project, WorkspaceAICredential, WorkspaceAISettings
|
||||
from plane.license.utils.encryption import encrypt_data
|
||||
|
||||
from .base import BaseSerializer
|
||||
|
||||
|
||||
class WorkspaceAISettingsSerializer(BaseSerializer):
|
||||
default_project_id = serializers.UUIDField(required=False, allow_null=True)
|
||||
openai_api_key = serializers.CharField(required=False, allow_blank=True, write_only=True, trim_whitespace=False)
|
||||
credential = serializers.SerializerMethodField(read_only=True)
|
||||
|
||||
class Meta:
|
||||
model = WorkspaceAISettings
|
||||
fields = [
|
||||
"id",
|
||||
"workspace_id",
|
||||
"voice_tasker_enabled",
|
||||
"provider",
|
||||
"transcription_model",
|
||||
"structuring_model",
|
||||
"default_project_id",
|
||||
"access_mode",
|
||||
"max_audio_duration_seconds",
|
||||
"per_user_hourly_limit",
|
||||
"workspace_hourly_limit",
|
||||
"credential",
|
||||
"openai_api_key",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
read_only_fields = ["id", "workspace_id", "provider", "created_at", "updated_at", "credential"]
|
||||
|
||||
def get_credential(self, obj):
|
||||
credential = WorkspaceAICredential.objects.filter(workspace=obj.workspace, provider=obj.provider).first()
|
||||
return {
|
||||
"provider": obj.provider,
|
||||
"has_key": bool(credential and credential.encrypted_api_key and credential.is_active),
|
||||
"key_last4": credential.key_last4 if credential else "",
|
||||
"updated_at": credential.updated_at if credential else None,
|
||||
}
|
||||
|
||||
def validate_default_project_id(self, value):
|
||||
if value is None:
|
||||
return None
|
||||
|
||||
workspace = self.context["workspace"]
|
||||
if not Project.objects.filter(workspace=workspace, id=value, archived_at__isnull=True).exists():
|
||||
raise serializers.ValidationError("Default project must belong to this workspace.")
|
||||
return value
|
||||
|
||||
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.")
|
||||
return value
|
||||
|
||||
def validate_per_user_hourly_limit(self, value):
|
||||
if value < 1 or value > 1000:
|
||||
raise serializers.ValidationError("Per-user hourly limit must be between 1 and 1000.")
|
||||
return value
|
||||
|
||||
def validate_workspace_hourly_limit(self, value):
|
||||
if value < 1 or value > 10000:
|
||||
raise serializers.ValidationError("Workspace hourly limit must be between 1 and 10000.")
|
||||
return value
|
||||
|
||||
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)
|
||||
|
||||
if default_project_id is not serializers.empty:
|
||||
instance.default_project_id = default_project_id
|
||||
|
||||
for key, value in validated_data.items():
|
||||
setattr(instance, key, value)
|
||||
|
||||
instance.save()
|
||||
|
||||
if api_key:
|
||||
cleaned_api_key = api_key.strip()
|
||||
credential, _ = WorkspaceAICredential.objects.get_or_create(
|
||||
workspace=instance.workspace,
|
||||
provider=instance.provider,
|
||||
)
|
||||
credential.encrypted_api_key = encrypt_data(cleaned_api_key)
|
||||
credential.key_last4 = cleaned_api_key[-4:] if len(cleaned_api_key) >= 4 else cleaned_api_key
|
||||
credential.is_active = True
|
||||
credential.save()
|
||||
|
||||
return instance
|
||||
@@ -23,6 +23,7 @@ from .webhook import urlpatterns as webhook_urls
|
||||
from .workspace import urlpatterns as workspace_urls
|
||||
from .timezone import urlpatterns as timezone_urls
|
||||
from .exporter import urlpatterns as exporter_urls
|
||||
from .voice_tasker import urlpatterns as voice_tasker_urls
|
||||
|
||||
urlpatterns = [
|
||||
*analytic_urls,
|
||||
@@ -46,4 +47,5 @@ urlpatterns = [
|
||||
*webhook_urls,
|
||||
*timezone_urls,
|
||||
*exporter_urls,
|
||||
*voice_tasker_urls,
|
||||
]
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.urls import path
|
||||
|
||||
from plane.app.views import (
|
||||
WorkspaceAISettingsEndpoint,
|
||||
WorkspaceAISettingsTestConnectionEndpoint,
|
||||
)
|
||||
|
||||
|
||||
urlpatterns = [
|
||||
path(
|
||||
"workspaces/<str:slug>/voice-tasker/settings/",
|
||||
WorkspaceAISettingsEndpoint.as_view(),
|
||||
name="voice-tasker-settings",
|
||||
),
|
||||
path(
|
||||
"workspaces/<str:slug>/voice-tasker/settings/test-connection/",
|
||||
WorkspaceAISettingsTestConnectionEndpoint.as_view(),
|
||||
name="voice-tasker-settings-test-connection",
|
||||
),
|
||||
]
|
||||
@@ -243,6 +243,11 @@ from .webhook.base import (
|
||||
WebhookSecretRegenerateEndpoint,
|
||||
)
|
||||
|
||||
from .voice_tasker import (
|
||||
WorkspaceAISettingsEndpoint,
|
||||
WorkspaceAISettingsTestConnectionEndpoint,
|
||||
)
|
||||
|
||||
from .error_404 import custom_404_view
|
||||
|
||||
from .notification.base import MarkAllReadNotificationViewSet
|
||||
|
||||
@@ -0,0 +1,107 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from openai import OpenAI
|
||||
|
||||
from rest_framework import status
|
||||
from rest_framework.response import Response
|
||||
|
||||
from plane.app.permissions import ROLE, allow_permission
|
||||
from plane.app.serializers import WorkspaceAISettingsSerializer
|
||||
from plane.db.models import Workspace, WorkspaceAICredential, WorkspaceAISettings
|
||||
from plane.license.utils.encryption import decrypt_data
|
||||
from plane.utils.exception_logger import log_exception
|
||||
|
||||
from .base import BaseAPIView
|
||||
|
||||
|
||||
class WorkspaceAISettingsEndpoint(BaseAPIView):
|
||||
def get_settings(self, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
|
||||
return workspace, ai_settings
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def get(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
serializer = WorkspaceAISettingsSerializer(ai_settings, context={"workspace": workspace})
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def patch(self, request, slug):
|
||||
workspace, ai_settings = self.get_settings(slug)
|
||||
serializer = WorkspaceAISettingsSerializer(
|
||||
ai_settings,
|
||||
data=request.data,
|
||||
partial=True,
|
||||
context={"workspace": workspace},
|
||||
)
|
||||
if serializer.is_valid():
|
||||
serializer.save()
|
||||
return Response(serializer.data, status=status.HTTP_200_OK)
|
||||
return Response(serializer.errors, status=status.HTTP_400_BAD_REQUEST)
|
||||
|
||||
|
||||
class WorkspaceAISettingsTestConnectionEndpoint(BaseAPIView):
|
||||
@allow_permission(allowed_roles=[ROLE.ADMIN], level="WORKSPACE")
|
||||
def post(self, request, slug):
|
||||
workspace = Workspace.objects.get(slug=slug)
|
||||
ai_settings, _ = WorkspaceAISettings.objects.get_or_create(workspace=workspace)
|
||||
credential = WorkspaceAICredential.objects.filter(
|
||||
workspace=workspace,
|
||||
provider=ai_settings.provider,
|
||||
is_active=True,
|
||||
).first()
|
||||
|
||||
if not credential or not credential.encrypted_api_key:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "missing_api_key",
|
||||
"error": "OpenAI API key is not configured for this workspace.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
api_key = decrypt_data(credential.encrypted_api_key)
|
||||
if not api_key:
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": "invalid_encrypted_key",
|
||||
"error": "OpenAI API key could not be decrypted.",
|
||||
},
|
||||
status=status.HTTP_400_BAD_REQUEST,
|
||||
)
|
||||
|
||||
try:
|
||||
client = OpenAI(api_key=api_key)
|
||||
client.models.retrieve(ai_settings.structuring_model)
|
||||
return Response(
|
||||
{
|
||||
"ok": True,
|
||||
"provider": ai_settings.provider,
|
||||
"model": ai_settings.structuring_model,
|
||||
},
|
||||
status=status.HTTP_200_OK,
|
||||
)
|
||||
except Exception as exc:
|
||||
log_exception(exc)
|
||||
error_type = exc.__class__.__name__
|
||||
status_code = status.HTTP_400_BAD_REQUEST
|
||||
error_code = "openai_connection_failed"
|
||||
if error_type == "AuthenticationError":
|
||||
error_code = "invalid_api_key"
|
||||
elif error_type == "RateLimitError":
|
||||
error_code = "rate_limited"
|
||||
status_code = status.HTTP_429_TOO_MANY_REQUESTS
|
||||
|
||||
return Response(
|
||||
{
|
||||
"ok": False,
|
||||
"code": error_code,
|
||||
"error": "OpenAI connection check failed.",
|
||||
},
|
||||
status=status_code,
|
||||
)
|
||||
@@ -0,0 +1,187 @@
|
||||
# Generated by Codex on 2026-04-24
|
||||
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
import uuid
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
("db", "0123_force_profile_language_ru"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceAICredential",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
(
|
||||
"provider",
|
||||
models.CharField(
|
||||
choices=[("openai", "OpenAI")],
|
||||
default="openai",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
("encrypted_api_key", models.TextField(blank=True)),
|
||||
("key_last4", models.CharField(blank=True, max_length=4)),
|
||||
("is_active", models.BooleanField(default=True)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ai_credential",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace AI Credential",
|
||||
"verbose_name_plural": "Workspace AI Credentials",
|
||||
"db_table": "workspace_ai_credentials",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name="WorkspaceAISettings",
|
||||
fields=[
|
||||
(
|
||||
"created_at",
|
||||
models.DateTimeField(auto_now_add=True, verbose_name="Created At"),
|
||||
),
|
||||
(
|
||||
"updated_at",
|
||||
models.DateTimeField(auto_now=True, verbose_name="Last Modified At"),
|
||||
),
|
||||
("deleted_at", models.DateTimeField(blank=True, editable=False, null=True)),
|
||||
(
|
||||
"id",
|
||||
models.UUIDField(
|
||||
db_index=True,
|
||||
default=uuid.uuid4,
|
||||
editable=False,
|
||||
primary_key=True,
|
||||
serialize=False,
|
||||
unique=True,
|
||||
),
|
||||
),
|
||||
("voice_tasker_enabled", models.BooleanField(default=False)),
|
||||
(
|
||||
"provider",
|
||||
models.CharField(
|
||||
choices=[("openai", "OpenAI")],
|
||||
default="openai",
|
||||
max_length=32,
|
||||
),
|
||||
),
|
||||
(
|
||||
"transcription_model",
|
||||
models.CharField(default="gpt-4o-mini-transcribe", max_length=80),
|
||||
),
|
||||
(
|
||||
"structuring_model",
|
||||
models.CharField(default="gpt-4o-mini", max_length=80),
|
||||
),
|
||||
(
|
||||
"access_mode",
|
||||
models.CharField(
|
||||
choices=[
|
||||
("all_workspace_members", "All workspace members"),
|
||||
("admins_only", "Admins only"),
|
||||
],
|
||||
default="all_workspace_members",
|
||||
max_length=40,
|
||||
),
|
||||
),
|
||||
("max_audio_duration_seconds", models.PositiveIntegerField(default=120)),
|
||||
("per_user_hourly_limit", models.PositiveIntegerField(default=30)),
|
||||
("workspace_hourly_limit", models.PositiveIntegerField(default=300)),
|
||||
(
|
||||
"created_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_created_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Created By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"default_project",
|
||||
models.ForeignKey(
|
||||
blank=True,
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="workspace_ai_default_project",
|
||||
to="db.project",
|
||||
),
|
||||
),
|
||||
(
|
||||
"updated_by",
|
||||
models.ForeignKey(
|
||||
null=True,
|
||||
on_delete=django.db.models.deletion.SET_NULL,
|
||||
related_name="%(class)s_updated_by",
|
||||
to=settings.AUTH_USER_MODEL,
|
||||
verbose_name="Last Modified By",
|
||||
),
|
||||
),
|
||||
(
|
||||
"workspace",
|
||||
models.OneToOneField(
|
||||
on_delete=django.db.models.deletion.CASCADE,
|
||||
related_name="ai_settings",
|
||||
to="db.workspace",
|
||||
),
|
||||
),
|
||||
],
|
||||
options={
|
||||
"verbose_name": "Workspace AI Settings",
|
||||
"verbose_name_plural": "Workspace AI Settings",
|
||||
"db_table": "workspace_ai_settings",
|
||||
"ordering": ("-created_at",),
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -65,6 +65,7 @@ from .state import State, StateGroup, DEFAULT_STATES
|
||||
from .user import Account, Profile, User, BotTypeEnum
|
||||
from .view import IssueView
|
||||
from .webhook import Webhook, WebhookLog
|
||||
from .voice_tasker import WorkspaceAICredential, WorkspaceAISettings
|
||||
from .workspace import (
|
||||
Workspace,
|
||||
WorkspaceBaseModel,
|
||||
|
||||
@@ -0,0 +1,74 @@
|
||||
# Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
# SPDX-License-Identifier: AGPL-3.0-only
|
||||
# See the LICENSE file for details.
|
||||
|
||||
from django.db import models
|
||||
|
||||
from .base import BaseModel
|
||||
|
||||
|
||||
class WorkspaceAISettings(BaseModel):
|
||||
class Provider(models.TextChoices):
|
||||
OPENAI = "openai", "OpenAI"
|
||||
|
||||
class AccessMode(models.TextChoices):
|
||||
ALL_WORKSPACE_MEMBERS = "all_workspace_members", "All workspace members"
|
||||
ADMINS_ONLY = "admins_only", "Admins only"
|
||||
|
||||
workspace = models.OneToOneField(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ai_settings",
|
||||
)
|
||||
voice_tasker_enabled = models.BooleanField(default=False)
|
||||
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
|
||||
transcription_model = models.CharField(max_length=80, default="gpt-4o-mini-transcribe")
|
||||
structuring_model = models.CharField(max_length=80, default="gpt-4o-mini")
|
||||
default_project = models.ForeignKey(
|
||||
"db.Project",
|
||||
on_delete=models.SET_NULL,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name="workspace_ai_default_project",
|
||||
)
|
||||
access_mode = models.CharField(
|
||||
max_length=40,
|
||||
choices=AccessMode.choices,
|
||||
default=AccessMode.ALL_WORKSPACE_MEMBERS,
|
||||
)
|
||||
max_audio_duration_seconds = models.PositiveIntegerField(default=120)
|
||||
per_user_hourly_limit = models.PositiveIntegerField(default=30)
|
||||
workspace_hourly_limit = models.PositiveIntegerField(default=300)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace AI Settings"
|
||||
verbose_name_plural = "Workspace AI Settings"
|
||||
db_table = "workspace_ai_settings"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} AI settings"
|
||||
|
||||
|
||||
class WorkspaceAICredential(BaseModel):
|
||||
class Provider(models.TextChoices):
|
||||
OPENAI = "openai", "OpenAI"
|
||||
|
||||
workspace = models.OneToOneField(
|
||||
"db.Workspace",
|
||||
on_delete=models.CASCADE,
|
||||
related_name="ai_credential",
|
||||
)
|
||||
provider = models.CharField(max_length=32, choices=Provider.choices, default=Provider.OPENAI)
|
||||
encrypted_api_key = models.TextField(blank=True)
|
||||
key_last4 = models.CharField(max_length=4, blank=True)
|
||||
is_active = models.BooleanField(default=True)
|
||||
|
||||
class Meta:
|
||||
verbose_name = "Workspace AI Credential"
|
||||
verbose_name_plural = "Workspace AI Credentials"
|
||||
db_table = "workspace_ai_credentials"
|
||||
ordering = ("-created_at",)
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.workspace.slug} {self.provider} credential"
|
||||
+40
@@ -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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
});
|
||||
+393
@@ -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);
|
||||
@@ -289,6 +289,10 @@ export const coreRoutes: RouteConfigEntry[] = [
|
||||
":workspaceSlug/settings/webhooks/:webhookId",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/webhooks/[webhookId]/page.tsx"
|
||||
),
|
||||
route(
|
||||
":workspaceSlug/settings/ai-voice-tasker",
|
||||
"./(all)/[workspaceSlug]/(settings)/settings/(workspace)/ai-voice-tasker/page.tsx"
|
||||
),
|
||||
]),
|
||||
|
||||
// --------------------------------------------------------------------
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
*/
|
||||
|
||||
import type { LucideIcon } from "lucide-react";
|
||||
import { ArrowUpToLine, Building, CreditCard, Users, Webhook } from "lucide-react";
|
||||
import { ArrowUpToLine, Building, CreditCard, Mic, Users, Webhook } from "lucide-react";
|
||||
// plane imports
|
||||
import type { ISvgIcons } from "@plane/propel/icons";
|
||||
import type { TWorkspaceSettingsTabs } from "@plane/types";
|
||||
@@ -16,4 +16,5 @@ export const WORKSPACE_SETTINGS_ICONS: Record<TWorkspaceSettingsTabs, LucideIcon
|
||||
export: ArrowUpToLine,
|
||||
"billing-and-plans": CreditCard,
|
||||
webhooks: Webhook,
|
||||
"ai-voice-tasker": Mic,
|
||||
};
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
/**
|
||||
* Copyright (c) 2023-present Plane Software, Inc. and contributors
|
||||
* SPDX-License-Identifier: AGPL-3.0-only
|
||||
* See the LICENSE file for details.
|
||||
*/
|
||||
|
||||
import { API_BASE_URL } from "@plane/constants";
|
||||
import type {
|
||||
TWorkspaceAIConnectionTestResult,
|
||||
TWorkspaceAISettings,
|
||||
TWorkspaceAISettingsPayload,
|
||||
} from "@plane/types";
|
||||
import { APIService } from "@/services/api.service";
|
||||
|
||||
export class WorkspaceAIService extends APIService {
|
||||
constructor() {
|
||||
super(API_BASE_URL);
|
||||
}
|
||||
|
||||
async retrieveSettings(workspaceSlug: string): Promise<TWorkspaceAISettings> {
|
||||
return this.get(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async updateSettings(
|
||||
workspaceSlug: string,
|
||||
data: TWorkspaceAISettingsPayload
|
||||
): Promise<TWorkspaceAISettings> {
|
||||
return this.patch(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/`, data)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
|
||||
async testConnection(workspaceSlug: string): Promise<TWorkspaceAIConnectionTestResult> {
|
||||
return this.post(`/api/workspaces/${workspaceSlug}/voice-tasker/settings/test-connection/`)
|
||||
.then((response) => response?.data)
|
||||
.catch((error) => {
|
||||
throw error?.response?.data;
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user