ФУНКЦИИ - МЕЖПРОЕКТНАЯ КОММУНИКАЦИЯ: ручное назначение Voice Tasker
This commit is contained in:
@@ -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<Exclude<TVoiceTaskPriority, null>, 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<TVoiceTaskTargetOption | null>(null);
|
||||
const [dockSlot, setDockSlot] = useState<Element | null>(null);
|
||||
const preflightProjectId = activeProjectId ?? getCurrentProjectId();
|
||||
|
||||
const mediaRecorderRef = useRef<MediaRecorder | null>(null);
|
||||
const discardedRecorderRef = useRef<MediaRecorder | null>(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(
|
||||
<Tooltip tooltipContent={tooltipContent} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-bottom-dock-voice-button"
|
||||
onClick={openVoiceTasker}
|
||||
aria-label="Voice Tasker"
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
</button>
|
||||
</Tooltip>,
|
||||
dockSlot
|
||||
)
|
||||
<Tooltip tooltipContent={tooltipContent} position="top">
|
||||
<button
|
||||
type="button"
|
||||
className="nodedc-bottom-dock-voice-button"
|
||||
onClick={openVoiceTasker}
|
||||
aria-label="Voice Tasker"
|
||||
>
|
||||
<Mic className="size-4" />
|
||||
</button>
|
||||
</Tooltip>,
|
||||
dockSlot
|
||||
)
|
||||
: null}
|
||||
|
||||
<ModalCore
|
||||
|
||||
+266
-17
@@ -8,12 +8,18 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import type { ElementType, ReactNode } from "react";
|
||||
import { observer } from "mobx-react";
|
||||
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
|
||||
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 type {
|
||||
IProject,
|
||||
IWorkspaceMember,
|
||||
TWorkspaceAIAccessMode,
|
||||
TWorkspaceAISettings,
|
||||
TWorkspaceAISettingsPayload,
|
||||
} from "@plane/types";
|
||||
import { Input, ToggleSwitch } from "@plane/ui";
|
||||
import { cn } from "@plane/utils";
|
||||
// components
|
||||
@@ -21,6 +27,7 @@ import { NotAuthorizedView } from "@/components/auth-screens/not-authorized-view
|
||||
import { SettingsHeading } from "@/components/settings/heading";
|
||||
// hooks
|
||||
import { useProject } from "@/hooks/store/use-project";
|
||||
import { useMember } from "@/hooks/store/use-member";
|
||||
import { useWorkspace } from "@/hooks/store/use-workspace";
|
||||
import { useUserPermissions } from "@/hooks/store/user";
|
||||
// services
|
||||
@@ -34,6 +41,8 @@ type TFormState = {
|
||||
structuring_model: string;
|
||||
default_project_id: string;
|
||||
access_mode: TWorkspaceAIAccessMode;
|
||||
enabled_project_ids: string[];
|
||||
enabled_member_ids: string[];
|
||||
max_audio_duration_seconds: number;
|
||||
per_user_hourly_limit: number;
|
||||
workspace_hourly_limit: number;
|
||||
@@ -51,12 +60,46 @@ const getInitialFormState = (settings?: TWorkspaceAISettings): TFormState => ({
|
||||
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<TFormState>(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 ? (
|
||||
<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">
|
||||
@@ -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">
|
||||
<Field label="Provider">
|
||||
<Input value="OpenAI" disabled className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4" />
|
||||
</Field>
|
||||
<Field label="Access mode">
|
||||
<select
|
||||
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>
|
||||
<Input
|
||||
value="OpenAI"
|
||||
disabled
|
||||
className="nodedc-settings-input w-full cursor-not-allowed !bg-white/4"
|
||||
/>
|
||||
</Field>
|
||||
<Field label="Default project fallback">
|
||||
<select
|
||||
value={formState.default_project_id}
|
||||
onChange={(event) => updateFormValue("default_project_id", event.target.value)}
|
||||
className="nodedc-settings-select w-full px-4 text-sm"
|
||||
className="nodedc-settings-select text-sm w-full px-4"
|
||||
>
|
||||
<option value="">None</option>
|
||||
{projects.map((project) => (
|
||||
@@ -312,6 +400,167 @@ type TFieldProps = {
|
||||
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) {
|
||||
return (
|
||||
<label className="flex flex-col gap-2.5">
|
||||
@@ -338,9 +587,9 @@ function NumberInput({ max, min, onChange, suffix, value }: TNumberInputProps) {
|
||||
max={max}
|
||||
value={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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user